Skip to content
Merged
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
18 changes: 18 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Browser behavior
# mock = keep using deterministic local replies
# api = call /api/chat, which uses server-side provider credentials
VITE_HAGGLY_CHAT_MODE=mock

# Server model provider for /api/chat
# Choose one: openai or anthropic
AI_PROVIDER=
AI_MAX_TOKENS=900

# OpenAI Responses API
OPENAI_API_KEY=
OPENAI_MODEL=

# Anthropic Messages API
ANTHROPIC_API_KEY=
ANTHROPIC_MODEL=
ANTHROPIC_VERSION=2023-06-01
43 changes: 43 additions & 0 deletions api/chat.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { handleChatRequest } from '../src/lib/ai/chatHandler.js'

async function readJsonBody(request) {
if (request.body && typeof request.body === 'object' && !Buffer.isBuffer(request.body)) {
return request.body
}

if (typeof request.body === 'string') {
return JSON.parse(request.body)
}

if (Buffer.isBuffer(request.body)) {
return JSON.parse(request.body.toString('utf8'))
}

const chunks = []
for await (const chunk of request) {
chunks.push(chunk)
}

if (!chunks.length) {
return {}
}

return JSON.parse(Buffer.concat(chunks).toString('utf8'))
}

export default async function handler(request, response) {
try {
const body = request.method === 'POST' ? await readJsonBody(request) : undefined
const result = await handleChatRequest({
method: request.method,
body,
})

response.status(result.status).json(result.body)
} catch (_error) {
response.status(400).json({
code: 'invalid_json',
error: 'Request body must be valid JSON.',
})
}
}
41 changes: 35 additions & 6 deletions src/features/chat/ChatPanel.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useState } from 'react'
import Surface from '../../components/ui/Surface'
import { isApiChatEnabled, requestAssistantReply } from '../../lib/ai/chatClient'
import { createInitialMessages, createMockAssistantReply } from './mockAssistant'
import ChatComposer from './ChatComposer'
import ChatMessage from './ChatMessage'
Expand All @@ -11,6 +12,7 @@ function ChatPanel({ conversation, mode, onConversationChange, onCopy }) {
const [conversationStatus, setConversationStatus] = useState(() => conversation?.status ?? 'active')
const [conversationCreatedAt, setConversationCreatedAt] = useState(() => conversation?.createdAt ?? Date.now())
const [isReplying, setIsReplying] = useState(false)
const isApiMode = isApiChatEnabled()

const publishMessages = (nextMessages, status = conversationStatus) => {
const nextId = conversation?.id ?? conversationId
Expand All @@ -32,7 +34,7 @@ function ChatPanel({ conversation, mode, onConversationChange, onCopy }) {
})
}

const handleSend = (content) => {
const handleSend = async (content) => {
const userMessage = {
id: `user-${Date.now()}`,
role: 'user',
Expand All @@ -44,13 +46,38 @@ function ChatPanel({ conversation, mode, onConversationChange, onCopy }) {
publishMessages(messagesWithUser)
setIsReplying(true)

setTimeout(() => {
if (!isApiMode) {
setTimeout(() => {
publishMessages([
...messagesWithUser,
createMockAssistantReply({ mode: activeMode, message: content }),
])
setIsReplying(false)
}, 450)
return
}

try {
const assistantReply = await requestAssistantReply({
mode: activeMode,
message: content,
messages: messagesWithUser,
})

publishMessages([...messagesWithUser, assistantReply])
} catch (error) {
publishMessages([
...messagesWithUser,
createMockAssistantReply({ mode: activeMode, message: content }),
{
id: `assistant-error-${Date.now()}`,
role: 'assistant',
content: `I could not reach the Haggly model endpoint. ${error.message}`,
createdAt: Date.now(),
},
])
} finally {
setIsReplying(false)
}, 450)
}
}

const handleStatusChange = (event) => {
Expand All @@ -62,7 +89,9 @@ function ChatPanel({ conversation, mode, onConversationChange, onCopy }) {
<div className="flex items-start justify-between gap-4 border-b border-stone-200 px-4 py-3">
<div>
<p className="text-sm font-semibold text-stone-950">Negotiation chat</p>
<p className="mt-1 text-xs text-stone-500">Mock Haggly agent for now. No API key needed.</p>
<p className="mt-1 text-xs text-stone-500">
{isApiMode ? 'API-backed Haggly agent.' : 'Mock Haggly agent for now. No API key needed.'}
</p>
</div>
<label className="text-xs font-semibold text-stone-500">
Status
Expand Down Expand Up @@ -90,7 +119,7 @@ function ChatPanel({ conversation, mode, onConversationChange, onCopy }) {

{isReplying && (
<div className="inline-flex rounded-full border border-primary-100 bg-white px-3 py-2 text-sm font-medium text-primary-800 shadow-sm shadow-stone-900/5">
Haggly is drafting...
Haggly is thinking...
</div>
)}
</div>
Expand Down
33 changes: 33 additions & 0 deletions src/lib/ai/chatClient.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
export function isApiChatEnabled(env = import.meta.env) {
return env?.VITE_HAGGLY_CHAT_MODE === 'api'
}

async function readResponseJson(response) {
try {
return await response.json()
} catch (_error) {
return {}
}
}

export async function requestAssistantReply({ mode, message, messages, fetchImpl = fetch }) {
const response = await fetchImpl('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode, message, messages }),
})
const data = await readResponseJson(response)

if (!response.ok) {
throw new Error(data.error ?? 'Haggly API request failed.')
}

return {
id: `assistant-${Date.now()}`,
role: 'assistant',
content: data.message,
provider: data.provider,
playbookIds: data.playbookIds,
createdAt: Date.now(),
}
}
37 changes: 37 additions & 0 deletions src/lib/ai/chatClient.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { describe, expect, it, vi } from 'vitest'
import { isApiChatEnabled, requestAssistantReply } from './chatClient'

describe('chatClient', () => {
it('uses mock mode unless API mode is explicitly enabled', () => {
expect(isApiChatEnabled({})).toBe(false)
expect(isApiChatEnabled({ VITE_HAGGLY_CHAT_MODE: 'api' })).toBe(true)
})

it('calls the local chat API and returns an assistant message', async () => {
const fetchImpl = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ message: 'API reply', provider: 'openai' }),
})

await expect(
requestAssistantReply({
mode: 'seller',
message: 'They offered $80.',
messages: [{ role: 'user', content: 'They offered $80.' }],
fetchImpl,
}),
).resolves.toMatchObject({
role: 'assistant',
content: 'API reply',
provider: 'openai',
})

expect(fetchImpl).toHaveBeenCalledWith(
'/api/chat',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}),
)
})
})
83 changes: 83 additions & 0 deletions src/lib/ai/chatHandler.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { createChatPrompt } from './chatPrompt'
import { createModelClient, MissingModelConfigError, ModelRequestError } from './modelClient'

function getLatestMessage(body) {
return typeof body?.message === 'string' ? body.message.trim() : ''
}

function getErrorResponse(error) {
if (error instanceof MissingModelConfigError) {
return {
status: 500,
body: {
code: error.code,
error: error.message,
},
}
}

if (error instanceof ModelRequestError) {
return {
status: 502,
body: {
code: error.code,
error: error.message,
provider: error.provider,
},
}
}

return {
status: 500,
body: {
code: 'chat_api_error',
error: 'Haggly could not create a response.',
},
}
}

export async function handleChatRequest({
method,
body,
env = process.env,
fetchImpl = fetch,
} = {}) {
if (method !== 'POST') {
return {
status: 405,
body: { error: 'Method not allowed' },
}
}

const message = getLatestMessage(body)
if (!message) {
return {
status: 400,
body: {
code: 'missing_message',
error: 'Message is required.',
},
}
}

try {
const prompt = createChatPrompt({
mode: body?.mode,
message,
messages: body?.messages,
})
const client = createModelClient({ env, fetchImpl })
const assistantMessage = await client.createReply(prompt)

return {
status: 200,
body: {
message: assistantMessage,
provider: client.provider,
playbookIds: prompt.playbookIds,
},
}
} catch (error) {
return getErrorResponse(error)
}
}
58 changes: 58 additions & 0 deletions src/lib/ai/chatHandler.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, expect, it, vi } from 'vitest'
import { handleChatRequest } from './chatHandler'

describe('handleChatRequest', () => {
it('rejects non-POST requests', async () => {
await expect(handleChatRequest({ method: 'GET' })).resolves.toMatchObject({
status: 405,
body: { error: 'Method not allowed' },
})
})

it('returns a clear developer error when model config is missing', async () => {
await expect(
handleChatRequest({
method: 'POST',
body: { mode: 'seller', message: 'They offered $80.' },
env: {},
fetchImpl: vi.fn(),
}),
).resolves.toMatchObject({
status: 500,
body: {
code: 'missing_model_config',
},
})
})

it('returns an assistant message from the configured model provider', async () => {
const fetchImpl = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ output_text: 'Current read: hold your price.' }),
})

await expect(
handleChatRequest({
method: 'POST',
body: {
mode: 'seller',
message: "I'm selling a chair for $200 and they offered $100.",
messages: [{ role: 'user', content: "I'm selling a chair." }],
},
env: {
AI_PROVIDER: 'openai',
OPENAI_API_KEY: 'openai-secret',
OPENAI_MODEL: 'test-openai-model',
},
fetchImpl,
}),
).resolves.toMatchObject({
status: 200,
body: {
message: 'Current read: hold your price.',
provider: 'openai',
playbookIds: ['core-negotiation-agent', 'seller-marketplace'],
},
})
})
})
Loading