diff --git a/build-fetch-patch.js b/build-fetch-patch.js index 70e88c14..d16f3f46 100644 --- a/build-fetch-patch.js +++ b/build-fetch-patch.js @@ -13,15 +13,34 @@ if (!/keepsimple\.io/.test(url)) return _origFetch(input, init); try { const resp = await _origFetch(input, init); - const ct = (resp.headers && resp.headers.get && resp.headers.get('content-type')) || ''; + const ct = + (resp.headers && + resp.headers.get && + resp.headers.get('content-type')) || + ''; if (!resp.ok || ct.startsWith('text/html')) { - console.warn('[build-fetch] non-JSON/!ok (', resp.status, ct, ') — empty data fallback for', url.slice(0, 100)); - return new Response('{"data":[]}', { status: 200, headers: { 'content-type': 'application/json' } }); + console.warn( + '[build-fetch] non-JSON/!ok (', + resp.status, + ct, + ') — empty data fallback for', + url.slice(0, 100), + ); + return new Response('{"data":[]}', { + status: 200, + headers: { 'content-type': 'application/json' }, + }); } return resp; } catch (e) { - console.warn('[build-fetch] threw — empty data fallback:', e && e.message); - return new Response('{"data":[]}', { status: 200, headers: { 'content-type': 'application/json' } }); + console.warn( + '[build-fetch] threw — empty data fallback:', + e && e.message, + ); + return new Response('{"data":[]}', { + status: 200, + headers: { 'content-type': 'application/json' }, + }); } }; })(); diff --git a/public/assets/tom/background.png b/public/assets/tom/background.png new file mode 100644 index 00000000..3cf8f00a Binary files /dev/null and b/public/assets/tom/background.png differ diff --git a/public/assets/tom/tom_img.png b/public/assets/tom/tom_img.png new file mode 100644 index 00000000..8bb5e991 Binary files /dev/null and b/public/assets/tom/tom_img.png differ diff --git a/src/api/tomGpt.ts b/src/api/tomGpt.ts new file mode 100644 index 00000000..ce218e74 --- /dev/null +++ b/src/api/tomGpt.ts @@ -0,0 +1,239 @@ +import { UxCatRoute } from './configs'; + +export type TomGptErrorCode = + | 'RATE_LIMITED' + | 'SESSION_LIMIT_REACHED' + | 'OPENAI_RATE_LIMIT' + | 'UPSTREAM_ERROR' + | 'FEATURE_DISABLED' + | 'UNAUTHORIZED' + | 'CHAT_NOT_FOUND' + | 'NETWORK_ERROR' + | 'UNKNOWN_ERROR'; + +export type TomGptResult = + | { ok: true; data: T } + | { ok: false; code: TomGptErrorCode; status: number; message?: string }; + +export interface TomChatListItem { + chatId: string; + createdAt: string; +} + +export interface TomChatMessage { + role: 'user' | 'assistant' | 'system'; + content: string; + createdAt?: string; +} + +export type TomStreamEvent = + | { type: 'delta'; text: string } + | { type: 'done'; chatId?: string } + | { type: 'error'; code: TomGptErrorCode; message?: string }; + +const trimSlash = (s: string) => s.replace(/\/+$/, ''); +const baseUrl = () => trimSlash(UxCatRoute || ''); + +// UXCat NestJS middleware reads a custom `accessToken` header (raw JWT, no +// "Bearer " prefix). Do NOT switch this to `Authorization` — middleware will +// treat it as missing and return 401. +const authHeader = (): Record => { + if (typeof window === 'undefined') return {}; + const token = localStorage.getItem('accessToken'); + return token ? { accessToken: token } : {}; +}; + +const statusToCode = (status: number): TomGptErrorCode => { + if (status === 401) return 'UNAUTHORIZED'; + if (status === 403) return 'FEATURE_DISABLED'; + if (status === 404) return 'CHAT_NOT_FOUND'; + if (status === 429) return 'RATE_LIMITED'; + if (status >= 500) return 'UPSTREAM_ERROR'; + return 'UNKNOWN_ERROR'; +}; + +const parseError = async ( + res: Response, +): Promise<{ code: TomGptErrorCode; status: number; message?: string }> => { + let body: any = null; + try { + body = await res.json(); + } catch { + body = null; + } + const rawCode: string | undefined = body?.error?.code || body?.code; + return { + code: (rawCode as TomGptErrorCode) || statusToCode(res.status), + status: res.status, + message: body?.error?.message || body?.message, + }; +}; + +const request = async ( + path: string, + init: RequestInit = {}, +): Promise> => { + try { + const res = await fetch(`${baseUrl()}${path}`, { + ...init, + headers: { + 'Content-Type': 'application/json', + ...authHeader(), + ...(init.headers || {}), + }, + }); + if (!res.ok) return { ok: false, ...(await parseError(res)) }; + const data = (await res.json()) as T; + return { ok: true, data }; + } catch (e: any) { + return { ok: false, code: 'NETWORK_ERROR', status: 0, message: e?.message }; + } +}; + +export const createTomChat = () => + request<{ chatId: string; createdAt: string }>('/gpt/chats', { + method: 'POST', + }); + +export const listTomChats = () => + request<{ chats: TomChatListItem[] }>('/gpt/chats'); + +export const getTomChatHistory = (chatId: string) => + request<{ messages: TomChatMessage[] }>( + `/gpt/chats/${encodeURIComponent(chatId)}`, + ); + +export const deleteTomChat = (chatId: string) => + request<{ success: boolean }>(`/gpt/chats/${encodeURIComponent(chatId)}`, { + method: 'DELETE', + }); + +// Parses an SSE byte stream into discrete `event`/`data` pairs. Splits on the +// blank-line delimiter; `data:` lines accumulate (multi-line joined with \n). +async function* parseSse( + res: Response, +): AsyncGenerator<{ event: string; data: string }> { + if (!res.body) return; + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let idx: number; + while ((idx = buffer.indexOf('\n\n')) !== -1) { + const block = buffer.slice(0, idx); + buffer = buffer.slice(idx + 2); + let event = 'message'; + const dataLines: string[] = []; + for (const line of block.split('\n')) { + if (line.startsWith('event:')) event = line.slice(6).trim(); + else if (line.startsWith('data:')) dataLines.push(line.slice(5).trim()); + } + yield { event, data: dataLines.join('\n') }; + } + } +} + +// Backend examples show `data: {"text": "..."}`. We also accept a bare string +// in case the wire format drifts. +const parseDeltaText = (data: string): string => { + try { + const payload = JSON.parse(data); + if (typeof payload?.text === 'string') return payload.text; + if (typeof payload === 'string') return payload; + } catch { + return data; + } + return ''; +}; + +// EventSource cannot send custom headers, and we need the `accessToken` +// header, so we hand-roll the SSE parser on top of fetch + ReadableStream. +export async function* sendTomMessage( + chatId: string, + message: string, + signal?: AbortSignal, +): AsyncGenerator { + let res: Response; + try { + res = await fetch( + `${baseUrl()}/gpt/chats/${encodeURIComponent(chatId)}/messages`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...authHeader(), + }, + body: JSON.stringify({ message }), + signal, + }, + ); + } catch (e: any) { + yield { type: 'error', code: 'NETWORK_ERROR', message: e?.message }; + return; + } + + if (!res.ok) { + const err = await parseError(res); + yield { type: 'error', code: err.code, message: err.message }; + return; + } + + try { + for await (const { event, data } of parseSse(res)) { + if (event === 'delta') { + const text = parseDeltaText(data); + if (text) yield { type: 'delta', text }; + } else if (event === 'done') { + let chatIdOut: string | undefined; + try { + const payload = JSON.parse(data); + chatIdOut = payload?.conversationId || payload?.chatId; + } catch { + // No payload — emit done anyway. + } + yield { type: 'done', chatId: chatIdOut }; + return; + } else if (event === 'error') { + let code: TomGptErrorCode = 'UPSTREAM_ERROR'; + let msg: string | undefined; + try { + const payload = JSON.parse(data); + if (payload?.code) code = payload.code as TomGptErrorCode; + if (payload?.message) msg = payload.message; + } catch { + // Malformed error payload — fall through with default code. + } + yield { type: 'error', code, message: msg }; + return; + } + } + } catch (e: any) { + yield { type: 'error', code: 'NETWORK_ERROR', message: e?.message }; + } +} + +export const tomErrorCopy = (code: TomGptErrorCode): string => { + switch (code) { + case 'FEATURE_DISABLED': + return 'Tom GPT is not available on your account yet.'; + case 'UNAUTHORIZED': + return 'Please sign in to use chat.'; + case 'CHAT_NOT_FOUND': + return 'This chat no longer exists.'; + case 'RATE_LIMITED': + return "You've reached today's message limit. Please come back tomorrow."; + case 'SESSION_LIMIT_REACHED': + return 'This chat has reached its message limit. Start a new chat to continue.'; + case 'OPENAI_RATE_LIMIT': + return 'Too many messages, please slow down.'; + case 'UPSTREAM_ERROR': + return 'Something went wrong. Try again in a moment.'; + case 'NETWORK_ERROR': + return 'Network error. Check your connection.'; + default: + return 'Something went wrong.'; + } +}; diff --git a/src/components/tom/TomChat.module.scss b/src/components/tom/TomChat.module.scss new file mode 100644 index 00000000..fcafe7b0 --- /dev/null +++ b/src/components/tom/TomChat.module.scss @@ -0,0 +1,146 @@ +.root { + display: flex; + height: calc(100vh - 48px); + overflow: hidden; + background: var(--tom-bg); + position: relative; + z-index: 1; +} + +.main { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + min-width: 0; + position: relative; + z-index: 1; +} + +.mobileToggle { + position: absolute; + top: 12px; + left: 12px; + z-index: 5; + align-self: flex-start; + background: var(--tom-sidebar-bg); + border: 1px solid var(--tom-border); + border-radius: 8px; + padding: 6px; + cursor: pointer; + color: var(--tom-text-secondary); + display: flex; + align-items: center; + box-shadow: 0 1px 4px var(--tom-shadow); + + &:hover { + color: var(--tom-text); + background: var(--tom-hover); + } +} + +// Welcome screen +.welcome { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px 24px; + text-align: center; + gap: 4px; + max-width: 780px; + width: 100%; +} + +.welcomeAvatar { + width: 120px; + height: 120px; + border-radius: 50%; + object-fit: cover; + margin-bottom: 16px; +} + +.welcomeTitle { + font-family: var(--tom-font-display); + font-size: 32px; + font-weight: 700; + margin: 0; + line-height: 1.2; +} + +.welcomeSubtitle { + font-size: 15px; + color: var(--tom-text-secondary); + margin: 4px 0 12px; + font-style: italic; +} + +.welcomeTagline { + font-size: 16px; + color: var(--tom-text); + line-height: 1.65; + margin: 0; +} + +.banner { + width: 100%; + max-width: 780px; + margin: 12px auto 0; + padding: 10px 14px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + font-size: 14px; + line-height: 1.4; + background: rgba(220, 80, 80, 0.08); + border: 1px solid rgba(220, 80, 80, 0.35); + color: var(--tom-text); +} + +.bannerClose { + background: none; + border: none; + color: inherit; + font-size: 20px; + line-height: 1; + cursor: pointer; + padding: 0 4px; + opacity: 0.7; + + &:hover { + opacity: 1; + } +} + +/* ── Tablet ── */ +@media (max-width: 1140px) { + .root { + height: calc(100vh - 55px); + } +} + +/* ── Mobile ── */ +@media (max-width: 960px) { + .root { + height: calc(100dvh - 55px); + margin-top: 55px; + } +} + +@media (max-width: 768px) { + .welcomeAvatar { + width: 90px; + height: 90px; + } + + .welcomeTitle { + font-size: 26px; + } + + .welcomeTagline { + font-size: 15px; + } +} diff --git a/src/components/tom/TomChat.tsx b/src/components/tom/TomChat.tsx new file mode 100644 index 00000000..25b3b18d --- /dev/null +++ b/src/components/tom/TomChat.tsx @@ -0,0 +1,165 @@ +import { FC, useEffect, useState } from 'react'; + +import TomInput from './TomInput'; +import TomMessages from './TomMessages'; +import TomSidebar from './TomSidebar'; +import useTomChat from './useTomChat'; + +import styles from './TomChat.module.scss'; + +const TomChat: FC = () => { + const { + activeChat, + activeChatId, + todayChats, + recentChats, + canCreateChat, + isLoading, + isFeatureDisabled, + isDailyCapped, + lastError, + createChat, + selectChat, + deleteChat, + sendMessage, + dismissError, + } = useTomChat(); + + const [sidebarOpen, setSidebarOpen] = useState(true); + + useEffect(() => { + const mq = window.matchMedia('(max-width: 768px)'); + if (mq.matches) setSidebarOpen(false); + + const handler = (e: MediaQueryListEvent) => { + if (e.matches) setSidebarOpen(false); + else setSidebarOpen(true); + }; + mq.addEventListener('change', handler); + return () => mq.removeEventListener('change', handler); + }, []); + + const handleSelectChat = (id: string) => { + selectChat(id); + if (window.innerWidth <= 768) setSidebarOpen(false); + }; + + const handleNewChat = () => { + createChat(); + if (window.innerWidth <= 768) setSidebarOpen(false); + }; + + if (isFeatureDisabled) { + return ( +
+
+
+ Friendly Tom +

Friendly Tom

+

Because you matter

+

+ Longevity GPT is not available on your account yet. +
+ Check back soon. +

+
+
+
+ ); + } + + const hasMessages = activeChat && activeChat.messages.length > 0; + // Per Strapi feature-flag guidance: render entry point optimistically on + // first load — only a real 403 should disable. So isInitializing is not in + // this gate; it flows to the disabled-screen takeover via isFeatureDisabled. + const inputDisabled = isLoading || isDailyCapped; + + return ( +
+ setSidebarOpen(prev => !prev)} + onClose={() => setSidebarOpen(false)} + todayChats={todayChats} + recentChats={recentChats} + activeChatId={activeChatId} + canCreateChat={canCreateChat} + onNewChat={handleNewChat} + onSelectChat={handleSelectChat} + onDeleteChat={deleteChat} + /> + +
+ {!sidebarOpen && ( + + )} + + {lastError && ( +
+ {lastError.message} + +
+ )} + + {hasMessages ? ( + + ) : ( +
+ Friendly Tom +

Friendly Tom

+

Because you matter

+

+ Do your best on living longer. +
+ Enjoy watching your foes leave this world first. +

+
+ )} + + +
+
+ ); +}; + +export default TomChat; diff --git a/src/components/tom/TomInput.module.scss b/src/components/tom/TomInput.module.scss new file mode 100644 index 00000000..a5b57054 --- /dev/null +++ b/src/components/tom/TomInput.module.scss @@ -0,0 +1,193 @@ +.wrapper { + padding: 0 20px 12px; + flex-shrink: 0; + max-width: 780px; + width: 100%; + margin: 0 auto; +} + +.previews { + display: flex; + gap: 8px; + padding: 8px 0; + flex-wrap: wrap; +} + +.preview { + position: relative; + border-radius: 8px; + overflow: hidden; + border: 1px solid var(--tom-border); +} + +.previewImg { + height: 60px; + width: auto; + max-width: 100px; + object-fit: cover; + display: block; +} + +.previewFile { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + font-size: 12px; + color: var(--tom-text-secondary); + background: var(--tom-hover); + + span { + max-width: 80px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +.removeBtn { + position: absolute; + top: 2px; + right: 2px; + width: 18px; + height: 18px; + border-radius: 50%; + border: none; + background: rgba(0, 0, 0, 0.5); + color: #fff; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding: 0; +} + +.form { + display: flex; + align-items: flex-end; + gap: 0; + background: var(--tom-input-bg); + border: 1px solid var(--tom-border); + border-radius: 16px; + padding: 6px 8px; + box-shadow: 0 1px 4px var(--tom-shadow); +} + +.fileInput { + display: none; +} + +.attachBtn { + background: none; + border: none; + cursor: pointer; + color: var(--tom-text-muted); + padding: 6px; + border-radius: 8px; + display: flex; + align-items: center; + flex-shrink: 0; + + &:hover { + color: var(--tom-text); + background: var(--tom-hover); + } +} + +.textarea { + flex: 1; + border: none; + outline: none; + resize: none; + background: transparent; + font-family: var(--tom-font-body); + font-size: 15px; + line-height: 1.5; + color: var(--tom-text); + padding: 6px 4px; + max-height: 150px; + + &::placeholder { + color: var(--tom-text-muted); + } + + &:disabled { + opacity: 0.5; + } +} + +.sendBtn { + background: none; + border: none; + cursor: pointer; + color: var(--tom-text-muted); + padding: 6px; + border-radius: 8px; + display: flex; + align-items: center; + flex-shrink: 0; + opacity: 0.4; + transition: + opacity 0.15s ease, + color 0.15s ease, + background 0.15s ease; + + &:disabled { + cursor: not-allowed; + } +} + +.sendActive { + opacity: 1; + color: var(--tom-text); + + &:hover { + background: var(--tom-hover); + } +} + +.footer { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + margin: 8px 0 0; +} + +.disclaimer { + text-align: center; + font-size: 11.5px; + color: var(--tom-text-muted); + margin: 0; + padding: 0; +} + +.counter { + font-size: 11.5px; + color: var(--tom-text-muted); + font-variant-numeric: tabular-nums; +} + +.counterOver { + color: rgb(220, 80, 80); +} + +/* ── Mobile ── */ +@media (max-width: 768px) { + .wrapper { + padding: 0 12px 8px; + } + + .form { + border-radius: 14px; + padding: 4px 6px; + } + + .textarea { + font-size: 14px; + } + + .disclaimer { + font-size: 10.5px; + } +} diff --git a/src/components/tom/TomInput.tsx b/src/components/tom/TomInput.tsx new file mode 100644 index 00000000..d2dd8f0d --- /dev/null +++ b/src/components/tom/TomInput.tsx @@ -0,0 +1,256 @@ +import { + ClipboardEvent, + FC, + FormEvent, + KeyboardEvent, + useRef, + useState, +} from 'react'; + +import type { TomAttachment } from './types'; + +import styles from './TomInput.module.scss'; + +const MAX_CHARS = 4000; +const COUNTER_VISIBLE_AT = 3500; + +interface Props { + onSend: (content: string, attachments?: TomAttachment[]) => void; + disabled: boolean; + disabledReason?: string; +} + +function uid() { + return Date.now().toString(36) + Math.random().toString(36).slice(2, 8); +} + +const ACCEPTED = 'image/png,image/jpeg,image/gif,image/webp,text/plain,.txt'; + +const TomInput: FC = ({ onSend, disabled, disabledReason }) => { + const [text, setText] = useState(''); + const [attachments, setAttachments] = useState([]); + const fileRef = useRef(null); + const textareaRef = useRef(null); + + const handleFile = (file: File) => { + if (file.type.startsWith('image/')) { + const reader = new FileReader(); + reader.onload = () => { + setAttachments(prev => [ + ...prev, + { + id: uid(), + name: file.name, + type: 'image', + dataUrl: reader.result as string, + }, + ]); + }; + reader.readAsDataURL(file); + } else if (file.type === 'text/plain' || file.name.endsWith('.txt')) { + const reader = new FileReader(); + reader.onload = () => { + setAttachments(prev => [ + ...prev, + { + id: uid(), + name: file.name, + type: 'text', + dataUrl: '', + content: reader.result as string, + }, + ]); + }; + reader.readAsText(file); + } + }; + + const removeAttachment = (id: string) => { + setAttachments(prev => prev.filter(a => a.id !== id)); + }; + + const canSubmit = + !disabled && text.trim().length > 0 && text.length <= MAX_CHARS; + + const handleSubmit = (e?: FormEvent) => { + e?.preventDefault(); + if (!canSubmit) return; + onSend(text, attachments.length > 0 ? attachments : undefined); + setText(''); + setAttachments([]); + if (textareaRef.current) { + textareaRef.current.style.height = 'auto'; + } + }; + + const handlePaste = (e: ClipboardEvent) => { + const items = e.clipboardData?.items; + if (!items) return; + for (const item of Array.from(items)) { + if (item.type.startsWith('image/')) { + e.preventDefault(); + const file = item.getAsFile(); + if (file) handleFile(file); + return; + } + } + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSubmit(); + } + }; + + const autoResize = () => { + const el = textareaRef.current; + if (!el) return; + el.style.height = 'auto'; + el.style.height = Math.min(el.scrollHeight, 150) + 'px'; + }; + + const handleTextChange = (value: string) => { + // Hard-cap at MAX_CHARS so a paste of 10k characters can't ever bypass. + setText(value.length > MAX_CHARS ? value.slice(0, MAX_CHARS) : value); + autoResize(); + }; + + const showCounter = text.length >= COUNTER_VISIBLE_AT; + const overLimit = text.length >= MAX_CHARS; + + return ( +
+ {attachments.length > 0 && ( +
+ {attachments.map(a => ( +
+ {a.type === 'image' ? ( + {a.name} + ) : ( +
+ + + + + {a.name} +
+ )} + +
+ ))} +
+ )} + +
+ + + { + const file = e.target.files?.[0]; + if (file) handleFile(file); + e.target.value = ''; + }} + /> + +