Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ prod-build.env
infra/dev/typesense-secrets.yml
infra/prod/typesense-secrets.yml

/.venv
.python-version
__pycache__/
*.pyc
Expand Down Expand Up @@ -102,3 +103,7 @@ CLAUDE.md
#gcloud
.gcloudignore

# Python virtual environments
.venv/
venv/
env/
91 changes: 91 additions & 0 deletions components/llm/ChatWidget.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
.widget {
display: flex;
flex-direction: column;
max-width: 480px;
height: 480px;
border: 1px solid #d9d9d9;
border-radius: 8px;
overflow: hidden;
font-size: 0.9rem;
}

.header {
padding: 0.75rem 1rem;
border-bottom: 1px solid #d9d9d9;
}

.header h3 {
margin: 0;
font-size: 1rem;
}

.hint {
margin: 0.25rem 0 0;
font-size: 0.75rem;
color: #666;
}

.messages {
flex: 1;
overflow-y: auto;
padding: 0.75rem 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}

.welcome {
color: #666;
}

.message {
padding: 0.5rem 0.75rem;
border-radius: 8px;
max-width: 85%;
white-space: pre-wrap;
}

.user {
align-self: flex-end;
background: #0a5c36;
color: #fff;
}

.assistant {
align-self: flex-start;
background: #f1f1f1;
color: #111;
}

.error {
color: #b00020;
font-size: 0.85rem;
}

.inputRow {
display: flex;
gap: 0.5rem;
padding: 0.75rem;
border-top: 1px solid #d9d9d9;
}

.input {
flex: 1;
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 4px;
}

.submit {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
background: #0a5c36;
color: #fff;
cursor: pointer;
}

.submit:disabled {
opacity: 0.5;
cursor: default;
}
104 changes: 104 additions & 0 deletions components/llm/ChatWidget.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { useState, useRef, useEffect } from "react"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A big open question is if we should be authoring our own chat widget or adopting a framework like copilotkit instead.

import { httpsCallable } from "firebase/functions"
import { functions } from "components/firebase"
import { useAuth } from "components/auth"
import styles from "./ChatWidget.module.css"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think we need to add a mock frontend pag that just exposes this widget on a placeholder page for initial testing purposes.


type AskQuestionRequest = { question: string }
type AskQuestionResponse = {
answer: string
usage: { tokensUsed: number; isLoggedIn: boolean }
}

const askQuestion = httpsCallable<AskQuestionRequest, AskQuestionResponse>(
functions,
"askQuestion"
)

type ChatMessage = {
id: string
role: "user" | "assistant"
content: string
}

/**
* Lightweight bill/policy Q&A chat widget backed by the LangGraph ReAct
* agent (functions/src/llm). Dependency-free beyond firebase/functions, so
* it can be dropped into any page.
*/
export function ChatWidget({ title = "Ask about bills & policy" }: { title?: string }) {
const { user } = useAuth()
const [messages, setMessages] = useState<ChatMessage[]>([])
const [input, setInput] = useState("")
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const endRef = useRef<HTMLDivElement>(null)

useEffect(() => {
endRef.current?.scrollIntoView({ behavior: "smooth" })
}, [messages, loading])

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const question = input.trim()
if (!question || loading) return

setMessages(prev => [...prev, { id: crypto.randomUUID(), role: "user", content: question }])
setInput("")
setLoading(true)
setError(null)

try {
const result = await askQuestion({ question })
setMessages(prev => [
...prev,
{ id: crypto.randomUUID(), role: "assistant", content: result.data.answer }
])
} catch (err: any) {
setError(err?.message ?? "Something went wrong. Please try again.")
} finally {
setLoading(false)
}
}

return (
<div className={styles.widget}>
<div className={styles.header}>
<h3>{title}</h3>
{!user && <p className={styles.hint}>Sign in for a higher daily usage limit.</p>}
</div>

<div className={styles.messages}>
{messages.length === 0 && (
<p className={styles.welcome}>
Ask a question about a bill, testimony, or ballot question.
</p>
)}
{messages.map(message => (
<div key={message.id} className={`${styles.message} ${styles[message.role]}`}>
{message.content}
</div>
))}
{loading && <div className={`${styles.message} ${styles.assistant}`}>Thinking…</div>}
{error && <div className={styles.error}>{error}</div>}
<div ref={endRef} />
</div>

<form className={styles.inputRow} onSubmit={handleSubmit}>
<input
className={styles.input}
type="text"
value={input}
onChange={e => setInput(e.target.value)}
placeholder="e.g. What bills address education funding?"
disabled={loading}
/>
<button className={styles.submit} type="submit" disabled={loading || !input.trim()}>
Send
</button>
</form>
</div>
)
}

export default ChatWidget
Loading
Loading