Skip to content
Closed
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
3 changes: 2 additions & 1 deletion .prettierignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
package-lock.json
pnpm-lock.yaml
yarn.lock
yarn.lock
service.template.yaml
18 changes: 16 additions & 2 deletions adk/cofacts_ai/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,17 @@
- AI Proof-readers: Role-play different political perspectives to test reply effectiveness
"""

import os
from typing import Dict, Optional
import re
import json

from dotenv import load_dotenv
from google.adk.apps import App
from google.adk.agents import LlmAgent
from google.adk.agents.callback_context import CallbackContext
from google.adk.models.llm_response import LlmResponse
from google.adk.sessions import DatabaseSessionService
from google.adk.tools import url_context, google_search
from google.adk.tools.agent_tool import AgentTool
from datetime import datetime
Expand All @@ -26,7 +29,6 @@
submit_cofacts_reply,
resolve_vertex_redirect
)
from google.adk.apps.app import App
from .instrumentation import setup_instrumentation, LangfuseTracingPlugin

load_dotenv()
Expand Down Expand Up @@ -546,9 +548,21 @@ async def append_grounding_sources(
],
)

# Initialize Session Service
db_url = os.getenv("DATABASE_URL")
if not db_url:
# Local/CI: Initialize with SQLite at adk/.adk/sessions.db
db_path = os.path.abspath("adk/.adk/sessions.db")
os.makedirs(os.path.dirname(db_path), exist_ok=True)
db_url = f"sqlite+aiosqlite:///{db_path}"

session_service = DatabaseSessionService(db_url=db_url)

app = App(
name="cofacts_ai",
name="cofacts-ai",
root_agent=ai_writer,
session_service=session_service,
plugins=[LangfuseTracingPlugin()],
)

root_agent = ai_writer
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The root_agent is redefined here after the app object has been created. This is confusing and likely redundant if the application server is configured to use the app object, which encapsulates the agent and session service. To improve clarity and maintainability, please consider removing this line if it's not strictly necessary for a specific execution path. If it is required, a comment explaining its purpose would be very helpful.

4 changes: 4 additions & 0 deletions adk/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,8 @@ dependencies = [
"langfuse",
"opentelemetry-sdk",
"openinference-instrumentation-google-adk",
"aiosqlite>=0.22.1",
"sqlalchemy>=2.0.48",
"asyncpg>=0.31.0",
"aiomysql>=0.3.2",
]
2,035 changes: 1,050 additions & 985 deletions adk/uv.lock

Large diffs are not rendered by default.

7 changes: 6 additions & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,9 @@

import { tanstackConfig } from '@tanstack/eslint-config'

export default [...tanstackConfig]
export default [
...tanstackConfig,
{
ignores: ['adk/.venv/**', 'adk/.adk/**'],
},
]
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,4 +63,4 @@
"vitest": "^3.0.5",
"web-vitals": "^5.1.0"
}
}
}
3 changes: 3 additions & 0 deletions service.template.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ spec:
run.googleapis.com/container-dependencies: '{"ingress":["backend"]}'
autoscaling.knative.dev/minScale: "0"
autoscaling.knative.dev/maxScale: "3"
run.googleapis.com/cloudsql-instances: industrious-eye-145611:asia-east1:cofacts
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The Cloud SQL instance connection string appears to contain a hardcoded project ID (industrious-eye-145611). Hardcoding project-specific identifiers can make the configuration less portable and harder to manage across different environments (e.g., dev, staging, prod). It's recommended to use a variable that can be substituted during deployment, similar to other variables in this file.

        run.googleapis.com/cloudsql-instances: ${GCP_PROJECT_ID}:asia-east1:cofacts

spec:
containers:
- name: ingress
Expand Down Expand Up @@ -45,6 +46,8 @@ spec:
value: "${LANGFUSE_SECRET_KEY}"
- name: LANGFUSE_BASE_URL
value: "https://langfuse.cofacts.tw"
- name: DATABASE_URL
value: "${DATABASE_URL}"
startupProbe:
tcpSocket:
port: 8000
Expand Down
123 changes: 103 additions & 20 deletions src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { Link, useParams } from '@tanstack/react-router'
import { useCallback, useEffect, useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { getSessionTitle, useSessions } from '@/hooks/useSessions'
import {
SESSION_TITLE_KEY,
listSessions,
updateSession,
} from '@/lib/sessions.functions'

interface SidebarProps {
isOpen: boolean
Expand All @@ -12,6 +19,50 @@ export function Sidebar({ isOpen, onClose }: SidebarProps) {
.sessionId

const { data: sessions, isLoading } = useSessions()
const queryClient = useQueryClient()

const [editingSessionId, setEditingSessionId] = useState<string | null>(null)
const [editTitle, setEditTitle] = useState('')

const handleStartEdit = useCallback(
(e: React.MouseEvent, sessionId: string, currentTitle: string) => {
e.preventDefault()
e.stopPropagation()
setEditingSessionId(sessionId)
setEditTitle(currentTitle)
},
[],
)

const handleCancelEdit = useCallback(() => {
setEditingSessionId(null)
setEditTitle('')
}, [])

const handleSaveEdit = useCallback(
async (sessionId: string) => {
if (!editTitle.trim()) {
handleCancelEdit()
return
}

try {
await updateSession({
data: {
sessionId,
stateDelta: { [SESSION_TITLE_KEY]: editTitle.trim() },
},
})
// Invalidate sessions query to refresh the list
await queryClient.invalidateQueries({ queryKey: ['sessions'] })
} catch (err) {
console.error('Failed to update session title:', err)
} finally {
handleCancelEdit()
}
},
[editTitle, handleCancelEdit, queryClient],
)

return (
<>
Expand Down Expand Up @@ -70,28 +121,60 @@ export function Sidebar({ isOpen, onClose }: SidebarProps) {
{sessions?.map((session) => {
const isActive = currentSessionId === session.id
const title = getSessionTitle(session)
const isEditing = editingSessionId === session.id

return (
<Link
key={session.id}
to="/session/$sessionId"
params={{ sessionId: session.id }}
onClick={onClose}
className={`
flex flex-col p-3 rounded-lg group transition-colors
${isActive ? 'bg-primary/10 text-text-main' : 'hover:bg-gray-50 text-text-muted'}
`}
>
<div className="flex-1 min-w-0">
<div
className={`text-sm font-medium truncate text-left ${!isActive ? 'group-hover:text-text-main' : ''}`}
>
{title}
<div key={session.id} className="relative group">
<Link
to="/session/$sessionId"
params={{ sessionId: session.id }}
onClick={onClose}
className={`
flex flex-col p-3 rounded-lg transition-colors w-full
${isActive ? 'bg-primary/10 text-text-main' : 'hover:bg-gray-50 text-text-muted'}
`}
>
<div className="flex-1 min-w-0 pr-6">
{isEditing ? (
<input
autoFocus
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
onBlur={() => handleSaveEdit(session.id)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveEdit(session.id)
if (e.key === 'Escape') handleCancelEdit()
}}
className="w-full text-sm font-medium bg-white border border-primary rounded px-1 outline-none"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
}}
/>
) : (
<div
className={`text-sm font-medium truncate text-left ${!isActive ? 'group-hover:text-text-main' : ''}`}
>
{title}
</div>
)}
<div className="text-xs text-text-muted truncate mt-0.5 text-left font-mono">
{session.id.slice(0, 8)}…
</div>
</div>
<div className="text-xs text-text-muted truncate mt-0.5 text-left font-mono">
{session.id.slice(0, 8)}…
</div>
</div>
</Link>
</Link>

{!isEditing && (
<button
onClick={(e) => handleStartEdit(e, session.id, title)}
className="absolute right-2 top-3 p-1 opacity-0 group-hover:opacity-100 text-gray-400 hover:text-primary transition-opacity"
>
<span className="material-symbols-outlined text-sm">
edit
</span>
</button>
)}
</div>
)
})}
</div>
Expand Down
2 changes: 1 addition & 1 deletion src/components/ui/field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ function FieldError({
...new Map(errors.map((error) => [error?.message, error])).values(),
]

if (uniqueErrors?.length == 1) {
if (uniqueErrors.length === 1) {
return uniqueErrors[0]?.message
}

Expand Down
11 changes: 9 additions & 2 deletions src/hooks/useSessions.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query'
import type { AdkSession } from '@/lib/adk'
import { listSessions } from '@/lib/sessions.functions'
import { SESSION_TITLE_KEY, listSessions } from '@/lib/sessions.functions'

export function useSessions() {
return useQuery<Array<AdkSession>>({
Expand All @@ -11,8 +11,15 @@ export function useSessions() {
})
}

/** Derives a human-readable title from the first user message in a session's events */
/** Derives a human-readable title from the session state or first user message */
export function getSessionTitle(session: AdkSession): string {
// 1. Prefer explicit title from session state
const stateTitle = session.state?.[SESSION_TITLE_KEY]
if (typeof stateTitle === 'string' && stateTitle) {
return stateTitle
}

// 2. Fallback to deriving from first user message
const firstUserEvent = session.events?.find(
(e) => e.content?.role === 'user' && e.content.parts?.[0]?.text,
)
Expand Down
File renamed without changes.
45 changes: 40 additions & 5 deletions src/lib/sessions.functions.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { createServerFn } from '@tanstack/react-start'
import { getRequest } from '@tanstack/react-start/server'
import { ADK_APP_NAME, ADK_USER_ID, adkClient } from './adkClient'
import { handleAdkError, handleAdkResponseError } from './adk.server'
import { handleAdkError, handleAdkResponseError } from './adk-errors'
import type { AdkEvent } from './adk'
import type { components } from './adk-types'

type RunRequest = components['schemas']['RunAgentRequest']

export const SESSION_TITLE_KEY = 'title'

export const listSessions = createServerFn({ method: 'GET' }).handler(
async () => {
const { data, error } = await adkClient.GET(
Expand Down Expand Up @@ -41,9 +43,14 @@ export const getSession = createServerFn({ method: 'GET' })
return data
})

interface CreateSessionInput {
sessionId: string
initialState?: Record<string, any>
}

export const createSession = createServerFn({ method: 'POST' })
.inputValidator((sessionId: string) => sessionId)
.handler(async ({ data: sessionId }) => {
.inputValidator((input: CreateSessionInput) => input)
.handler(async ({ data: { sessionId, initialState } }) => {
const { response } = await adkClient.POST(
'/apps/{app_name}/users/{user_id}/sessions/{session_id}',
{
Expand All @@ -54,8 +61,9 @@ export const createSession = createServerFn({ method: 'POST' })
session_id: sessionId,
},
},
// OpenAPI spec expects body for this POST request
body: {},
// In the updated ADK schema, the body for /sessions/{session_id} POST
// is expected to be the initial state (Record<string, any>).
body: initialState ?? {},
},
)

Expand All @@ -67,6 +75,33 @@ export const createSession = createServerFn({ method: 'POST' })
return { ok: true }
})

interface UpdateSessionInput {
sessionId: string
stateDelta: Record<string, any>
}

export const updateSession = createServerFn({ method: 'PATCH' })
.inputValidator((input: UpdateSessionInput) => input)
.handler(async ({ data: { sessionId, stateDelta } }) => {
const { data, error } = await adkClient.PATCH(
'/apps/{app_name}/users/{user_id}/sessions/{session_id}',
{
params: {
path: {
app_name: ADK_APP_NAME,
user_id: ADK_USER_ID,
session_id: sessionId,
},
},
body: {
stateDelta,
},
},
)
if (error) handleAdkError(error)
return data
})

export type ChatInput = Omit<RunRequest, 'appName' | 'userId' | 'streaming'>

export const runChat = createServerFn({ method: 'POST' })
Expand Down
Loading
Loading