dAMQaiH zm^r3v#$Q#2T=X>bsY#D%s!bhs^M9PMAcHbCc0FMHV{u-dwlL;a1eJ63v5U*?Q_8JO zT#50!RD619#j_Uf))0ooADz~*9&lN!bBDRUgE>Vud-i5ck%vT=r^yD*^?Mp@Q^v+V zG#-?gKlr}Eeqifb{|So?HM&g91P8|av8hQoCmQXkd?7wIJw b z_^v8bbg` SAn{I*4bH$u(RZ6*x UhuA~hc=8czK8SHEKTzSxgbwi~9(OqJB&gwb^l4+m`k*Q;_?>Y-APi1{k zAHQ)P)G)f|AyjSgcCFps)Fh6Bca*Xznq36!pV6Az&m{O8$wGFD? zY&O*3*J0;_EqM#jh6^gMQKpXV?#1?>$ml1xvh8nSN>-?H=V;nJIwB07YX$e6vLxH( zqYwQ>qxwR(i4f)DLd)-$P>T-no_c!LsN@)8`e;W@)-Hj0>nJ-}Kla4-ZdPJzI&Mce zv)V_j;(3ERN3_@I$N<^|4Lf`B;8n+bX@bHbcZTopEmDI*Jfl)-pFDvo6svPRoo@(x z);_{lY<;);XzT`dBFpRmGrr}z5u1=p C^ S-{ce6iXQlLGcItwJ^mZx{m$&DA_oEZ)B{_bYPq-HA zcH8WGoBG(aBU_j)vEy+_71T34@4dmSg!|M8Vf92Zj6WH7Q7t#OHQqWgFE3ARt+%!T z?oLovLVlnf?2c7pTc)~cc^($_8nyKwsN`RA-23ed3sdj(ys%pjjM+9JrctL;dy8a( z@en&CQmnV(()bu|Y%G1-4a(6x{aLytn$T-;(&{QIJB9vMox11U-1HpD@d(QkaJdEb zG{)+6Dos_L+O3NpWo^=gR?evp|CqEG?L&Ut#D*KLaRFOgOEK(Kq1@!EGcTfo+%A&I z=dLbB+d$u{sh?u)xP{PF8L%;YPPW53+@{>5W=Jt#wQpN;0_HYdw1{ksf_XhO4#2F= zyPx6Lx2<92L-;L5PD`zn6zwIH`Jk( $?Qw({erA$^bC;q33hv!d!>%wRhj# zal^hk+WGNg;rJtb-EB(?czvOM=H7dl=vblBwAv>}%1@{}mnpUznfq1cE^sgsL0*4I zJ##!*B?=vI_OEVis5o+_IwMIRrpQyT_Sq~ZU%oY7c5JMIADzpD!Upz9h@iWg_>>~j zOLS;wp^i$-E?4<_cp?RiS%Rd?i;f*mOz=~(&3lo<=@(nR!_Rqiprh@weZlL!t#NCc zO!QTcInq|%#>OVgobj{~ixEUec`E25zJ~*DofsQdzIa@5^nOXj2T;8O`l--(QyU ^$t?TGY^7#&FQ+2SS3B#qK*k3`ye?8jUYSajE5iBbJls75CCc(m3dk{t?- zopcER9{Z?TC)mk~gpi^kbbu>b-+a{m#8-y2^p$ka4n60w;Sc2}HMf<8JUvh CL0B&Btk)T`ctE$*qNW8L$`7!r^9T+>=<=2qaq-;ll2{`{Rg zc5a0ZUI$oG&j-qVOuKa=*v4aY#IsoM+1|c4Z)<}lEDvy;5huB@1RJPquU2U*U-;gu z=En2m+qjBzR#DEJDO`WU)hdd{Vj%^0V*KoyZ|5lzV87&g_j~NCjwv0uQVqXOb*QrQ zy|Qn`hxx(58c 70$E;L(X0uZZ72M1!6oeg)(cdKO ze0gDaTz+ohR-#d)NbAH4x{I(21yjwvBQfmpLu$)|m{XolbgF!pmsqJ#D}(ylp6uC> z{bqtcI#hT#HW=wl7>p!38sKsJ`r8}lt-q%Keqy%u(xk=yiIJiUw6|5IvkS+#?JTBl z8H5(Q?l#wzazujH!8o>1xtn8#_w+397* _cy8!pQGP%K(Ga3pAjsaTbbXJlQF_+m+-UpUUent@xM zg%jqLUExj~o^vQ3Gl*>wh=_gOr2*|U64_iXb+-111a H}$TjeajM+I20xw(((>fej-@CIz4S1pi$(#}P7`4({6QS2CaQS4NPENDp>sAqD z$bH4KGzXGffkJ7R>V>)>tC)uax{UsN*dbeNC*v}#8Y#OWYwL4t$ePR?VTyIs!wea+ z5Urmc)X|^`MG~*dS6pGSbU+gPJoq*^a=_>$n4|P^w$sMBBy@f*Z^Jg6?n5?oId6f{ z$LW4M|4m502z0t7g<#Bx%X;9<=)smFolV&(V^(7Cv2-sxbxopQ!)*#ZRhTBpx1)Fc zNm1T%bONzv6@#|dz(w02AH8OXe>kQ#1FMCzO}2J_mST)+ExmBr9cva-@?;wnmWMOk z{3_~EX_xadgJGv&H@zK_8{(x84`}+c?oSBX*Ge3VdfTt&F}yCpFP?CpW+BE^cWY0^ zb&uBN!Ja3UzYHK-CTyA5=L zEMW{l3Usky#ly=7px648W31UNV@K)&Ub&zP1c7%)`{);I4b0Q<)B}3;NMG2JH=X$U zfIW4)4n9ZM`-yRj67I)YSLDK)qfUJ_ij}a#aZN~9EXrh8eZY2&=uY%2N0UFF7<~%M zsB8=erOWZ>Ct_#^tHZ|*q`H;A)5;ycw*I cmVxi8_0Xk}aJA^ath+E;xg!x+As(M#0=)3!NJR6H&9+zd#iP(m0PIW8$ z1Y^VX`>jm`W!=WpF*{ioM?C9`yOR>@0q=u7o>BP-eSHqCgMDj!2anwH?s%i2p+Q7D zzszIf5XJpE)IG4;d_(La-xenmF(tgAxK`Y4sQ}BSJEPs6N_U2vI{8=0C_F?@7<(G; zo$~G=8p+076G;`}>{MQ>t>7cm=zGtfbdDXm6||jUU|?X?CaE?(<6bKDYKeHlz}DA8 zXT={X=yp_R;HfJ9h%?eWvQ!dRgz&Su*JfNt!Wu>|XfU &68iRikRrHRW|ZxzRR^`eIGt zIeiDgVS>IeExKVRWW8-= A= yA`}`)ZkWBrZD`hpWIxBGkh&f#ijr449~m`j6{4jiJ*C!oVA8ZC?$1RM#K(_b zL9TW)kN*Y4%^-qPpMP7d4)o?Nk#>aoYHT(*g)qmRUb?**F@pnNiy6Fv9rEiUqD(^O zzyS?nBrX63BTRYduaG(0VVG2yJRe%o&rVrLjbxTaAFTd8s;<<@Qs>u(<193R8>}2_ zuwp{7;H2a*X7_jryzriZXMg?bTuegABb^87@SsKkr2)0Gyiax8KQWstw^v #ix45EVrcEhr>!NMhprl $InQMzjSFH54x5k9qHc`@9uKQzvL4ihcq{^B zPrVR=o_ic%Y>6&rMN)hTZsI7I<3&`#(nl+3y3ys9A~ &^=4?PL&nd8)`OfG#n zwAMN$1&>K++c{^|7< 4P=2y(B{jJsQ0a#U;HTo4ZmWZYvI{+s;Td{Yzem%0*k#)vjpB zia;J&>}ICate44SFYY3vEelqStQWFihx%^vQ@Do(sOy7yR2@WNv7Y9I^yL=nZr3mb zXKV5t@=?-Sk|b{XMhA7ZGB@2hqsx}4xwCW!in#C zI@}sc Zlr3-NFJ@NFaJlhyfcw{k^vvtGl`N9xSo**rDW4S}i zM9{fMPWo%4wYDG~BZ18BD+}h|GQKc-g^{++3MY>}W_uq7jGHx{mwE9fZiPCoxN$+7 zrODGGJrOkcPQUB(FD5aoS4g~7#6NR^ma7-!>mHuJfY5kTe6PpNNKC9GGRiu^L31uG z$7v`*JknQHsYB!Tm_W{a32TM099djW%5e+j0Ve_ct}IM>XLF1Ap+YvcrLV=|CKo6S zb+ 9Nl3_YdKP6%Cxy@6TxZ>;4&nTneadr z_ES90ydCev)LV!dN=#(*f}|ZORFdvkYBni^aLbUk>BajeWIOcmHP#8S)*2U~QKI%S zyrLmtPqb&TphJ;>yAxri#;{uyk`JJqODDw%(Z=2 `1uc}br^V%>j!gS)D*q*f_-qf8&D;W1dJgQMlaH5er zN2U<%Smb7==vE}dDI8K7cKz!vs^73o9f>2sgiTzWcwY|BMYHH5%Vn7#kiw&eItCqa zIkR2~Q}>X=Ar8W|^Ms41Fm8o6IB2_j60eOeBB1Br!boW7JnoeX6Gs)?7rW0^5psc- zjS16yb>dFn>KPOF;imD}e!enuIniFzv}n$m2#gCCv4jM#ArwlzZ$7@9&XkFxZ4n!V zj3dyiwW4Ki2QG{@i>yuZXQizw_OkZI^-3otXC{!(lUpJF33gI60ak;Uqitp74|B6I zgg{b=Iz}WkhCGj1M =hu4#Aw173YxIVbISaoc z-nLZC*6Tgivd5V`K%GxhBsp@SUU60-rfc$=wb>zdJzXS&-5(NRRodFk;Kxk!S( O(a0e7oY=E( zAyS;Ow?6Q&XA+cnkCb{28_1N8H#?J!*$MmIwLq^*T_9-z^&UE@A(z9oGYtFy6EZef LrJugUA?W`A8`#=m literal 0 HcmV?d00001 diff --git a/examples/ai-conference-assistant/src/app/globals.css b/examples/ai-conference-assistant/src/app/globals.css new file mode 100644 index 0000000..85a978f --- /dev/null +++ b/examples/ai-conference-assistant/src/app/globals.css @@ -0,0 +1,19 @@ +@import "tailwindcss"; + +:root { + --background: #f9fafb; + --foreground: #171717; +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); +} + +body { + background: var(--background); + color: var(--foreground); + font-family: var(--font-sans), Arial, Helvetica, sans-serif; +} diff --git a/examples/ai-conference-assistant/src/app/layout.tsx b/examples/ai-conference-assistant/src/app/layout.tsx new file mode 100644 index 0000000..ea2d001 --- /dev/null +++ b/examples/ai-conference-assistant/src/app/layout.tsx @@ -0,0 +1,22 @@ +import type { Metadata } from "next"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "HackMD Conference Assistant", + description: "AI-powered collaborative note generator for conferences using HackMD API", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + {children} + + ); +} diff --git a/examples/ai-conference-assistant/src/app/page.tsx b/examples/ai-conference-assistant/src/app/page.tsx new file mode 100644 index 0000000..02b2077 --- /dev/null +++ b/examples/ai-conference-assistant/src/app/page.tsx @@ -0,0 +1,69 @@ +'use client' + +import { useState, useRef } from 'react' +import { SetupPanel } from '@/components/setup-panel' +import { ChatPanel } from '@/components/chat-panel' +import { PreviewPanel } from '@/components/preview-panel' +import { ProgressModal } from '@/components/progress-modal' + +export interface AppConfig { + apiKey: string + openaiApiKey: string + apiEndpoint: string + teamPath: string + webDomain: string +} + +export interface GeneratedData { + homepage: { title: string; content: string } + pages: Array<{ sessionId: string; title: string; content: string }> +} + +export default function Home() { + const [config, setConfig] = useState (null) + const [sessionData, setSessionData] = useState ('') + const [generatedData, setGeneratedData] = useState (null) + const [showProgress, setShowProgress] = useState(false) + const [previewPage, setPreviewPage] = useState<{ title: string; content: string } | null>(null) + const sessionDataRef = useRef(sessionData) + sessionDataRef.current = sessionData + + if (!config) { + return + } + + return ( + + {/* Left: Chat Panel */} ++ ) +} diff --git a/examples/ai-conference-assistant/src/components/chat-panel.tsx b/examples/ai-conference-assistant/src/components/chat-panel.tsx new file mode 100644 index 0000000..6b81c05 --- /dev/null +++ b/examples/ai-conference-assistant/src/components/chat-panel.tsx @@ -0,0 +1,298 @@ +'use client' + +import { useChat } from '@ai-sdk/react' +import { TextStreamChatTransport } from 'ai' +import { useState, useRef, useEffect, type MutableRefObject, type FormEvent } from 'react' +import type { AppConfig, GeneratedData } from '@/app/page' + +interface ChatPanelProps { + config: AppConfig + sessionData: MutableRefObject++ + {/* Right: Preview Panel */} +setShowProgress(true)} + onPreviewPage={setPreviewPage} + generatedData={generatedData} + /> + ++ + {/* Progress Modal */} + {showProgress && generatedData && ( ++ setShowProgress(false)} + /> + )} + + onSessionDataChange: (data: string) => void + onGenerated: (data: GeneratedData) => void + onCreateNotes: () => void + onPreviewPage: (page: { title: string; content: string }) => void + generatedData: GeneratedData | null +} + +export function ChatPanel({ + config, + sessionData, + onSessionDataChange, + onGenerated, + onCreateNotes, + onPreviewPage, + generatedData, +}: ChatPanelProps) { + const [fileUploaded, setFileUploaded] = useState(false) + const [inputValue, setInputValue] = useState('') + const messagesEndRef = useRef (null) + const fileInputRef = useRef (null) + + const { messages, sendMessage, status, error } = useChat({ + transport: new TextStreamChatTransport({ + api: '/api/chat', + body: { + config: { + apiKey: config.apiKey, + apiEndpoint: config.apiEndpoint, + teamPath: config.teamPath, + openaiApiKey: config.openaiApiKey, + }, + }, + }), + }) + + const isLoading = status === 'submitted' || status === 'streaming' + + // Auto-scroll to bottom on new messages + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, [messages]) + + function handleFileUpload(e: React.ChangeEvent ) { + const file = e.target.files?.[0] + if (!file) return + + const reader = new FileReader() + reader.onload = (ev) => { + const text = ev.target?.result as string + try { + JSON.parse(text) // Validate JSON + onSessionDataChange(text) + setFileUploaded(true) + } catch { + alert('Invalid JSON file. Please upload a valid JSON file.') + } + } + reader.readAsText(file) + } + + function onSubmit(e: FormEvent) { + e.preventDefault() + if (!inputValue.trim() || isLoading) return + + // If session data is available and hasn't been sent yet, include it + let text = inputValue + if ( + fileUploaded && + sessionData.current && + !messages.some(m => m.parts?.some(p => p.type === 'text' && p.text.includes('[Session data uploaded'))) + ) { + const count = JSON.parse(sessionData.current).length + text = `${inputValue}\n\n[Session data uploaded - ${count} sessions]\n \n${sessionData.current}\n ` + } + + sendMessage({ text }) + setInputValue('') + } + + return ( ++ {/* Header */} ++ ) +} diff --git a/examples/ai-conference-assistant/src/components/preview-panel.tsx b/examples/ai-conference-assistant/src/components/preview-panel.tsx new file mode 100644 index 0000000..6a6fa5b --- /dev/null +++ b/examples/ai-conference-assistant/src/components/preview-panel.tsx @@ -0,0 +1,78 @@ +'use client' + +import type { GeneratedData } from '@/app/page' + +interface PreviewPanelProps { + generatedData: GeneratedData | null + previewPage: { title: string; content: string } | null + onSelectPage: (page: { title: string; content: string }) => void +} + +export function PreviewPanel({ generatedData, previewPage, onSelectPage }: PreviewPanelProps) { + if (!generatedData && !previewPage) { + return ( +++ + {/* Messages */} +++📚 Conference Assistant
+ + {config.teamPath} + ++ {generatedData && ( + + )} +++ {/* Welcome message */} + {messages.length === 0 && ( ++ + {/* Input area */} +++ )} + + {messages.map((message) => ( ++ 👋 Welcome! Let's create conference notes +
++ Upload your session data JSON, tell me about your conference, and I'll + generate all the collaborative notes for you. +
++ + {fileUploaded && ( + + ✅ {JSON.parse(sessionData.current).length} sessions loaded + + )} ++++ ))} + + {isLoading && ( ++ {message.parts?.map((part, i) => { + // Tool parts have type like 'tool-hackmd_get_me', 'tool-generate_pages', etc. + if (part.type.startsWith('tool-')) { + const toolPart = part as { type: string; state: string; toolCallId: string; output?: unknown; title?: string } + const toolName = part.type.replace('tool-', '') + + if (toolPart.state === 'call' || toolPart.state === 'input-streaming') { + return ( +++ 🔧 Calling {toolName}... ++ ) + } + + if (toolPart.state === 'result') { + // For generate_pages, show a compact summary with preview button + if (toolName === 'generate_pages' && toolPart.output) { + const result = toolPart.output as { + homepage?: { title: string; content: string } + pages?: Array<{ sessionId: string; title: string; content: string }> + summary?: string + } + + // Capture generated data for preview panel + if (result.homepage && result.pages) { + queueMicrotask(() => { + onGenerated(result as GeneratedData) + }) + } + + return ( +++ ) + } + + return ( ++ ✅ {result.summary || 'Pages generated'} +
+ {result.homepage && ( + + )} + {result.pages && result.pages.length > 0 && ( + + )} ++ ✅ {toolName} completed ++ ) + } + } + + if (part.type === 'text' && part.text) { + return ( ++ {part.text} ++ ) + } + + return null + })} +++ )} + + {error && ( +++++●+●+●++ Error: {error.message} ++ )} + + ++ + {fileUploaded && ( +++ ✅ {JSON.parse(sessionData.current).length} sessions loaded from file +
+ )} +++ ) + } + + const currentPage = previewPage || generatedData?.homepage + + return ( +📄++ Markdown preview will appear here after the AI generates your conference notes. +
++ {/* Header with page selector */} ++ ) +} diff --git a/examples/ai-conference-assistant/src/components/progress-modal.tsx b/examples/ai-conference-assistant/src/components/progress-modal.tsx new file mode 100644 index 0000000..dc20d02 --- /dev/null +++ b/examples/ai-conference-assistant/src/components/progress-modal.tsx @@ -0,0 +1,300 @@ +'use client' + +import { useState, useEffect, useCallback } from 'react' +import type { AppConfig, GeneratedData } from '@/app/page' + +interface ProgressModalProps { + config: AppConfig + generatedData: GeneratedData + onClose: () => void +} + +interface ProgressEvent { + phase: string + total: number + completed: number + current: string + mainBookUrl?: string + noteCreated?: { + sessionId: string + title: string + shortId: string + url: string + } + error?: { + sessionId?: string + title?: string + message: string + } + createdNotes?: Array<{ + sessionId: string + shortId: string + url: string + }> + errors?: Array<{ + sessionId: string + title: string + error: string + }> +} + +export function ProgressModal({ config, generatedData, onClose }: ProgressModalProps) { + const [phase, setPhase] = useState++ + {/* Markdown content */} +Preview
+ {generatedData && ( ++ + {generatedData.pages.slice(0, 10).map((page, i) => ( + + ))} + {generatedData.pages.length > 10 && ( + + +{generatedData.pages.length - 10} more + + )} ++ )} ++ {currentPage && ( ++++ )} +{currentPage.title}
++ {currentPage.content} ++('idle') + const [total, setTotal] = useState(0) + const [completed, setCompleted] = useState(0) + const [current, setCurrent] = useState('') + const [logs, setLogs] = useState >([]) + const [mainBookUrl, setMainBookUrl] = useState (null) + const [delayMs, setDelayMs] = useState(300) + const [started, setStarted] = useState(false) + + const startCreation = useCallback(async () => { + setStarted(true) + setPhase('creating-sessions') + setLogs([{ type: 'info', text: '🚀 Starting note creation...' }]) + + try { + const response = await fetch('/api/create-notes', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + config: { + apiKey: config.apiKey, + apiEndpoint: config.apiEndpoint, + teamPath: config.teamPath, + webDomain: config.webDomain, + conferenceName: generatedData.homepage.title, + delayMs, + }, + homepage: generatedData.homepage, + pages: generatedData.pages, + }), + }) + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + + const reader = response.body?.getReader() + if (!reader) throw new Error('No response body') + + const decoder = new TextDecoder() + let buffer = '' + + while (true) { + const { done, value } = await reader.read() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split('\n\n') + buffer = lines.pop() || '' + + for (const line of lines) { + if (!line.startsWith('data: ')) continue + const jsonStr = line.slice(6) + let event: ProgressEvent + try { + event = JSON.parse(jsonStr) + } catch { + continue + } + + setPhase(event.phase) + setTotal(event.total) + setCompleted(event.completed) + setCurrent(event.current) + + if (event.noteCreated) { + setLogs(prev => [ + ...prev, + { + type: 'success', + text: `✅ ${event.noteCreated!.title} → ${event.noteCreated!.url}`, + }, + ]) + } + + if (event.error) { + setLogs(prev => [ + ...prev, + { + type: 'error', + text: `❌ ${event.error!.title || 'Error'}: ${event.error!.message}`, + }, + ]) + } + + if (event.mainBookUrl) { + setMainBookUrl(event.mainBookUrl) + setLogs(prev => [ + ...prev, + { type: 'success', text: `📚 Main book: ${event.mainBookUrl}` }, + ]) + } + + if (event.phase === 'done') { + setLogs(prev => [ + ...prev, + { type: 'info', text: `🎉 All done! ${event.completed}/${event.total} notes created.` }, + ]) + } + } + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + setPhase('error') + setLogs(prev => [...prev, { type: 'error', text: `💥 ${message}` }]) + } + }, [config, generatedData, delayMs]) + + // Auto-scroll logs + useEffect(() => { + const el = document.getElementById('progress-logs') + if (el) el.scrollTop = el.scrollHeight + }, [logs]) + + const percent = total > 0 ? Math.round((completed / total) * 100) : 0 + const isDone = phase === 'done' || phase === 'error' + + return ( + ++ ) +} diff --git a/examples/ai-conference-assistant/src/components/setup-panel.tsx b/examples/ai-conference-assistant/src/components/setup-panel.tsx new file mode 100644 index 0000000..3fe24f0 --- /dev/null +++ b/examples/ai-conference-assistant/src/components/setup-panel.tsx @@ -0,0 +1,168 @@ +'use client' + +import { useState, type FormEvent } from 'react' +import type { AppConfig } from '@/app/page' + +interface SetupPanelProps { + onConfigured: (config: AppConfig) => void +} + +export function SetupPanel({ onConfigured }: SetupPanelProps) { + const [apiKey, setApiKey] = useState('') + const [openaiApiKey, setOpenaiApiKey] = useState('') + const [apiEndpoint, setApiEndpoint] = useState('https://api.hackmd.io/v1') + const [teamPath, setTeamPath] = useState('') + const [webDomain, setWebDomain] = useState('https://hackmd.io') + const [verifying, setVerifying] = useState(false) + const [error, setError] = useState('') + + async function handleSubmit(e: FormEvent) { + e.preventDefault() + setError('') + setVerifying(true) + + try { + // Verify HackMD credentials + const res = await fetch(`${apiEndpoint}/me`, { + headers: { Authorization: `Bearer ${apiKey}` }, + }) + if (!res.ok) { + throw new Error(`HackMD API returned ${res.status}: ${res.statusText}`) + } + const user = await res.json() + const teams = user.teams || [] + const hasTeam = !teamPath || teams.some((t: { path: string }) => t.path === teamPath) + + if (teamPath && !hasTeam) { + const teamNames = teams.map((t: { path: string }) => t.path).join(', ') + throw new Error( + `Team "${teamPath}" not found. Available teams: ${teamNames || 'none'}`, + ) + } + + onConfigured({ + apiKey, + openaiApiKey, + apiEndpoint, + teamPath: teamPath || teams[0]?.path || '', + webDomain, + }) + } catch (err) { + setError(err instanceof Error ? err.message : 'Verification failed') + } finally { + setVerifying(false) + } + } + + return ( ++ {/* Header */} ++++ + {/* Content */} ++ {isDone ? '🎉 Notes Created' : '📝 Creating Notes...'} +
+ {isDone && ( + + )} ++ {/* Pre-start config */} + {!started && ( ++ + {/* Footer */} + {isDone && ( +++ )} + + {/* Progress bar */} + {started && ( + <> ++ Ready to create {generatedData.pages.length + 1} notes in + team {config.teamPath}. +
+ ++ + setDelayMs(parseInt(e.target.value) || 0)} + min={0} + max={5000} + step={100} + className="w-32 px-3 py-2 border border-gray-300 rounded-lg text-gray-900" + /> ++ + ++ Recommended: 200-500ms to avoid rate limits +
+++ + {/* Log output */} ++ + {phase === 'creating-sessions' + ? 'Creating session notes...' + : phase === 'creating-book' + ? 'Creating homepage...' + : phase === 'done' + ? 'Complete!' + : phase === 'error' + ? 'Error occurred' + : 'Starting...'} + + + {completed}/{total} ({percent}%) + +++ ++ {current && phase !== 'done' && ( +Current: {current}
+ )} ++ {logs.map((log, i) => ( ++ + {/* Main book link */} + {mainBookUrl && ( ++ {log.text} ++ ))} +++ )} + > + )} +📚 Main Book Created:
+ + {mainBookUrl} + ++ ++ )} +++ ) +} diff --git a/examples/ai-conference-assistant/src/lib/hackmd-client.ts b/examples/ai-conference-assistant/src/lib/hackmd-client.ts new file mode 100644 index 0000000..b96f82a --- /dev/null +++ b/examples/ai-conference-assistant/src/lib/hackmd-client.ts @@ -0,0 +1,122 @@ +/** + * Server-side HackMD API client wrapper. + * This file wraps the raw HackMD REST API using fetch so we avoid bundling + * the full @hackmd/api (which depends on axios and Node-only modules) into + * the Next.js edge/serverless runtime. + */ + +export interface HackMDClientOptions { + accessToken: string + apiEndpoint?: string +} + +export interface HackMDNote { + id: string + title: string + tags: string[] + shortId: string + publishLink: string + content?: string + publishType?: string + readPermission?: string + writePermission?: string + teamPath?: string | null + userPath?: string | null + lastChangedAt?: string + createdAt?: string +} + +export interface HackMDUser { + id: string + name: string + email: string | null + userPath: string + photo: string + teams: Array<{ + id: string + name: string + path: string + logo: string + description: string + visibility: string + }> +} + +export interface CreateNotePayload { + title?: string + content?: string + readPermission?: 'owner' | 'signed_in' | 'guest' + writePermission?: 'owner' | 'signed_in' | 'guest' + commentPermission?: string + permalink?: string +} + +class HackMDAPIError extends Error { + constructor( + message: string, + public status: number, + public statusText: string, + ) { + super(message) + this.name = 'HackMDAPIError' + } +} + +export class HackMDClient { + private baseURL: string + private accessToken: string + + constructor(options: HackMDClientOptions) { + this.accessToken = options.accessToken + this.baseURL = options.apiEndpoint || 'https://api.hackmd.io/v1' + } + + private async request++++ + +📚 HackMD Conference Assistant
++ AI-powered collaborative note generator for conferences +
+(path: string, options: RequestInit = {}): Promise { + const url = `${this.baseURL}/${path}` + const response = await fetch(url, { + ...options, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.accessToken}`, + ...options.headers, + }, + }) + + if (!response.ok) { + const text = await response.text().catch(() => '') + throw new HackMDAPIError( + `HackMD API error: ${response.status} ${response.statusText}${text ? ` - ${text}` : ''}`, + response.status, + response.statusText, + ) + } + + return response.json() as Promise + } + + async getMe(): Promise { + return this.request ('me') + } + + async getNote(noteId: string): Promise { + return this.request (`notes/${noteId}`) + } + + async getTeamNotes(teamPath: string): Promise { + return this.request (`teams/${teamPath}/notes`) + } + + async createTeamNote(teamPath: string, payload: CreateNotePayload): Promise { + return this.request (`teams/${teamPath}/notes`, { + method: 'POST', + body: JSON.stringify(payload), + }) + } + + async createNote(payload: CreateNotePayload): Promise { + return this.request ('notes', { + method: 'POST', + body: JSON.stringify(payload), + }) + } +} diff --git a/examples/ai-conference-assistant/src/lib/tools.ts b/examples/ai-conference-assistant/src/lib/tools.ts new file mode 100644 index 0000000..4d75a3b --- /dev/null +++ b/examples/ai-conference-assistant/src/lib/tools.ts @@ -0,0 +1,425 @@ +/** + * AI SDK tool definitions for the conference assistant agent. + * + * Tools available to the AI: + * - hackmd_get_me: Verify credentials & get user info + * - hackmd_get_note: Fetch a note's content (for reference/templates) + * - hackmd_get_team_notes: List team notes + * - jq_query: Token-efficient JSON data analysis + * - preview_pages: Return generated markdown for preview + */ + +import { tool } from 'ai' +import { z } from 'zod' +import { HackMDClient } from './hackmd-client' + +/** + * Create all read-only tools the AI agent can use during conversation. + * Write tools (createTeamNote) are NOT exposed to the AI — note creation + * is handled by a dedicated server action with progress tracking. + */ +export function createTools(apiKey: string, apiEndpoint: string) { + const client = new HackMDClient({ + accessToken: apiKey, + apiEndpoint, + }) + + return { + hackmd_get_me: tool({ + description: 'Get the current HackMD user info and list of teams. Use this to verify credentials and discover available teams.', + inputSchema: z.object({}), + execute: async () => { + const user = await client.getMe() + return { + name: user.name, + email: user.email, + userPath: user.userPath, + teams: user.teams.map(t => ({ + name: t.name, + path: t.path, + description: t.description, + })), + } + }, + }), + + hackmd_get_note: tool({ + description: 'Fetch a single HackMD note by its ID or shortId. Returns the full content. Useful for reading reference/template notes from previous conferences.', + inputSchema: z.object({ + noteId: z.string().describe('The note ID or shortId to fetch'), + }), + execute: async ({ noteId }) => { + const note = await client.getNote(noteId) + return { + id: note.id, + title: note.title, + shortId: note.shortId, + content: note.content, + tags: note.tags, + publishType: note.publishType, + } + }, + }), + + hackmd_get_team_notes: tool({ + description: 'List all notes in a team workspace. Returns note metadata (no content). Use to discover existing notes or find reference templates.', + inputSchema: z.object({ + teamPath: z.string().describe('The team path (e.g. "my-team")'), + }), + execute: async ({ teamPath }) => { + const notes = await client.getTeamNotes(teamPath) + return notes.map(n => ({ + id: n.id, + title: n.title, + shortId: n.shortId, + tags: n.tags, + publishType: n.publishType, + lastChangedAt: n.lastChangedAt, + })) + }, + }), + + jq_query: tool({ + description: `Analyze JSON session data using jq-like queries. This is token-efficient — use it to understand data shape, count items, filter, group, and extract fields without sending the full data to the conversation. + +Supported operations: +- "length" — count items +- "keys" — get field names from first item +- "unique " — unique values of a field +- "group_by " — group and count by field +- "select " — filter items (ops: ==, !=, contains) +- "map ..." — extract specific fields +- "first [n]" — first n items (default 1) +- "sort_by [desc]" — sort items +- "flat_map " — flatten nested arrays by field`, + inputSchema: z.object({ + query: z.string().describe('The jq-like query to run on the session data'), + data: z.string().describe('The JSON data to query (stringified)'), + }), + execute: async ({ query, data }) => { + return executeJqQuery(query, data) + }, + }), + + generate_pages: tool({ + description: `Generate all conference note pages (homepage + individual session pages) based on the session data and configuration. Call this when you have enough information from the user about: conference name, team path, session data format, desired page template, and any customizations. + +Returns the generated markdown for preview. The user can then confirm to actually create the notes via HackMD API.`, + inputSchema: z.object({ + conferenceName: z.string().describe('Conference name (e.g. "COSCUP 2026")'), + teamPath: z.string().describe('HackMD team path'), + sessionsJson: z.string().describe('The full sessions JSON data as a string'), + announcementNote: z.string().optional().describe('HackMD announcement note to embed (e.g. "@team/note-id")'), + excludeTypes: z.array(z.string()).optional().describe('Session titles to exclude (e.g. ["Break", "Lunch"])'), + pageTemplate: z.string().optional().describe('Custom page template. Use {title}, {time}, {room}, {announcement}, {tags} as placeholders.'), + webDomain: z.string().optional().describe('HackMD web domain for links (default: https://hackmd.io)'), + }), + execute: async ({ + conferenceName, + sessionsJson, + announcementNote, + excludeTypes, + pageTemplate, + }) => { + return generateAllPages({ + conferenceName, + sessionsJson, + announcementNote: announcementNote || '', + excludeTypes: excludeTypes || [], + pageTemplate, + }) + }, + }), + } +} + +// ============================================================ +// jq-like query engine (token-efficient data analysis) +// ============================================================ + +function executeJqQuery(query: string, dataStr: string): unknown { + let data: unknown + try { + data = JSON.parse(dataStr) + } catch { + return { error: 'Invalid JSON data' } + } + + const arr = Array.isArray(data) ? data : [data] + const parts = query.trim().split(/\s+/) + const cmd = parts[0] + + switch (cmd) { + case 'length': + return { count: arr.length } + + case 'keys': { + if (arr.length === 0) return { keys: [] } + return { keys: Object.keys(arr[0] as Record ), sample: arr[0] } + } + + case 'unique': { + const field = parts[1] + if (!field) return { error: 'Usage: unique ' } + const values = [...new Set(arr.map(item => getNestedValue(item, field)))] + return { field, uniqueValues: values, count: values.length } + } + + case 'group_by': { + const field = parts[1] + if (!field) return { error: 'Usage: group_by ' } + const groups: Record = {} + for (const item of arr) { + const val = String(getNestedValue(item, field) ?? 'null') + groups[val] = (groups[val] || 0) + 1 + } + return { field, groups, totalGroups: Object.keys(groups).length } + } + + case 'select': { + const field = parts[1] + const op = parts[2] + const value = parts.slice(3).join(' ') + if (!field || !op) return { error: 'Usage: select ' } + const filtered = arr.filter(item => { + const v = getNestedValue(item, field) + switch (op) { + case '==': return String(v) === value + case '!=': return String(v) !== value + case 'contains': return String(v).includes(value) + default: return false + } + }) + return { count: filtered.length, results: filtered } + } + + case 'map': { + const fields = parts.slice(1) + if (fields.length === 0) return { error: 'Usage: map ...' } + const mapped = arr.map(item => { + const result: Record = {} + for (const f of fields) { + result[f] = getNestedValue(item, f) + } + return result + }) + return mapped + } + + case 'first': { + const n = parseInt(parts[1] || '1', 10) + return arr.slice(0, n) + } + + case 'sort_by': { + const field = parts[1] + const desc = parts[2] === 'desc' + if (!field) return { error: 'Usage: sort_by [desc]' } + const sorted = [...arr].sort((a, b) => { + const va = String(getNestedValue(a, field) ?? '') + const vb = String(getNestedValue(b, field) ?? '') + return desc ? vb.localeCompare(va) : va.localeCompare(vb) + }) + return sorted + } + + case 'flat_map': { + const field = parts[1] + if (!field) return { error: 'Usage: flat_map ' } + const results: unknown[] = [] + for (const item of arr) { + const val = getNestedValue(item, field) + if (Array.isArray(val)) { + results.push(...val) + } else { + results.push(val) + } + } + return { count: results.length, results } + } + + default: + return { + error: `Unknown command: ${cmd}`, + help: 'Supported: length, keys, unique , group_by , select , map , first [n], sort_by [desc], flat_map ', + } + } +} + +function getNestedValue(obj: unknown, path: string): unknown { + const parts = path.split('.') + let current: unknown = obj + for (const part of parts) { + if (current == null || typeof current !== 'object') return undefined + current = (current as Record )[part] + } + return current +} + +// ============================================================ +// Page generation +// ============================================================ + +interface GenerateOptions { + conferenceName: string + sessionsJson: string + announcementNote: string + excludeTypes: string[] + pageTemplate?: string +} + +interface ProcessedSession { + id: string + title: string + speakers: string + startDate: string + day: string + startTime: string + endTime: string + sessionType: string + classroom: string + language: string + difficulty: string + tags: string[] +} + +function processSessions(raw: string, excludeTypes: string[], conferenceName: string): ProcessedSession[] { + const sessions = JSON.parse(raw) + if (!Array.isArray(sessions)) throw new Error('Session data must be an array') + + const defaultExclude = ['報到時間', '開幕', '閉幕', 'Opening', 'Closing', 'Break', 'Lunch', '休息時間', '午餐'] + const allExclude = [...defaultExclude, ...excludeTypes] + + return sessions + .filter((s: Record ) => { + if (!s.session_type) return false + const title = String(s.title || '').trim() + return !allExclude.includes(title) + }) + .map((s: Record ) => { + const speakers = Array.isArray(s.speaker) + ? (s.speaker as Array<{ speaker: { public_name: string } }>) + .map(sp => sp.speaker.public_name) + .join('、') + : '' + + const startedAt = new Date(s.started_at as string) + const finishedAt = new Date(s.finished_at as string) + const classroom = s.classroom as { tw_name?: string; en_name?: string } | undefined + + return { + id: String(s.id), + title: String(s.title) + (speakers ? ` - ${speakers}` : ''), + speakers, + startDate: startedAt.toISOString(), + day: `${String(startedAt.getMonth() + 1).padStart(2, '0')}/${String(startedAt.getDate()).padStart(2, '0')}`, + startTime: `${String(startedAt.getHours()).padStart(2, '0')}:${String(startedAt.getMinutes()).padStart(2, '0')}`, + endTime: `${String(finishedAt.getHours()).padStart(2, '0')}:${String(finishedAt.getMinutes()).padStart(2, '0')}`, + sessionType: String(s.session_type || ''), + classroom: classroom?.tw_name || classroom?.en_name || 'TBD', + language: String(s.language || 'en'), + difficulty: String(s.difficulty || 'General'), + tags: [conferenceName, ...((s.tags || []) as string[])], + } + }) + .sort((a: ProcessedSession, b: ProcessedSession) => a.startDate.localeCompare(b.startDate)) +} + +function generateSessionPage(session: ProcessedSession, announcementNote: string, conferenceName: string, customTemplate?: string): string { + if (customTemplate) { + return customTemplate + .replace(/\{title\}/g, session.title) + .replace(/\{time\}/g, `${session.startTime} ~ ${session.endTime}`) + .replace(/\{room\}/g, session.classroom) + .replace(/\{announcement\}/g, announcementNote ? `{%hackmd ${announcementNote} %}` : '') + .replace(/\{tags\}/g, conferenceName) + .replace(/\{speakers\}/g, session.speakers) + .replace(/\{difficulty\}/g, session.difficulty) + .replace(/\{language\}/g, session.language) + } + + return `# ${session.title} + +**Time:** ${session.startTime} ~ ${session.endTime} | **Room:** ${session.classroom} +${announcementNote ? `\n{%hackmd ${announcementNote} %}\n` : ''} +> ==投影片== +> (講者請在此放置投影片連結) + +> ==Q & A== +> (講者 Q&A 相關連結) + +## 📝 筆記區 +> 請從這裡開始記錄你的筆記 + + + +## ❓ Q&A 區域 +> 講者問答與現場互動 + + + +## 💬 討論區 +> 歡迎在此進行討論與交流 + + + +###### tags: \`${conferenceName}\` +` +} + +function generateHomepage(sessions: ProcessedSession[], conferenceName: string): string { + // Group by day, then by start time + const byDay: Record = {} + for (const s of sessions) { + if (!byDay[s.day]) byDay[s.day] = [] + byDay[s.day].push(s) + } + + let bookContent = '' + const sortedDays = Object.keys(byDay).sort() + for (const day of sortedDays) { + bookContent += `## ${day}\n\n` + const daySessions = byDay[day].sort((a, b) => a.startTime.localeCompare(b.startTime)) + for (const s of daySessions) { + // Placeholder links — will be replaced with actual shortIds after creation + bookContent += `- ${s.startTime} ~ ${s.endTime} [${s.title}](/{noteUrl:${s.id}}) (${s.classroom})\n` + } + bookContent += '\n' + } + + return `${conferenceName} 共同筆記 +=== + +## 歡迎來到 ${conferenceName}! + +- [HackMD 快速入門](https://hackmd.io/s/BJvtP4zGX) +- [HackMD 會議功能介紹](https://hackmd.io/s/BJHWlNQMX) + +## 議程筆記 + +${bookContent} +###### tags: \`${conferenceName}\` +` +} + +function generateAllPages(options: GenerateOptions) { + const sessions = processSessions(options.sessionsJson, options.excludeTypes, options.conferenceName) + + const pages = sessions.map(s => ({ + sessionId: s.id, + title: s.title, + content: generateSessionPage(s, options.announcementNote, options.conferenceName, options.pageTemplate), + })) + + const homepage = { + title: `${options.conferenceName} 共同筆記`, + content: generateHomepage(sessions, options.conferenceName), + } + + return { + homepage, + pages, + totalPages: pages.length + 1, + summary: `Generated ${pages.length} session pages + 1 homepage for ${options.conferenceName}`, + } +} diff --git a/examples/ai-conference-assistant/src/lib/types.ts b/examples/ai-conference-assistant/src/lib/types.ts new file mode 100644 index 0000000..23cd353 --- /dev/null +++ b/examples/ai-conference-assistant/src/lib/types.ts @@ -0,0 +1,47 @@ +/** + * Shared types for the AI Conference Assistant + */ + +export interface SessionData { + id: string + title: string + speaker: Array<{ + speaker: { + public_name: string + } + }> + session_type: string | null + started_at: string + finished_at: string + tags?: string[] + classroom?: { + tw_name?: string + en_name?: string + } + language?: string + difficulty?: string +} + +export interface GeneratedPage { + sessionId: string + title: string + content: string +} + +export interface CreatedNote { + sessionId: string + noteId: string + shortId: string + url: string + title: string +} + +export interface ProgressState { + total: number + completed: number + current: string + createdNotes: CreatedNote[] + errors: Array<{ sessionId: string; title: string; error: string }> + phase: 'idle' | 'creating-sessions' | 'creating-book' | 'done' | 'error' + mainBookUrl?: string +} diff --git a/examples/ai-conference-assistant/tsconfig.json b/examples/ai-conference-assistant/tsconfig.json new file mode 100644 index 0000000..cf9c65d --- /dev/null +++ b/examples/ai-conference-assistant/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts", + "**/*.mts" + ], + "exclude": ["node_modules"] +} From 634c3b303123ecc2ad72acef25b41f46fa88a51e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Mar 2026 03:35:32 +0000 Subject: [PATCH 2/8] fix: address code review feedback - fix ref update, extract constants, improve progress tracking Co-authored-by: Yukaii <4230968+Yukaii@users.noreply.github.com> Agent-Logs-Url: https://github.com/hackmdio/api-client/sessions/a73c91f7-229e-482d-b038-6bb8e454ab09 --- README.md | 20 + .../ai-conference-assistant/public/file.svg | 1 - .../ai-conference-assistant/public/globe.svg | 1 - .../ai-conference-assistant/public/next.svg | 1 - .../ai-conference-assistant/public/vercel.svg | 1 - .../ai-conference-assistant/public/window.svg | 1 - .../src/app/api/create-notes/route.ts | 4 +- .../ai-conference-assistant/src/app/page.tsx | 4 +- .../src/components/chat-panel.tsx | 4 +- .../src/components/progress-modal.tsx | 6 +- .../ai-conference-assistant/src/lib/tools.ts | 6 +- nodejs/package-lock.json | 342 ------------------ 12 files changed, 35 insertions(+), 356 deletions(-) delete mode 100644 examples/ai-conference-assistant/public/file.svg delete mode 100644 examples/ai-conference-assistant/public/globe.svg delete mode 100644 examples/ai-conference-assistant/public/next.svg delete mode 100644 examples/ai-conference-assistant/public/vercel.svg delete mode 100644 examples/ai-conference-assistant/public/window.svg diff --git a/README.md b/README.md index 0b890b7..dce08d8 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,26 @@ To run the book mode conference example: This example demonstrates advanced usage patterns including bulk operations, team note management, and creating interconnected note structures for conferences or events. +### AI Conference Assistant (Web) + +The `examples/ai-conference-assistant/` directory contains a web-based AI assistant that helps create book-mode conference notes through a chat interface: + +- **Chat Interface**: Conversational AI (powered by Vercel AI SDK) guides you through conference note creation +- **Frontend API Key Entry**: Provide your HackMD and OpenAI API keys from the browser — no server-side secrets needed +- **Session Data Analysis**: Upload conference session JSON; the AI uses a jq-like tool to efficiently analyze data shape +- **Reference Note Fetching**: Point the AI to existing HackMD notes to replicate formatting from previous conferences +- **Markdown Preview**: Preview the generated homepage and all session pages before creating +- **Rate-Limit-Aware Creation**: Batch note creation with configurable delay and real-time SSE progress tracking + +To run the AI conference assistant: + +1. Navigate to the example directory: `cd examples/ai-conference-assistant` +2. Install dependencies: `npm install` +3. Start the development server: `npm run dev` +4. Open [http://localhost:3000](http://localhost:3000) and enter your credentials + +See [examples/ai-conference-assistant/README.md](./examples/ai-conference-assistant/README.md) for full documentation. + ## LICENSE MIT diff --git a/examples/ai-conference-assistant/public/file.svg b/examples/ai-conference-assistant/public/file.svg deleted file mode 100644 index 004145c..0000000 --- a/examples/ai-conference-assistant/public/file.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/examples/ai-conference-assistant/public/globe.svg b/examples/ai-conference-assistant/public/globe.svg deleted file mode 100644 index 567f17b..0000000 --- a/examples/ai-conference-assistant/public/globe.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/examples/ai-conference-assistant/public/next.svg b/examples/ai-conference-assistant/public/next.svg deleted file mode 100644 index 5174b28..0000000 --- a/examples/ai-conference-assistant/public/next.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/examples/ai-conference-assistant/public/vercel.svg b/examples/ai-conference-assistant/public/vercel.svg deleted file mode 100644 index 7705396..0000000 --- a/examples/ai-conference-assistant/public/vercel.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/examples/ai-conference-assistant/public/window.svg b/examples/ai-conference-assistant/public/window.svg deleted file mode 100644 index b2b2a44..0000000 --- a/examples/ai-conference-assistant/public/window.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/examples/ai-conference-assistant/src/app/api/create-notes/route.ts b/examples/ai-conference-assistant/src/app/api/create-notes/route.ts index ffed13a..480e7a2 100644 --- a/examples/ai-conference-assistant/src/app/api/create-notes/route.ts +++ b/examples/ai-conference-assistant/src/app/api/create-notes/route.ts @@ -144,8 +144,8 @@ export async function POST(req: Request) { `/${shortId}`, ) } - // Remove any remaining unresolved placeholders (failed sessions) - homepageContent = homepageContent.replace(/\/{noteUrl:[^}]+}/g, '#') + // Remove lines with unresolved placeholders (failed sessions) + homepageContent = homepageContent.replace(/^.*\/{noteUrl:[^}]+}.*\n?/gm, '') const mainNote = await client.createTeamNote(config.teamPath, { title: homepage.title, diff --git a/examples/ai-conference-assistant/src/app/page.tsx b/examples/ai-conference-assistant/src/app/page.tsx index 02b2077..6faa4f1 100644 --- a/examples/ai-conference-assistant/src/app/page.tsx +++ b/examples/ai-conference-assistant/src/app/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useRef } from 'react' +import { useState, useRef, useEffect } from 'react' import { SetupPanel } from '@/components/setup-panel' import { ChatPanel } from '@/components/chat-panel' import { PreviewPanel } from '@/components/preview-panel' @@ -26,7 +26,7 @@ export default function Home() { const [showProgress, setShowProgress] = useState(false) const [previewPage, setPreviewPage] = useState<{ title: string; content: string } | null>(null) const sessionDataRef = useRef(sessionData) - sessionDataRef.current = sessionData + useEffect(() => { sessionDataRef.current = sessionData }, [sessionData]) if (!config) { return diff --git a/examples/ai-conference-assistant/src/components/chat-panel.tsx b/examples/ai-conference-assistant/src/components/chat-panel.tsx index 6b81c05..802d7c6 100644 --- a/examples/ai-conference-assistant/src/components/chat-panel.tsx +++ b/examples/ai-conference-assistant/src/components/chat-panel.tsx @@ -25,6 +25,7 @@ export function ChatPanel({ generatedData, }: ChatPanelProps) { const [fileUploaded, setFileUploaded] = useState(false) + const [sessionDataSent, setSessionDataSent] = useState(false) const [inputValue, setInputValue] = useState('') const messagesEndRef = useRef (null) const fileInputRef = useRef (null) @@ -77,10 +78,11 @@ export function ChatPanel({ if ( fileUploaded && sessionData.current && - !messages.some(m => m.parts?.some(p => p.type === 'text' && p.text.includes('[Session data uploaded'))) + !sessionDataSent ) { const count = JSON.parse(sessionData.current).length text = `${inputValue}\n\n[Session data uploaded - ${count} sessions]\n \n${sessionData.current}\n ` + setSessionDataSent(true) } sendMessage({ text }) diff --git a/examples/ai-conference-assistant/src/components/progress-modal.tsx b/examples/ai-conference-assistant/src/components/progress-modal.tsx index dc20d02..068c0b8 100644 --- a/examples/ai-conference-assistant/src/components/progress-modal.tsx +++ b/examples/ai-conference-assistant/src/components/progress-modal.tsx @@ -105,21 +105,23 @@ export function ProgressModal({ config, generatedData, onClose }: ProgressModalP setCurrent(event.current) if (event.noteCreated) { + const created = event.noteCreated setLogs(prev => [ ...prev, { type: 'success', - text: `✅ ${event.noteCreated!.title} → ${event.noteCreated!.url}`, + text: `✅ ${created.title} → ${created.url}`, }, ]) } if (event.error) { + const err = event.error setLogs(prev => [ ...prev, { type: 'error', - text: `❌ ${event.error!.title || 'Error'}: ${event.error!.message}`, + text: `❌ ${err.title || 'Error'}: ${err.message}`, }, ]) } diff --git a/examples/ai-conference-assistant/src/lib/tools.ts b/examples/ai-conference-assistant/src/lib/tools.ts index 4d75a3b..317a590 100644 --- a/examples/ai-conference-assistant/src/lib/tools.ts +++ b/examples/ai-conference-assistant/src/lib/tools.ts @@ -283,12 +283,14 @@ interface ProcessedSession { tags: string[] } +/** Default session titles to exclude from note generation (non-content sessions) */ +const DEFAULT_EXCLUDE_TYPES = ['報到時間', '開幕', '閉幕', 'Opening', 'Closing', 'Break', 'Lunch', '休息時間', '午餐'] + function processSessions(raw: string, excludeTypes: string[], conferenceName: string): ProcessedSession[] { const sessions = JSON.parse(raw) if (!Array.isArray(sessions)) throw new Error('Session data must be an array') - const defaultExclude = ['報到時間', '開幕', '閉幕', 'Opening', 'Closing', 'Break', 'Lunch', '休息時間', '午餐'] - const allExclude = [...defaultExclude, ...excludeTypes] + const allExclude = [...DEFAULT_EXCLUDE_TYPES, ...excludeTypes] return sessions .filter((s: Record) => { diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index e1d877e..2c71d9a 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -595,32 +595,6 @@ "tough-cookie": "^4.1.4" } }, - "node_modules/@cspotcode/source-map-support": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", - "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@jridgewell/trace-mapping": "0.3.9" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" - } - }, "node_modules/@eslint-community/eslint-utils": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", @@ -907,51 +881,6 @@ } } }, - "node_modules/@jest/core/node_modules/ts-node": { - "version": "10.9.1", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz", - "integrity": "sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@cspotcode/source-map-support": "^0.8.0", - "@tsconfig/node10": "^1.0.7", - "@tsconfig/node12": "^1.0.7", - "@tsconfig/node14": "^1.0.0", - "@tsconfig/node16": "^1.0.2", - "acorn": "^8.4.1", - "acorn-walk": "^8.1.1", - "arg": "^4.1.0", - "create-require": "^1.1.0", - "diff": "^4.0.1", - "make-error": "^1.1.1", - "v8-compile-cache-lib": "^3.0.1", - "yn": "3.1.1" - }, - "bin": { - "ts-node": "dist/bin.js", - "ts-node-cwd": "dist/bin-cwd.js", - "ts-node-esm": "dist/bin-esm.js", - "ts-node-script": "dist/bin-script.js", - "ts-node-transpile-only": "dist/bin-transpile.js", - "ts-script": "dist/bin-script-deprecated.js" - }, - "peerDependencies": { - "@swc/core": ">=1.2.50", - "@swc/wasm": ">=1.2.50", - "@types/node": "*", - "typescript": ">=2.7" - }, - "peerDependenciesMeta": { - "@swc/core": { - "optional": true - }, - "@swc/wasm": { - "optional": true - } - } - }, "node_modules/@jest/environment": { "version": "29.4.2", "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.4.2.tgz", @@ -1744,38 +1673,6 @@ "@sinonjs/commons": "^2.0.0" } }, - "node_modules/@tsconfig/node10": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", - "integrity": "sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==", - "dev": true, - "optional": true, - "peer": true - }, - "node_modules/@tsconfig/node12": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", - "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "dev": true, - "optional": true, - "peer": true - }, - "node_modules/@tsconfig/node14": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", - "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "dev": true, - "optional": true, - "peer": true - }, - "node_modules/@tsconfig/node16": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.3.tgz", - "integrity": "sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==", - "dev": true, - "optional": true, - "peer": true - }, "node_modules/@types/babel__core": { "version": "7.20.0", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.0.tgz", @@ -2206,17 +2103,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/acorn-walk": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz", - "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", - "dev": true, - "optional": true, - "peer": true, - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -2705,14 +2591,6 @@ "node": ">= 0.6" } }, - "node_modules/create-require": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", - "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "dev": true, - "optional": true, - "peer": true - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4110,51 +3988,6 @@ } } }, - "node_modules/jest-cli/node_modules/ts-node": { - "version": "10.9.1", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz", - "integrity": "sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@cspotcode/source-map-support": "^0.8.0", - "@tsconfig/node10": "^1.0.7", - "@tsconfig/node12": "^1.0.7", - "@tsconfig/node14": "^1.0.0", - "@tsconfig/node16": "^1.0.2", - "acorn": "^8.4.1", - "acorn-walk": "^8.1.1", - "arg": "^4.1.0", - "create-require": "^1.1.0", - "diff": "^4.0.1", - "make-error": "^1.1.1", - "v8-compile-cache-lib": "^3.0.1", - "yn": "3.1.1" - }, - "bin": { - "ts-node": "dist/bin.js", - "ts-node-cwd": "dist/bin-cwd.js", - "ts-node-esm": "dist/bin-esm.js", - "ts-node-script": "dist/bin-script.js", - "ts-node-transpile-only": "dist/bin-transpile.js", - "ts-script": "dist/bin-script-deprecated.js" - }, - "peerDependencies": { - "@swc/core": ">=1.2.50", - "@swc/wasm": ">=1.2.50", - "@types/node": "*", - "typescript": ">=2.7" - }, - "peerDependenciesMeta": { - "@swc/core": { - "optional": true - }, - "@swc/wasm": { - "optional": true - } - } - }, "node_modules/jest-diff": { "version": "29.4.2", "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.4.2.tgz", @@ -4971,18 +4804,6 @@ } } }, - "node_modules/msw/node_modules/@types/node": { - "version": "22.13.14", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.14.tgz", - "integrity": "sha512-Zs/Ollc1SJ8nKUAgc7ivOEdIBM8JAKgrqqUYi2J997JuKO7/tpQC+WCetQ1sypiKCQWHdvdg9wBNpUPEWZae7w==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "undici-types": "~6.20.0" - } - }, "node_modules/msw/node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -5970,15 +5791,6 @@ "node": ">=4.2.0" } }, - "node_modules/undici-types": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", - "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/universalify": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", @@ -6036,14 +5848,6 @@ "requires-port": "^1.0.0" } }, - "node_modules/v8-compile-cache-lib": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", - "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "dev": true, - "optional": true, - "peer": true - }, "node_modules/v8-to-istanbul": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.0.1.tgz", @@ -6629,31 +6433,6 @@ "tough-cookie": "^4.1.4" } }, - "@cspotcode/source-map-support": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", - "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "dev": true, - "optional": true, - "peer": true, - "requires": { - "@jridgewell/trace-mapping": "0.3.9" - }, - "dependencies": { - "@jridgewell/trace-mapping": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "dev": true, - "optional": true, - "peer": true, - "requires": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" - } - } - } - }, "@eslint-community/eslint-utils": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", @@ -6852,29 +6631,6 @@ "slash": "^3.0.0", "strip-json-comments": "^3.1.1" } - }, - "ts-node": { - "version": "10.9.1", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz", - "integrity": "sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==", - "dev": true, - "optional": true, - "peer": true, - "requires": { - "@cspotcode/source-map-support": "^0.8.0", - "@tsconfig/node10": "^1.0.7", - "@tsconfig/node12": "^1.0.7", - "@tsconfig/node14": "^1.0.0", - "@tsconfig/node16": "^1.0.2", - "acorn": "^8.4.1", - "acorn-walk": "^8.1.1", - "arg": "^4.1.0", - "create-require": "^1.1.0", - "diff": "^4.0.1", - "make-error": "^1.1.1", - "v8-compile-cache-lib": "^3.0.1", - "yn": "3.1.1" - } } } }, @@ -7384,38 +7140,6 @@ "@sinonjs/commons": "^2.0.0" } }, - "@tsconfig/node10": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", - "integrity": "sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==", - "dev": true, - "optional": true, - "peer": true - }, - "@tsconfig/node12": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", - "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "dev": true, - "optional": true, - "peer": true - }, - "@tsconfig/node14": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", - "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "dev": true, - "optional": true, - "peer": true - }, - "@tsconfig/node16": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.3.tgz", - "integrity": "sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==", - "dev": true, - "optional": true, - "peer": true - }, "@types/babel__core": { "version": "7.20.0", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.0.tgz", @@ -7725,14 +7449,6 @@ "dev": true, "requires": {} }, - "acorn-walk": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz", - "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", - "dev": true, - "optional": true, - "peer": true - }, "ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -8082,14 +7798,6 @@ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", "dev": true }, - "create-require": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", - "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "dev": true, - "optional": true, - "peer": true - }, "cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -9049,29 +8757,6 @@ "slash": "^3.0.0", "strip-json-comments": "^3.1.1" } - }, - "ts-node": { - "version": "10.9.1", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz", - "integrity": "sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==", - "dev": true, - "optional": true, - "peer": true, - "requires": { - "@cspotcode/source-map-support": "^0.8.0", - "@tsconfig/node10": "^1.0.7", - "@tsconfig/node12": "^1.0.7", - "@tsconfig/node14": "^1.0.0", - "@tsconfig/node16": "^1.0.2", - "acorn": "^8.4.1", - "acorn-walk": "^8.1.1", - "arg": "^4.1.0", - "create-require": "^1.1.0", - "diff": "^4.0.1", - "make-error": "^1.1.1", - "v8-compile-cache-lib": "^3.0.1", - "yn": "3.1.1" - } } } }, @@ -9688,17 +9373,6 @@ "dev": true, "requires": {} }, - "@types/node": { - "version": "22.13.14", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.14.tgz", - "integrity": "sha512-Zs/Ollc1SJ8nKUAgc7ivOEdIBM8JAKgrqqUYi2J997JuKO7/tpQC+WCetQ1sypiKCQWHdvdg9wBNpUPEWZae7w==", - "dev": true, - "optional": true, - "peer": true, - "requires": { - "undici-types": "~6.20.0" - } - }, "signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -10361,14 +10035,6 @@ "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "dev": true }, - "undici-types": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", - "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", - "dev": true, - "optional": true, - "peer": true - }, "universalify": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", @@ -10404,14 +10070,6 @@ "requires-port": "^1.0.0" } }, - "v8-compile-cache-lib": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", - "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "dev": true, - "optional": true, - "peer": true - }, "v8-to-istanbul": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.0.1.tgz", From a20406687646daffc9fcbee9c7bda0c078219e0f Mon Sep 17 00:00:00 2001 From: Yukai Huang Date: Thu, 26 Mar 2026 11:22:09 -0700 Subject: [PATCH 3/8] feat: use api-client and rework flow --- examples/ai-conference-assistant/README.md | 13 +- .../ai-conference-assistant/next.config.mjs | 7 + .../ai-conference-assistant/next.config.ts | 7 - .../ai-conference-assistant/package-lock.json | 46 +++++++ examples/ai-conference-assistant/package.json | 5 +- .../src/app/api/chat/route.ts | 22 +++- .../src/app/api/create-notes/route.ts | 27 ++-- .../src/app/api/verify-hackmd/route.ts | 63 +++++++++ .../ai-conference-assistant/src/app/page.tsx | 1 - .../src/components/chat-panel.tsx | 32 +++-- .../src/components/setup-panel.tsx | 40 ++---- .../src/lib/create-hackmd-api.ts | 9 ++ .../src/lib/hackmd-client.ts | 122 ------------------ .../ai-conference-assistant/src/lib/tools.ts | 11 +- nodejs/src/index.ts | 2 + 15 files changed, 207 insertions(+), 200 deletions(-) create mode 100644 examples/ai-conference-assistant/next.config.mjs delete mode 100644 examples/ai-conference-assistant/next.config.ts create mode 100644 examples/ai-conference-assistant/src/app/api/verify-hackmd/route.ts create mode 100644 examples/ai-conference-assistant/src/lib/create-hackmd-api.ts delete mode 100644 examples/ai-conference-assistant/src/lib/hackmd-client.ts diff --git a/examples/ai-conference-assistant/README.md b/examples/ai-conference-assistant/README.md index b0195f2..f9a929c 100644 --- a/examples/ai-conference-assistant/README.md +++ b/examples/ai-conference-assistant/README.md @@ -9,7 +9,7 @@ A web-based AI assistant that helps you create book-mode collaborative note syst - **Reference Note Fetching**: Point the AI to an existing HackMD note (e.g., last year's conference) and it will analyze the format - **Markdown Preview**: Preview the generated homepage and all session pages before creating - **Rate-Limit-Aware Creation**: Batch note creation with configurable delay and real-time progress tracking via SSE -- **Frontend API Key Entry**: No server-side secrets needed — provide your HackMD and OpenAI API keys from the browser +- **Server-side LLM credentials**: `AI_GATEWAY_API_KEY` is read only on the server (never sent from the browser); you still enter your HackMD token in the UI ## Architecture @@ -19,7 +19,7 @@ A web-based AI assistant that helps you create book-mode collaborative note syst │ │ │ ┌──────────────┐ ┌──────────┐ ┌───────────┐ │ │ │ Setup Panel │ │ Chat UI │ │ Preview │ │ -│ │ (API keys) │ │ (useChat)│ │ (Markdown)│ │ +│ │ (HackMD) │ │ (useChat)│ │ (Markdown)│ │ │ └──────┬───────┘ └────┬─────┘ └───────────┘ │ │ │ │ │ └─────────┼───────────────┼────────────────────────┘ @@ -44,7 +44,7 @@ A web-based AI assistant that helps you create book-mode collaborative note syst - Node.js 18+ - A [HackMD API token](https://hackmd.io/settings/api) -- An [OpenAI API key](https://platform.openai.com/api-keys) +- A [Vercel AI Gateway](https://vercel.com/docs/ai-gateway) API key (or another OpenAI-compatible key) set as **`AI_GATEWAY_API_KEY` in the server environment** — for example in `.env.local` when developing, or in your host’s env for production ### Setup @@ -55,6 +55,11 @@ cd examples/ai-conference-assistant # Install dependencies npm install +# Required: LLM key for the API route (not committed — use .env.local) +echo 'AI_GATEWAY_API_KEY=your_key_here' >> .env.local +# Optional: custom OpenAI-compatible base URL (e.g. Vercel AI Gateway) +# echo 'AI_GATEWAY_BASE_URL=https://ai-gateway.vercel.sh/v1' >> .env.local + # Start the development server npm run dev ``` @@ -63,7 +68,7 @@ Open [http://localhost:3000](http://localhost:3000) in your browser. ### Usage -1. **Enter your credentials** — HackMD API key, OpenAI API key, and team path +1. **Enter your HackMD credentials** — API key and team path (LLM access uses `AI_GATEWAY_API_KEY` on the server only) 2. **Upload session data** — Click the 📁 button to upload your `sessions.json` 3. **Chat with the AI** — Tell it about your conference, reference notes, customizations 4. **Preview** — The AI generates pages; preview them in the right panel diff --git a/examples/ai-conference-assistant/next.config.mjs b/examples/ai-conference-assistant/next.config.mjs new file mode 100644 index 0000000..5efb3b7 --- /dev/null +++ b/examples/ai-conference-assistant/next.config.mjs @@ -0,0 +1,7 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + /** Pre-bundle issues with the linked file:../../nodejs package under Turbopack; webpack resolves it reliably. */ + serverExternalPackages: ["@hackmd/api", "axios"], +}; + +export default nextConfig; diff --git a/examples/ai-conference-assistant/next.config.ts b/examples/ai-conference-assistant/next.config.ts deleted file mode 100644 index e9ffa30..0000000 --- a/examples/ai-conference-assistant/next.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { NextConfig } from "next"; - -const nextConfig: NextConfig = { - /* config options here */ -}; - -export default nextConfig; diff --git a/examples/ai-conference-assistant/package-lock.json b/examples/ai-conference-assistant/package-lock.json index a5b114d..512bfb7 100644 --- a/examples/ai-conference-assistant/package-lock.json +++ b/examples/ai-conference-assistant/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@ai-sdk/openai": "^3.0.48", "@ai-sdk/react": "^3.0.140", + "@hackmd/api": "file:../../nodejs", "ai": "^6.0.138", "next": "16.2.1", "react": "19.2.4", @@ -27,6 +28,35 @@ "typescript": "^5" } }, + "../../nodejs": { + "name": "@hackmd/api", + "version": "2.5.0", + "license": "MIT", + "dependencies": { + "axios": "^1.8.4", + "tslib": "^1.14.1" + }, + "devDependencies": { + "@faker-js/faker": "^7.6.0", + "@rollup/plugin-commonjs": "^28.0.3", + "@rollup/plugin-node-resolve": "^16.0.1", + "@rollup/plugin-typescript": "^12.1.2", + "@types/eslint": "^8.21.0", + "@types/jest": "^29.4.0", + "@types/node": "^13.11.1", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@typescript-eslint/parser": "^6.21.0", + "dotenv": "^16.0.3", + "eslint": "^8.57.1", + "jest": "^29.4.2", + "msw": "^2.7.3", + "rimraf": "^4.1.2", + "rollup": "^4.41.1", + "ts-jest": "^29.0.5", + "ts-node": "^8.8.2", + "typescript": "^4.9.5" + } + }, "node_modules/@ai-sdk/gateway": { "version": "3.0.80", "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.80.tgz", @@ -151,6 +181,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -537,6 +568,10 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@hackmd/api": { + "resolved": "../../nodejs", + "link": true + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1660,6 +1695,7 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1719,6 +1755,7 @@ "integrity": "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.57.2", "@typescript-eslint/types": "8.57.2", @@ -2253,6 +2290,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2614,6 +2652,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3191,6 +3230,7 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3376,6 +3416,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -5588,6 +5629,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -5597,6 +5639,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -6310,6 +6353,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -6472,6 +6516,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6755,6 +6800,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/examples/ai-conference-assistant/package.json b/examples/ai-conference-assistant/package.json index 06f97b4..9a6cf5d 100644 --- a/examples/ai-conference-assistant/package.json +++ b/examples/ai-conference-assistant/package.json @@ -3,12 +3,13 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev", - "build": "next build", + "dev": "next dev --webpack", + "build": "next build --webpack", "start": "next start", "lint": "eslint" }, "dependencies": { + "@hackmd/api": "file:../../nodejs", "@ai-sdk/openai": "^3.0.48", "@ai-sdk/react": "^3.0.140", "ai": "^6.0.138", diff --git a/examples/ai-conference-assistant/src/app/api/chat/route.ts b/examples/ai-conference-assistant/src/app/api/chat/route.ts index 35247ed..5f63fec 100644 --- a/examples/ai-conference-assistant/src/app/api/chat/route.ts +++ b/examples/ai-conference-assistant/src/app/api/chat/route.ts @@ -61,7 +61,6 @@ export async function POST(req: Request) { apiKey: string apiEndpoint: string teamPath: string - openaiApiKey: string } } @@ -72,16 +71,25 @@ export async function POST(req: Request) { }) } - if (!config?.openaiApiKey) { - return new Response(JSON.stringify({ error: 'OpenAI API key is required' }), { - status: 400, - headers: { 'Content-Type': 'application/json' }, - }) + const aiGatewayApiKey = process.env.AI_GATEWAY_API_KEY + if (!aiGatewayApiKey) { + return new Response( + JSON.stringify({ + error: 'Server misconfiguration: AI_GATEWAY_API_KEY is not set', + }), + { + status: 503, + headers: { 'Content-Type': 'application/json' }, + }, + ) } const tools = createTools(config.apiKey, config.apiEndpoint) - const openai = createOpenAI({ apiKey: config.openaiApiKey }) + const openai = createOpenAI({ + apiKey: aiGatewayApiKey, + ...(process.env.AI_GATEWAY_BASE_URL && { baseURL: process.env.AI_GATEWAY_BASE_URL }), + }) const result = streamText({ model: openai('gpt-4o'), diff --git a/examples/ai-conference-assistant/src/app/api/create-notes/route.ts b/examples/ai-conference-assistant/src/app/api/create-notes/route.ts index 480e7a2..3564506 100644 --- a/examples/ai-conference-assistant/src/app/api/create-notes/route.ts +++ b/examples/ai-conference-assistant/src/app/api/create-notes/route.ts @@ -5,7 +5,17 @@ * and progress streaming via Server-Sent Events. */ -import { HackMDClient } from '@/lib/hackmd-client' +import { NotePermissionRole } from '@hackmd/api' +import { createHackMDApi } from '@/lib/create-hackmd-api' + +function isRateLimitError(err: unknown): boolean { + if (typeof err === 'object' && err !== null) { + const e = err as { code?: number; response?: { status?: number } } + if (e.code === 429 || e.response?.status === 429) return true + } + const msg = err instanceof Error ? err.message : String(err) + return msg.includes('429') || msg.toLowerCase().includes('too many requests') +} export const maxDuration = 300 @@ -40,10 +50,7 @@ export async function POST(req: Request) { }) } - const client = new HackMDClient({ - accessToken: config.apiKey, - apiEndpoint: config.apiEndpoint, - }) + const client = createHackMDApi(config.apiKey, config.apiEndpoint) const webDomain = config.webDomain || 'https://hackmd.io' const delayMs = Math.max(0, Math.min(config.delayMs || 300, 5000)) @@ -81,8 +88,8 @@ export async function POST(req: Request) { const note = await client.createTeamNote(config.teamPath, { title: page.title, content: page.content, - readPermission: 'guest', - writePermission: 'signed_in', + readPermission: NotePermissionRole.GUEST, + writePermission: NotePermissionRole.SIGNED_IN, }) createdNotes[page.sessionId] = note.shortId @@ -119,7 +126,7 @@ export async function POST(req: Request) { }) // If it's a rate limit error, wait longer - if (message.includes('429')) { + if (isRateLimitError(err)) { await new Promise(resolve => setTimeout(resolve, 10000)) } else if (delayMs > 0) { await new Promise(resolve => setTimeout(resolve, delayMs)) @@ -150,8 +157,8 @@ export async function POST(req: Request) { const mainNote = await client.createTeamNote(config.teamPath, { title: homepage.title, content: homepageContent, - readPermission: 'guest', - writePermission: 'signed_in', + readPermission: NotePermissionRole.GUEST, + writePermission: NotePermissionRole.SIGNED_IN, }) completed++ diff --git a/examples/ai-conference-assistant/src/app/api/verify-hackmd/route.ts b/examples/ai-conference-assistant/src/app/api/verify-hackmd/route.ts new file mode 100644 index 0000000..279978e --- /dev/null +++ b/examples/ai-conference-assistant/src/app/api/verify-hackmd/route.ts @@ -0,0 +1,63 @@ +/** + * Verifies HackMD credentials server-side. The HackMD API does not allow + * browser origins (CORS), so /me must be called from the backend. + */ + +import { createHackMDApi } from '@/lib/create-hackmd-api' + +function httpStatusFromError(err: unknown): number | undefined { + if (typeof err !== 'object' || err === null) return undefined + const e = err as { response?: { status?: number }; code?: number } + if (e.response?.status != null) return e.response.status + if (typeof e.code === 'number') return e.code + return undefined +} + +export async function POST(req: Request) { + const body = await req.json() + const { apiKey, apiEndpoint, teamPath } = body as { + apiKey?: string + apiEndpoint?: string + teamPath?: string + } + + if (!apiKey?.trim()) { + return Response.json({ error: 'HackMD API key is required' }, { status: 400 }) + } + + const client = createHackMDApi(apiKey, apiEndpoint) + + let user: { teams?: Array<{ path: string }> } + try { + user = await client.getMe() + } catch (err) { + const status = httpStatusFromError(err) + if (status === undefined) { + return Response.json( + { error: 'Failed to reach HackMD API. Check the API endpoint URL.' }, + { status: 502 }, + ) + } + return Response.json( + { error: `HackMD API returned ${status}` }, + { status: status === 401 ? 401 : 502 }, + ) + } + + const teams = user.teams || [] + const trimmedTeam = teamPath?.trim() + + if (trimmedTeam && !teams.some((t) => t.path === trimmedTeam)) { + const teamNames = teams.map((t) => t.path).join(', ') + return Response.json( + { + error: `Team "${trimmedTeam}" not found. Available teams: ${teamNames || 'none'}`, + }, + { status: 400 }, + ) + } + + return Response.json({ + teamPath: trimmedTeam || teams[0]?.path || '', + }) +} diff --git a/examples/ai-conference-assistant/src/app/page.tsx b/examples/ai-conference-assistant/src/app/page.tsx index 6faa4f1..b59ad08 100644 --- a/examples/ai-conference-assistant/src/app/page.tsx +++ b/examples/ai-conference-assistant/src/app/page.tsx @@ -8,7 +8,6 @@ import { ProgressModal } from '@/components/progress-modal' export interface AppConfig { apiKey: string - openaiApiKey: string apiEndpoint: string teamPath: string webDomain: string diff --git a/examples/ai-conference-assistant/src/components/chat-panel.tsx b/examples/ai-conference-assistant/src/components/chat-panel.tsx index 802d7c6..3f244f4 100644 --- a/examples/ai-conference-assistant/src/components/chat-panel.tsx +++ b/examples/ai-conference-assistant/src/components/chat-panel.tsx @@ -5,6 +5,17 @@ import { TextStreamChatTransport } from 'ai' import { useState, useRef, useEffect, type MutableRefObject, type FormEvent } from 'react' import type { AppConfig, GeneratedData } from '@/app/page' +function safeSessionArrayLength(json: string): number { + const t = json.trim() + if (!t) return 0 + try { + const parsed = JSON.parse(t) as unknown + return Array.isArray(parsed) ? parsed.length : 0 + } catch { + return 0 + } +} + interface ChatPanelProps { config: AppConfig sessionData: MutableRefObject @@ -25,6 +36,8 @@ export function ChatPanel({ generatedData, }: ChatPanelProps) { const [fileUploaded, setFileUploaded] = useState(false) + /** Set when a file parses successfully; avoids JSON.parse on ref before parent syncs sessionDataRef. */ + const [uploadedSessionCount, setUploadedSessionCount] = useState (null) const [sessionDataSent, setSessionDataSent] = useState(false) const [inputValue, setInputValue] = useState('') const messagesEndRef = useRef (null) @@ -38,7 +51,6 @@ export function ChatPanel({ apiKey: config.apiKey, apiEndpoint: config.apiEndpoint, teamPath: config.teamPath, - openaiApiKey: config.openaiApiKey, }, }, }), @@ -59,8 +71,10 @@ export function ChatPanel({ reader.onload = (ev) => { const text = ev.target?.result as string try { - JSON.parse(text) // Validate JSON + const parsed = JSON.parse(text) as unknown + const count = Array.isArray(parsed) ? parsed.length : 0 onSessionDataChange(text) + setUploadedSessionCount(count) setFileUploaded(true) } catch { alert('Invalid JSON file. Please upload a valid JSON file.') @@ -77,10 +91,12 @@ export function ChatPanel({ let text = inputValue if ( fileUploaded && - sessionData.current && + sessionData.current.trim() && !sessionDataSent ) { - const count = JSON.parse(sessionData.current).length + const count = + uploadedSessionCount ?? + safeSessionArrayLength(sessionData.current) text = `${inputValue}\n\n[Session data uploaded - ${count} sessions]\n \n${sessionData.current}\n ` setSessionDataSent(true) } @@ -130,9 +146,9 @@ export function ChatPanel({ > 📁 Upload sessions.json - {fileUploaded && ( + {fileUploaded && uploadedSessionCount !== null && ( - ✅ {JSON.parse(sessionData.current).length} sessions loaded + ✅ {uploadedSessionCount} sessions loaded )} @@ -289,9 +305,9 @@ export function ChatPanel({ Send - {fileUploaded && ( + {fileUploaded && uploadedSessionCount !== null && (- ✅ {JSON.parse(sessionData.current).length} sessions loaded from file + ✅ {uploadedSessionCount} sessions loaded from file
)} diff --git a/examples/ai-conference-assistant/src/components/setup-panel.tsx b/examples/ai-conference-assistant/src/components/setup-panel.tsx index 3fe24f0..a36bd6c 100644 --- a/examples/ai-conference-assistant/src/components/setup-panel.tsx +++ b/examples/ai-conference-assistant/src/components/setup-panel.tsx @@ -9,7 +9,6 @@ interface SetupPanelProps { export function SetupPanel({ onConfigured }: SetupPanelProps) { const [apiKey, setApiKey] = useState('') - const [openaiApiKey, setOpenaiApiKey] = useState('') const [apiEndpoint, setApiEndpoint] = useState('https://api.hackmd.io/v1') const [teamPath, setTeamPath] = useState('') const [webDomain, setWebDomain] = useState('https://hackmd.io') @@ -22,29 +21,20 @@ export function SetupPanel({ onConfigured }: SetupPanelProps) { setVerifying(true) try { - // Verify HackMD credentials - const res = await fetch(`${apiEndpoint}/me`, { - headers: { Authorization: `Bearer ${apiKey}` }, + const res = await fetch('/api/verify-hackmd', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ apiKey, apiEndpoint, teamPath }), }) + const data = (await res.json()) as { error?: string; teamPath?: string } if (!res.ok) { - throw new Error(`HackMD API returned ${res.status}: ${res.statusText}`) - } - const user = await res.json() - const teams = user.teams || [] - const hasTeam = !teamPath || teams.some((t: { path: string }) => t.path === teamPath) - - if (teamPath && !hasTeam) { - const teamNames = teams.map((t: { path: string }) => t.path).join(', ') - throw new Error( - `Team "${teamPath}" not found. Available teams: ${teamNames || 'none'}`, - ) + throw new Error(data.error || 'Verification failed') } onConfigured({ apiKey, - openaiApiKey, apiEndpoint, - teamPath: teamPath || teams[0]?.path || '', + teamPath: data.teamPath ?? '', webDomain, }) } catch (err) { @@ -90,20 +80,6 @@ export function SetupPanel({ onConfigured }: SetupPanelProps) { -- - setOpenaiApiKey(e.target.value)} - placeholder="sk-..." - required - className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition text-gray-900" - /> --