Skip to content
Draft
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
21 changes: 14 additions & 7 deletions src/components/ChatArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import { UserMessage } from './UserMessage'
import { AgentMessage } from './AgentMessage'
import { FeedbackButtons } from './FeedbackButtons'
import { ChatInput } from './ChatInput'
import { LoginPrompt } from './LoginPrompt'
import type { ChatMessage } from '@/lib/adk'
import { useAuth } from '@/lib/auth'

interface ChatAreaProps {
messages: Array<ChatMessage>
Expand All @@ -20,6 +22,7 @@ export function ChatArea({
onStop,
sessionId,
}: ChatAreaProps) {
const { user } = useAuth()
const scrollRef = useRef<HTMLDivElement>(null)

// Auto-scroll to bottom on new messages
Expand Down Expand Up @@ -83,13 +86,17 @@ export function ChatArea({
</div>

{/* Input area */}
<ChatInput
onSend={onSendMessage}
onStop={onStop}
isStreaming={isStreaming}
key={sessionId} // Reset input when session changes
sessionId={sessionId}
/>
{user ? (
<ChatInput
onSend={onSendMessage}
onStop={onStop}
isStreaming={isStreaming}
key={sessionId} // Reset input when session changes
sessionId={sessionId}
/>
) : (
<LoginPrompt message="登入後即可繼續與 Cofacts.ai 對話" />
)}
</>
)
}
5 changes: 5 additions & 0 deletions src/components/FeedbackButtons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useState } from 'react'
import { LangfuseWeb } from 'langfuse'
import { Popover, PopoverContent, PopoverTrigger } from './ui/popover'
import { FeedbackPopoverContent } from './FeedbackPopoverContent'
import { useAuth } from '@/lib/auth'

const langfuse = import.meta.env.VITE_LANGFUSE_PUBLIC_KEY
? new LangfuseWeb({
Expand All @@ -15,6 +16,7 @@ interface FeedbackButtonsProps {
}

export function FeedbackButtons({ traceId }: FeedbackButtonsProps) {
const { user } = useAuth()
const [feedbackGiven, setFeedbackGiven] = useState<1 | -1 | null>(null)
const [isPopoverOpen, setIsPopoverOpen] = useState(false)

Expand All @@ -35,6 +37,7 @@ export function FeedbackButtons({ traceId }: FeedbackButtonsProps) {
value: 0,
dataType: 'NUMERIC',
comment: '',
metadata: { userId: user?.id ?? null },
})
}
} else {
Expand All @@ -47,6 +50,7 @@ export function FeedbackButtons({ traceId }: FeedbackButtonsProps) {
name: 'user-thumbs',
value: next,
dataType: 'NUMERIC',
metadata: { userId: user?.id ?? null },
})
}
}
Expand All @@ -62,6 +66,7 @@ export function FeedbackButtons({ traceId }: FeedbackButtonsProps) {
value: feedbackGiven,
dataType: 'NUMERIC',
comment,
metadata: { userId: user?.id ?? null },
})
}
}
Expand Down
23 changes: 23 additions & 0 deletions src/components/LoginPrompt.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { useAuth } from '@/lib/auth'

interface LoginPromptProps {
message?: string
}

export function LoginPrompt({
message = '登入後即可開始與 Cofacts.ai 對話',
}: LoginPromptProps) {
const { login } = useAuth()
return (
<div className="border-t border-border-subtle bg-white p-4 flex flex-col items-center gap-3">
<p className="text-sm text-text-muted text-center">{message}</p>
<button
type="button"
onClick={() => login()}
className="px-4 py-1.5 rounded-full bg-primary text-white text-sm font-medium hover:bg-primary/90 cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
>
登入 / 註冊
</button>
</div>
)
}
2 changes: 0 additions & 2 deletions src/lib/adkClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,3 @@ const ADK_BASE_URL = process.env.ADK_URL || 'http://localhost:8000'

export const adkClient = createClient<paths>({ baseUrl: ADK_BASE_URL })
export const ADK_APP_NAME = 'cofacts_ai'
// TODO: 之後登入實作後,改從 request 解析 user ID
export const ADK_USER_ID = 'anonymous'
15 changes: 10 additions & 5 deletions src/lib/chatSessions.functions.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createServerFn } from '@tanstack/react-start'
import { ADK_APP_NAME, ADK_USER_ID, adkClient } from './adkClient'
import { ADK_APP_NAME, adkClient } from './adkClient'
import { handleAdkError, handleAdkResponseError } from './adk-errors'
import { resolveAdkUserIdOrThrow } from '@/server/adkUser'

const SESSION_TITLE_KEY = 'title'

Expand All @@ -12,11 +13,12 @@ export interface SessionListItem {

export const listSessions = createServerFn({ method: 'GET' }).handler(
async () => {
const userId = await resolveAdkUserIdOrThrow()
const { data, error } = await adkClient.GET(
'/apps/{app_name}/users/{user_id}/sessions',
{
params: {
path: { app_name: ADK_APP_NAME, user_id: ADK_USER_ID },
path: { app_name: ADK_APP_NAME, user_id: userId },
},
},
)
Expand All @@ -39,13 +41,14 @@ export const listSessions = createServerFn({ method: 'GET' }).handler(
export const getSession = createServerFn({ method: 'GET' })
.inputValidator((sessionId: string) => sessionId)
.handler(async ({ data: sessionId }) => {
const userId = await resolveAdkUserIdOrThrow()
const { data, error } = await adkClient.GET(
'/apps/{app_name}/users/{user_id}/sessions/{session_id}',
{
params: {
path: {
app_name: ADK_APP_NAME,
user_id: ADK_USER_ID,
user_id: userId,
session_id: sessionId,
},
},
Expand All @@ -63,13 +66,14 @@ interface CreateSessionInput {
export const createSession = createServerFn({ method: 'POST' })
.inputValidator((input: CreateSessionInput) => input)
.handler(async ({ data: { sessionId, name } }) => {
const userId = await resolveAdkUserIdOrThrow()
const { response } = await adkClient.POST(
'/apps/{app_name}/users/{user_id}/sessions/{session_id}',
{
params: {
path: {
app_name: ADK_APP_NAME,
user_id: ADK_USER_ID,
user_id: userId,
session_id: sessionId,
},
},
Expand All @@ -95,13 +99,14 @@ interface UpdateSessionInput {
export const updateSession = createServerFn({ method: 'POST' })
.inputValidator((input: UpdateSessionInput) => input)
.handler(async ({ data: { sessionId, name } }) => {
const userId = await resolveAdkUserIdOrThrow()
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,
user_id: userId,
session_id: sessionId,
},
},
Expand Down
25 changes: 18 additions & 7 deletions src/routes/_app/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { useQueryClient } from '@tanstack/react-query'
import { sendChatMessage } from '@/lib/chatCache'
import { createSession } from '@/lib/chatSessions.functions'
import { ChatInput } from '@/components/ChatInput'
import { LoginPrompt } from '@/components/LoginPrompt'
import { useAuth } from '@/lib/auth'

export const Route = createFileRoute('/_app/')({
component: LandingPage,
Expand All @@ -12,6 +14,7 @@ export const Route = createFileRoute('/_app/')({
function LandingPage() {
const navigate = useNavigate()
const queryClient = useQueryClient()
const { user } = useAuth()
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)

Expand Down Expand Up @@ -71,13 +74,21 @@ function LandingPage() {

{/* Input */}
<div className="max-w-2xl w-full mt-8">
<ChatInput
onSend={handleSend}
disabled={isLoading}
placeholder="貼上想查核的訊息,或輸入 Cofacts 文章連結 (https://cofacts.tw/article/...)..."
/>
{error && (
<div className="mt-2 text-sm text-red-500 text-center">{error}</div>
{user ? (
<>
<ChatInput
onSend={handleSend}
disabled={isLoading}
placeholder="貼上想查核的訊息,或輸入 Cofacts 文章連結 (https://cofacts.tw/article/...)..."
/>
{error && (
<div className="mt-2 text-sm text-red-500 text-center">
{error}
</div>
)}
</>
) : (
<LoginPrompt message="登入後即可開始與 Cofacts.ai 對話" />
)}
</div>
</div>
Expand Down
20 changes: 17 additions & 3 deletions src/routes/api/run-sse.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { createFileRoute } from '@tanstack/react-router'
import { ADK_APP_NAME, ADK_USER_ID, adkClient } from '@/lib/adkClient'
import { handleAdkResponseError } from '@/lib/adk-errors'
import type { components } from '@/lib/adk-types'
import { ADK_APP_NAME, adkClient } from '@/lib/adkClient'
import { handleAdkResponseError } from '@/lib/adk-errors'
import { UnauthorizedError, resolveAdkUserIdOrThrow } from '@/server/adkUser'

type RunRequest = components['schemas']['RunAgentRequest']
type ChatInput = Omit<RunRequest, 'appName' | 'userId' | 'streaming'>
Expand All @@ -18,14 +19,27 @@ export const Route = createFileRoute('/api/run-sse')({
server: {
handlers: {
POST: async ({ request }) => {
let userId: string
try {
userId = await resolveAdkUserIdOrThrow()
} catch (err) {
if (err instanceof UnauthorizedError) {
return new Response(JSON.stringify({ error: err.message }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
})
}
throw err
}

const input = (await request.json()) as ChatInput

const { response } = await adkClient.POST('/run_sse', {
parseAs: 'stream',
body: {
...input,
appName: ADK_APP_NAME,
userId: ADK_USER_ID,
userId,
streaming: true,
},
// When the client aborts the fetch, request.signal fires (via srvx),
Expand Down
23 changes: 23 additions & 0 deletions src/server/adkUser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Server-side helper that resolves the current user's Cofacts ID for use as
// the ADK `user_id` path/body parameter. Reads the HttpOnly cofacts_session
// cookie (via cofactsExec) and dispatches GetCurrentUser. Throws a 401-shaped
// Response when no authenticated user is present so callers can let the
// framework propagate it back to the browser.
Comment on lines +3 to +5
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 comment states that this helper throws a "401-shaped Response", but the implementation on lines 19-21 throws a custom UnauthorizedError object.

In TanStack Start, throwing a standard Error in a createServerFn (like those in chatSessions.functions.ts) typically results in a 500 Internal Server Error on the client side. To allow the framework to propagate a specific HTTP status code (like 401) automatically to the browser, you should throw a Response object instead.

If you prefer using the custom error class for manual handling (as seen in run-sse.ts), please update the comment to reflect that an Error is thrown rather than a Response.


import { getCurrentUserServerFn } from './me.functions'

export class UnauthorizedError extends Error {
readonly status = 401
constructor(message = 'Authentication required') {
super(message)
this.name = 'UnauthorizedError'
}
}

export async function resolveAdkUserIdOrThrow(): Promise<string> {
const user = await getCurrentUserServerFn()
if (!user) {
throw new UnauthorizedError()
}
return user.id
}
Loading