kyd-shared-badge 0.3.3 → 0.3.5
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 +1 -1
- package/src/ambient-ai.d.ts +1 -0
- package/src/chat/ChatWidget.tsx +28 -5
- package/src/chat/ChatWindowStreaming.tsx +15 -5
- package/src/components/ReportHeader.tsx +2 -2
- package/src/lib/chat-store.ts +4 -0
- package/src/lib/context.ts +24 -20
- package/src/lib/rate-limit.ts +2 -2
- package/src/lib/routes.ts +17 -18
- package/src/lib/auth-verify.ts +0 -48
package/package.json
CHANGED
package/src/ambient-ai.d.ts
CHANGED
package/src/chat/ChatWidget.tsx
CHANGED
|
@@ -47,11 +47,33 @@ export default function ChatWidget({ api = '/api/chat', title = 'KYD Bot', hintT
|
|
|
47
47
|
const [headerTop, setHeaderTop] = useState(0);
|
|
48
48
|
const [showHint, setShowHint] = useState(false);
|
|
49
49
|
const { messages, input, setInput, sending, sendMessage, cancel } = useChatStreaming({ api, badgeId });
|
|
50
|
-
const
|
|
50
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
51
|
+
const [autoScroll, setAutoScroll] = useState(true);
|
|
51
52
|
|
|
53
|
+
// Detect user scroll position to toggle auto-scroll
|
|
52
54
|
useEffect(() => {
|
|
53
|
-
if (
|
|
54
|
-
|
|
55
|
+
if (!open) return;
|
|
56
|
+
const root = containerRef.current;
|
|
57
|
+
if (!root) return;
|
|
58
|
+
const scrollEl = root.querySelector('.cs-message-list') as HTMLElement | null;
|
|
59
|
+
if (!scrollEl) return;
|
|
60
|
+
const onScroll = () => {
|
|
61
|
+
const nearBottom = scrollEl.scrollTop + scrollEl.clientHeight >= scrollEl.scrollHeight - 8;
|
|
62
|
+
setAutoScroll(nearBottom);
|
|
63
|
+
};
|
|
64
|
+
scrollEl.addEventListener('scroll', onScroll, { passive: true } as AddEventListenerOptions);
|
|
65
|
+
return () => scrollEl.removeEventListener('scroll', onScroll);
|
|
66
|
+
}, [open]);
|
|
67
|
+
|
|
68
|
+
// Keep scrolled to bottom only when autoScroll is enabled
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (!open) return;
|
|
71
|
+
if (!autoScroll) return;
|
|
72
|
+
const root = containerRef.current;
|
|
73
|
+
if (!root) return;
|
|
74
|
+
const scrollEl = root.querySelector('.cs-message-list') as HTMLElement | null;
|
|
75
|
+
if (scrollEl) scrollEl.scrollTop = scrollEl.scrollHeight;
|
|
76
|
+
}, [messages, open, autoScroll]);
|
|
55
77
|
|
|
56
78
|
// Optional hint (only shows if user hasn't dismissed before and when collapsed)
|
|
57
79
|
useEffect(() => {
|
|
@@ -208,6 +230,7 @@ export default function ChatWidget({ api = '/api/chat', title = 'KYD Bot', hintT
|
|
|
208
230
|
aria-label={'Chat sidebar'}
|
|
209
231
|
style={{ position: 'fixed', top: headerTop, right: 0, bottom: 0, width, maxWidth: '92vw' }}
|
|
210
232
|
className={'shadow-xl border flex flex-col overflow-hidden'}
|
|
233
|
+
ref={containerRef}
|
|
211
234
|
>
|
|
212
235
|
{/* Left-edge resizer */}
|
|
213
236
|
<div
|
|
@@ -222,7 +245,7 @@ export default function ChatWidget({ api = '/api/chat', title = 'KYD Bot', hintT
|
|
|
222
245
|
<div className={'flex-1'} style={{ minHeight: 0 }}>
|
|
223
246
|
<MainContainer style={{ height: '100%', position: 'relative', background: 'var(--content-card-background)', border: 'none'}}>
|
|
224
247
|
<ChatContainer style={{ height: '100%' }}>
|
|
225
|
-
<MessageList typingIndicator={undefined} autoScrollToBottom>
|
|
248
|
+
<MessageList typingIndicator={undefined} autoScrollToBottom={autoScroll}>
|
|
226
249
|
{messages.length === 0 && (
|
|
227
250
|
<Message model={{ message: 'Start a conversation. I can answer questions about this developer’s report.', sender: 'KYD', direction: 'incoming', position: 'single' }} />
|
|
228
251
|
)}
|
|
@@ -272,7 +295,7 @@ export default function ChatWidget({ api = '/api/chat', title = 'KYD Bot', hintT
|
|
|
272
295
|
</ChatContainer>
|
|
273
296
|
</MainContainer>
|
|
274
297
|
</div>
|
|
275
|
-
<form onSubmit={(e) => { e.preventDefault(); if (!sending && input.trim()) sendMessage(); }}>
|
|
298
|
+
<form onSubmit={(e) => { e.preventDefault(); if (!sending && input.trim()) { setAutoScroll(true); sendMessage(); } }}>
|
|
276
299
|
<div className={'flex items-end gap-2 p-2 border-t'} style={{ borderColor: 'var(--icon-button-secondary)', background: 'var(--content-card-background)' }}>
|
|
277
300
|
<input
|
|
278
301
|
aria-label={'Type your message'}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useEffect, useRef } from 'react';
|
|
3
|
+
import { useEffect, useRef, useState } from 'react';
|
|
4
4
|
import './chat-overrides.css';
|
|
5
5
|
import { FiSend } from 'react-icons/fi';
|
|
6
6
|
import { useChatStreaming } from './useChatStreaming';
|
|
@@ -8,14 +8,24 @@ import { useChatStreaming } from './useChatStreaming';
|
|
|
8
8
|
export default function ChatWindowStreaming({ api = '/api/chat', badgeId }: { api?: string, badgeId: string }) {
|
|
9
9
|
const { messages, input, setInput, sending, sendMessage } = useChatStreaming({ api, badgeId });
|
|
10
10
|
const listRef = useRef<HTMLDivElement>(null);
|
|
11
|
+
const autoScrollRef = useRef<boolean>(true);
|
|
12
|
+
|
|
13
|
+
const handleScroll = () => {
|
|
14
|
+
const el = listRef.current;
|
|
15
|
+
if (!el) return;
|
|
16
|
+
const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 8;
|
|
17
|
+
autoScrollRef.current = nearBottom;
|
|
18
|
+
};
|
|
11
19
|
|
|
12
20
|
useEffect(() => {
|
|
13
|
-
if (listRef.current
|
|
21
|
+
if (listRef.current && autoScrollRef.current) {
|
|
22
|
+
listRef.current.scrollTop = listRef.current.scrollHeight;
|
|
23
|
+
}
|
|
14
24
|
}, [messages]);
|
|
15
25
|
|
|
16
26
|
return (
|
|
17
27
|
<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">
|
|
28
|
+
<div ref={listRef} className="p-4 space-y-3 h-72 overflow-auto" onScroll={handleScroll}>
|
|
19
29
|
{messages.map(m => (
|
|
20
30
|
<div key={m.id} className={m.role === 'user' ? 'text-right' : 'text-left'}>
|
|
21
31
|
<div
|
|
@@ -35,13 +45,13 @@ export default function ChatWindowStreaming({ api = '/api/chat', badgeId }: { ap
|
|
|
35
45
|
<input
|
|
36
46
|
value={input}
|
|
37
47
|
onChange={e=>setInput(e.target.value)}
|
|
38
|
-
onKeyDown={e=>{ if (e.key==='Enter') sendMessage(); }}
|
|
48
|
+
onKeyDown={e=>{ if (e.key==='Enter') { autoScrollRef.current = true; sendMessage(); } }}
|
|
39
49
|
className="flex-1 px-3 py-2 rounded border kyd-chat-input"
|
|
40
50
|
style={{ background: 'var(--input-background)', color: 'var(--text-main)', borderColor: 'var(--icon-button-secondary)' }}
|
|
41
51
|
placeholder="Ask KYD…"
|
|
42
52
|
/>
|
|
43
53
|
<button
|
|
44
|
-
onClick={()=>sendMessage()}
|
|
54
|
+
onClick={()=>{ autoScrollRef.current = true; sendMessage(); }}
|
|
45
55
|
disabled={sending}
|
|
46
56
|
className="px-3 py-2 rounded disabled:opacity-60 kyd-chat-button"
|
|
47
57
|
style={{ background: 'var(--gradient-start)', color: '#fff' }}
|
|
@@ -58,8 +58,8 @@ const ReportHeader = ({ badgeId, developerName, updatedAt, score = 0, badgeImage
|
|
|
58
58
|
<div className="flex flex-col md:flex-row items-center md:items-stretch gap-6">
|
|
59
59
|
{/* Left Half: Badge Image with robust centered overlay */}
|
|
60
60
|
<div className="w-full md:w-1/3 flex items-center justify-center self-stretch">
|
|
61
|
-
<div className="relative w-full max-w-xs select-none"
|
|
62
|
-
<Image src={finalBadgeImageUrl} alt="KYD Badge" width={
|
|
61
|
+
<div className="relative w-full max-w-xs select-none">
|
|
62
|
+
<Image src={finalBadgeImageUrl} alt="KYD Badge" width={400} height={400} unoptimized className='w-full h-auto pointer-events-none p-10'/>
|
|
63
63
|
{/* Centered overlay slightly lower on Y axis, responsive and readable */}
|
|
64
64
|
<div className="pointer-events-none absolute left-1/2 top-[66%] -translate-x-1/2 -translate-y-1/2">
|
|
65
65
|
<div className="font-extrabold text-black text-3xl " >
|
package/src/lib/chat-store.ts
CHANGED
|
@@ -28,8 +28,11 @@ export type MessageItem = {
|
|
|
28
28
|
role: Role;
|
|
29
29
|
content: string;
|
|
30
30
|
createdAt: string;
|
|
31
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
31
32
|
redactions?: any;
|
|
33
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
32
34
|
toolCalls?: any;
|
|
35
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
33
36
|
evidenceJson?: any;
|
|
34
37
|
};
|
|
35
38
|
|
|
@@ -59,6 +62,7 @@ export async function createSession(params: { userId: string; companyId?: string
|
|
|
59
62
|
return sessionId;
|
|
60
63
|
}
|
|
61
64
|
|
|
65
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
62
66
|
export async function putMessage(params: { sessionId: string; role: Role; content: string; evidenceJson?: any }): Promise<void> {
|
|
63
67
|
const now = new Date().toISOString();
|
|
64
68
|
const item: MessageItem = {
|
package/src/lib/context.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
|
|
5
5
|
import { DynamoDBDocumentClient, GetCommand } from '@aws-sdk/lib-dynamodb';
|
|
6
6
|
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
|
|
7
|
-
import { mkdir, writeFile } from 'fs/promises';
|
|
7
|
+
// import { mkdir, writeFile } from 'fs/promises';
|
|
8
8
|
import { GraphInsightsPayload } from '../types';
|
|
9
9
|
|
|
10
10
|
const region = process.env.AWS_REGION!;
|
|
@@ -18,7 +18,7 @@ export async function aggregateUserData(userId: string, enterpriseMode?: boolean
|
|
|
18
18
|
// Minimal: load user record. Optionally, in enterprise mode, you could also fetch link overlay.
|
|
19
19
|
const userResp = await doc.send(new GetCommand({ TableName: usersTableName, Key: { userId } }));
|
|
20
20
|
const user = userResp.Item || {};
|
|
21
|
-
const merged = { ...user }
|
|
21
|
+
const merged = { ...user }
|
|
22
22
|
if (enterpriseMode && companyId) {
|
|
23
23
|
// Optionally merge enterprise overlay later; keeping simple for MVP.
|
|
24
24
|
merged.companyId = companyId;
|
|
@@ -50,10 +50,11 @@ export async function aggregateUserData(userId: string, enterpriseMode?: boolean
|
|
|
50
50
|
return merged;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
53
54
|
export function cleanDeveloperProfile(data: any): any {
|
|
54
55
|
if (Array.isArray(data)) return data.map(cleanDeveloperProfile);
|
|
55
56
|
if (data === null || typeof data !== 'object') return data;
|
|
56
|
-
const cleaned
|
|
57
|
+
const cleaned = {};
|
|
57
58
|
const keysToRemove = new Set([
|
|
58
59
|
'accessToken','refreshToken','access_token','refresh_token','id_token',
|
|
59
60
|
'stripeCustomerId','password','secret',
|
|
@@ -78,11 +79,13 @@ export function cleanDeveloperProfile(data: any): any {
|
|
|
78
79
|
delete v['additional_watchlists'];
|
|
79
80
|
delete v['risk_profile_domains'];
|
|
80
81
|
}
|
|
82
|
+
// @ts-expect-error - ignoring
|
|
81
83
|
cleaned[key] = v;
|
|
82
84
|
}
|
|
83
85
|
return cleaned;
|
|
84
86
|
}
|
|
85
87
|
|
|
88
|
+
// // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
86
89
|
export function buildAllContextPrompt(cleanedData: any, reportGraphData: GraphInsightsPayload | undefined, options?: { concise?: boolean, debugName?: string }): string {
|
|
87
90
|
const instructions = [
|
|
88
91
|
'You are KYD, a trust and risk analyst.',
|
|
@@ -116,29 +119,30 @@ export function buildAllContextPrompt(cleanedData: any, reportGraphData: GraphIn
|
|
|
116
119
|
return out;
|
|
117
120
|
}
|
|
118
121
|
|
|
122
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
119
123
|
async function getJsonFromS3(bucket: string, key: string): Promise<any> {
|
|
120
124
|
const res = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
|
|
121
|
-
const text = await
|
|
125
|
+
const text = await res.Body?.transformToString('utf-8');
|
|
122
126
|
return JSON.parse(text || '{}');
|
|
123
127
|
}
|
|
124
128
|
|
|
125
129
|
|
|
126
|
-
async function maybeWriteDebugContext(cleanedData: any, promptText: string, nameHint?: string): Promise<void> {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
}
|
|
130
|
+
// async function maybeWriteDebugContext(cleanedData: any, promptText: string, nameHint?: string): Promise<void> {
|
|
131
|
+
// try {
|
|
132
|
+
// const dir = './debug';
|
|
133
|
+
// await mkdir(dir, { recursive: true });
|
|
134
|
+
// const sanitize = (s: string) => s.replace(/[^a-zA-Z0-9_\-.]/g, '_').slice(0, 64) || 'context';
|
|
135
|
+
// const base = sanitize(nameHint || 'context');
|
|
136
|
+
// const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
137
|
+
// const jsonPath = `${dir}/kyd-${base}-${ts}.json`;
|
|
138
|
+
// const txtPath = `${dir}/kyd-${base}-${ts}.txt`;
|
|
139
|
+
// const jsonPretty = JSON.stringify(cleanedData, null, 2);
|
|
140
|
+
// await writeFile(jsonPath, jsonPretty, 'utf8');
|
|
141
|
+
// await writeFile(txtPath, promptText, 'utf8');
|
|
142
|
+
// } catch (e) {
|
|
143
|
+
// console.error('Context debug write failed', e);
|
|
144
|
+
// }
|
|
145
|
+
// }
|
|
142
146
|
|
|
143
147
|
|
|
144
148
|
export async function getReportGraphData(badgeId: string): Promise<GraphInsightsPayload | undefined> {
|
package/src/lib/rate-limit.ts
CHANGED
|
@@ -9,9 +9,9 @@ const doc = DynamoDBDocumentClient.from(ddb);
|
|
|
9
9
|
|
|
10
10
|
export async function checkAndConsumeToken(userId: string, capacityPerMinute: number = 10): Promise<boolean> {
|
|
11
11
|
const now = Date.now();
|
|
12
|
-
const key = { userId }
|
|
12
|
+
const key = { userId };
|
|
13
13
|
const resp = await doc.send(new GetCommand({ TableName: tableName, Key: key }));
|
|
14
|
-
const item =
|
|
14
|
+
const item = resp.Item || { userId, tokens: capacityPerMinute, refillAt: now };
|
|
15
15
|
let tokens: number = typeof item.tokens === 'number' ? item.tokens : capacityPerMinute;
|
|
16
16
|
let refillAt: number = typeof item.refillAt === 'number' ? item.refillAt : now;
|
|
17
17
|
if (now >= refillAt + 60_000) {
|
package/src/lib/routes.ts
CHANGED
|
@@ -43,31 +43,30 @@ export async function chatStreamRoute(req: NextRequest, userId: string, companyI
|
|
|
43
43
|
messages: [...history, { role: 'user' as const, content }],
|
|
44
44
|
maxTokens: 1024,
|
|
45
45
|
temperature: 0.2,
|
|
46
|
-
onFinish: async ({ text }: { text: string }) => {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
},
|
|
46
|
+
// onFinish: async ({ text }: { text: string }) => {
|
|
47
|
+
// // Attempt to capture evidence block if present (UI also parses, but cache server-side)
|
|
48
|
+
// const evidence = tryParseEvidenceServer(text);
|
|
49
|
+
// await putMessage({ sessionId, role: 'assistant', content: text, evidenceJson: evidence || undefined });
|
|
50
|
+
// },
|
|
51
51
|
});
|
|
52
52
|
|
|
53
53
|
return result.toTextStreamResponse();
|
|
54
|
-
} catch (e
|
|
55
|
-
if (e instanceof Response) return e;
|
|
54
|
+
} catch (e) {
|
|
56
55
|
console.error('chat error', e);
|
|
57
56
|
return new Response('An error occurred. Please try again.', { status: 500 });
|
|
58
57
|
}
|
|
59
58
|
}
|
|
60
59
|
|
|
61
|
-
function tryParseEvidenceServer(text: string): any | null {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
}
|
|
60
|
+
// function tryParseEvidenceServer(text: string): any | null {
|
|
61
|
+
// try {
|
|
62
|
+
// const start = text.indexOf('```json');
|
|
63
|
+
// const end = text.indexOf('```', start + 7);
|
|
64
|
+
// const raw = start >= 0 && end > start ? text.slice(start + 7, end).trim() : text.trim();
|
|
65
|
+
// const obj = JSON.parse(raw);
|
|
66
|
+
// if (obj && obj.type === 'evidence' && Array.isArray(obj.claims)) return obj;
|
|
67
|
+
// } catch {}
|
|
68
|
+
// return null;
|
|
69
|
+
// }
|
|
71
70
|
|
|
72
71
|
export async function createSessionRoute(req: NextRequest, userId: string, companyId?: string) {
|
|
73
72
|
try {
|
|
@@ -81,7 +80,7 @@ export async function createSessionRoute(req: NextRequest, userId: string, compa
|
|
|
81
80
|
promptVersion: body?.promptVersion,
|
|
82
81
|
});
|
|
83
82
|
return Response.json({ sessionId });
|
|
84
|
-
} catch (e
|
|
83
|
+
} catch (e) {
|
|
85
84
|
console.error(e);
|
|
86
85
|
if (e instanceof Response) return e;
|
|
87
86
|
return Response.json({ error: 'Failed to create session' }, { status: 500 });
|
package/src/lib/auth-verify.ts
DELETED
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
import { NextRequest } from 'next/server';
|
|
2
|
-
import { getServerSession } from 'next-auth';
|
|
3
|
-
import { authOptions } from '@/lib/auth';
|
|
4
|
-
import { jwtVerify, createRemoteJWKSet } from 'jose';
|
|
5
|
-
|
|
6
|
-
export type VerifiedUser = {
|
|
7
|
-
userId: string;
|
|
8
|
-
companyId?: string | null;
|
|
9
|
-
idToken: string;
|
|
10
|
-
};
|
|
11
|
-
|
|
12
|
-
// Verifies Cognito ID Token from the server session or Authorization header.
|
|
13
|
-
// Returns userId (sub) and optional companyId claim if present.
|
|
14
|
-
export async function verifyCognito(req: NextRequest): Promise<VerifiedUser> {
|
|
15
|
-
// Try to get id_token from NextAuth session first
|
|
16
|
-
const session = await getServerSession(authOptions as any);
|
|
17
|
-
let idToken: string | undefined;
|
|
18
|
-
if (session && (session as any).id_token) {
|
|
19
|
-
idToken = (session as any).id_token as string;
|
|
20
|
-
}
|
|
21
|
-
if (!idToken) {
|
|
22
|
-
const authz = req.headers.get('authorization') || req.headers.get('Authorization');
|
|
23
|
-
if (authz && authz.toLowerCase().startsWith('bearer ')) {
|
|
24
|
-
idToken = authz.slice(7).trim();
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
if (!idToken) {
|
|
29
|
-
throw new Response(JSON.stringify({ error: 'Not authenticated' }), { status: 401 }) as unknown as Error;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const region = process.env.AWS_REGION!;
|
|
33
|
-
const userPoolId = process.env.COGNITO_USER_POOL_ID!;
|
|
34
|
-
const issuer = `https://cognito-idp.${region}.amazonaws.com/${userPoolId}`;
|
|
35
|
-
const jwks = createRemoteJWKSet(new URL(`${issuer}/.well-known/jwks.json`));
|
|
36
|
-
|
|
37
|
-
try {
|
|
38
|
-
const { payload } = await jwtVerify(idToken, jwks, { issuer });
|
|
39
|
-
const sub = String(payload.sub || '');
|
|
40
|
-
if (!sub) throw new Error('Invalid token');
|
|
41
|
-
const companyId = (payload as any)['custom:companyId'] || (payload as any).companyId || null;
|
|
42
|
-
return { userId: sub, companyId, idToken };
|
|
43
|
-
} catch (e) {
|
|
44
|
-
throw new Response(JSON.stringify({ error: 'Invalid token' }), { status: 401 }) as unknown as Error;
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
|