diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9adff1a --- /dev/null +++ b/.env.example @@ -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 diff --git a/api/chat.js b/api/chat.js new file mode 100644 index 0000000..707c057 --- /dev/null +++ b/api/chat.js @@ -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.', + }) + } +} diff --git a/src/features/chat/ChatPanel.jsx b/src/features/chat/ChatPanel.jsx index d146095..15d4e16 100644 --- a/src/features/chat/ChatPanel.jsx +++ b/src/features/chat/ChatPanel.jsx @@ -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' @@ -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 @@ -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', @@ -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) => { @@ -62,7 +89,9 @@ function ChatPanel({ conversation, mode, onConversationChange, onCopy }) {

Negotiation chat

-

Mock Haggly agent for now. No API key needed.

+

+ {isApiMode ? 'API-backed Haggly agent.' : 'Mock Haggly agent for now. No API key needed.'} +

diff --git a/src/lib/ai/chatClient.js b/src/lib/ai/chatClient.js new file mode 100644 index 0000000..61ab594 --- /dev/null +++ b/src/lib/ai/chatClient.js @@ -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(), + } +} diff --git a/src/lib/ai/chatClient.test.js b/src/lib/ai/chatClient.test.js new file mode 100644 index 0000000..1e6fcfc --- /dev/null +++ b/src/lib/ai/chatClient.test.js @@ -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' }, + }), + ) + }) +}) diff --git a/src/lib/ai/chatHandler.js b/src/lib/ai/chatHandler.js new file mode 100644 index 0000000..0eba686 --- /dev/null +++ b/src/lib/ai/chatHandler.js @@ -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) + } +} diff --git a/src/lib/ai/chatHandler.test.js b/src/lib/ai/chatHandler.test.js new file mode 100644 index 0000000..1a9741a --- /dev/null +++ b/src/lib/ai/chatHandler.test.js @@ -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'], + }, + }) + }) +}) diff --git a/src/lib/ai/chatPrompt.js b/src/lib/ai/chatPrompt.js new file mode 100644 index 0000000..f820c83 --- /dev/null +++ b/src/lib/ai/chatPrompt.js @@ -0,0 +1,80 @@ +import { buildNegotiationContext } from '../../features/negotiation/negotiationContext' +import { assembleHagglyPrompt } from '../prompts/promptAssembler' + +function extractPriceAfterLabel(text, labels) { + for (const label of labels) { + const pattern = new RegExp(`${label}\\D{0,40}\\$?([0-9][0-9,.]*)`, 'i') + const match = text.match(pattern) + + if (match) { + return Number(match[1].replace(/,/g, '')) + } + } + + return null +} + +function extractPrices(text) { + const prices = [...text.matchAll(/\$([0-9][0-9,.]*)/g)].map((match) => + Number(match[1].replace(/,/g, '')), + ) + + return { + askingPrice: + extractPriceAfterLabel(text, ['asking', 'listed', 'price', 'selling', 'buying']) ?? + prices[0] ?? + null, + theirOffer: + extractPriceAfterLabel(text, ['offer', 'offered', 'they said', 'want to offer']) ?? + prices[1] ?? + null, + minimumPrice: + extractPriceAfterLabel(text, ['minimum', 'lowest', 'bottom', 'would take']) ?? null, + } +} + +function extractItem(text) { + const itemPatterns = [ + /(?:selling|buying|negotiating|looking at)\s+(?:a|an|the)?\s*([^,.]+?)(?:\s+for|\s+listed|\s+asking|,|\.|$)/i, + /(?:item is|it's|it is)\s+(?:a|an|the)?\s*([^,.]+?)(?:,|\.|$)/i, + ] + + for (const pattern of itemPatterns) { + const match = text.match(pattern) + + if (match?.[1]) { + return match[1].trim() + } + } + + return 'item' +} + +function normalizeConversation(messages) { + if (!Array.isArray(messages)) { + return [] + } + + return messages + .filter((message) => ['assistant', 'user'].includes(message.role) && message.content) + .slice(-8) + .map((message) => ({ + role: message.role, + content: String(message.content), + })) +} + +export function createChatPrompt({ mode = 'seller', message = '', messages = [] } = {}) { + const prices = extractPrices(message) + const context = buildNegotiationContext({ + mode, + item: extractItem(message), + ...prices, + }) + + return assembleHagglyPrompt({ + context, + latestMessage: message, + conversation: normalizeConversation(messages), + }) +} diff --git a/src/lib/ai/chatPrompt.test.js b/src/lib/ai/chatPrompt.test.js new file mode 100644 index 0000000..f30409c --- /dev/null +++ b/src/lib/ai/chatPrompt.test.js @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import { createChatPrompt } from './chatPrompt' + +describe('createChatPrompt', () => { + it('builds structured prompt parts from the latest seller message', () => { + const prompt = createChatPrompt({ + mode: 'seller', + message: + "I'm selling a gaming chair for $200. They offered $80. I would take $160 if they pick up tonight.", + messages: [ + { role: 'assistant', content: 'What is your minimum?' }, + { role: 'user', content: 'I would take $160.' }, + ], + }) + + expect(prompt.playbookIds).toEqual(['core-negotiation-agent', 'seller-marketplace']) + expect(prompt.system).toContain('Do not behave like a script generator') + expect(prompt.user).toContain('"mode": "seller"') + expect(prompt.user).toContain('"offerType": "lowball"') + expect(prompt.user).toContain('"minimumPrice": 160') + expect(prompt.user).toContain('pick up tonight') + }) +}) diff --git a/src/lib/ai/modelClient.js b/src/lib/ai/modelClient.js new file mode 100644 index 0000000..3d96065 --- /dev/null +++ b/src/lib/ai/modelClient.js @@ -0,0 +1,182 @@ +export class MissingModelConfigError extends Error { + constructor(message) { + super(message) + this.name = 'MissingModelConfigError' + this.code = 'missing_model_config' + } +} + +export class ModelRequestError extends Error { + constructor(message, { provider, status } = {}) { + super(message) + this.name = 'ModelRequestError' + this.code = 'model_request_failed' + this.provider = provider + this.status = status + } +} + +function requireEnv(env, key, helpText) { + const value = env[key]?.trim() + + if (!value) { + throw new MissingModelConfigError(`${key} is required. ${helpText}`) + } + + return value +} + +function getMaxTokens(env) { + const value = Number(env.AI_MAX_TOKENS ?? 900) + return Number.isFinite(value) && value > 0 ? Math.round(value) : 900 +} + +async function readJson(response) { + try { + return await response.json() + } catch (_error) { + return null + } +} + +function getProviderError(data) { + return data?.error?.message ?? data?.message ?? 'Unknown provider error' +} + +function extractOpenAIText(data) { + if (typeof data?.output_text === 'string' && data.output_text.trim()) { + return data.output_text.trim() + } + + const text = data?.output + ?.flatMap((item) => item.content ?? []) + .map((content) => content.text) + .filter(Boolean) + .join('\n') + .trim() + + return text || null +} + +function extractAnthropicText(data) { + const text = data?.content + ?.filter((content) => content.type === 'text') + .map((content) => content.text) + .join('\n') + .trim() + + return text || null +} + +function createOpenAIClient({ env, fetchImpl }) { + const apiKey = requireEnv( + env, + 'OPENAI_API_KEY', + 'Set OPENAI_API_KEY on the server or Vercel project.', + ) + const model = requireEnv(env, 'OPENAI_MODEL', 'Set OPENAI_MODEL to the model you want Haggly to use.') + + return { + provider: 'openai', + async createReply(prompt) { + const response = await fetchImpl('https://api.openai.com/v1/responses', { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model, + instructions: prompt.system, + input: prompt.user, + max_output_tokens: getMaxTokens(env), + }), + }) + const data = await readJson(response) + + if (!response.ok) { + throw new ModelRequestError(`OpenAI request failed: ${getProviderError(data)}`, { + provider: 'openai', + status: response.status, + }) + } + + const text = extractOpenAIText(data) + if (!text) { + throw new ModelRequestError('OpenAI response did not include text output.', { + provider: 'openai', + status: response.status, + }) + } + + return text + }, + } +} + +function createAnthropicClient({ env, fetchImpl }) { + const apiKey = requireEnv( + env, + 'ANTHROPIC_API_KEY', + 'Set ANTHROPIC_API_KEY on the server or Vercel project.', + ) + const model = requireEnv( + env, + 'ANTHROPIC_MODEL', + 'Set ANTHROPIC_MODEL to the model you want Haggly to use.', + ) + + return { + provider: 'anthropic', + async createReply(prompt) { + const response = await fetchImpl('https://api.anthropic.com/v1/messages', { + method: 'POST', + headers: { + 'x-api-key': apiKey, + 'anthropic-version': env.ANTHROPIC_VERSION ?? '2023-06-01', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model, + max_tokens: getMaxTokens(env), + system: prompt.system, + messages: [{ role: 'user', content: prompt.user }], + }), + }) + const data = await readJson(response) + + if (!response.ok) { + throw new ModelRequestError(`Anthropic request failed: ${getProviderError(data)}`, { + provider: 'anthropic', + status: response.status, + }) + } + + const text = extractAnthropicText(data) + if (!text) { + throw new ModelRequestError('Anthropic response did not include text output.', { + provider: 'anthropic', + status: response.status, + }) + } + + return text + }, + } +} + +export function createModelClient({ env = process.env, fetchImpl = fetch } = {}) { + const provider = env.AI_PROVIDER?.trim().toLowerCase() + + if (provider === 'openai') { + return createOpenAIClient({ env, fetchImpl }) + } + + if (provider === 'anthropic') { + return createAnthropicClient({ env, fetchImpl }) + } + + throw new MissingModelConfigError( + 'Set AI_PROVIDER to "openai" or "anthropic", then set the matching API key and model env vars.', + ) +} diff --git a/src/lib/ai/modelClient.test.js b/src/lib/ai/modelClient.test.js new file mode 100644 index 0000000..ee9058f --- /dev/null +++ b/src/lib/ai/modelClient.test.js @@ -0,0 +1,91 @@ +import { describe, expect, it, vi } from 'vitest' +import { createModelClient, MissingModelConfigError, ModelRequestError } from './modelClient' + +describe('createModelClient', () => { + it('throws a clear configuration error when no provider is configured', () => { + expect(() => createModelClient({ env: {}, fetchImpl: vi.fn() })).toThrow( + MissingModelConfigError, + ) + }) + + it('calls the OpenAI Responses API with server-side credentials', async () => { + const fetchImpl = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ output_text: 'OpenAI reply' }), + }) + + const client = createModelClient({ + env: { + AI_PROVIDER: 'openai', + OPENAI_API_KEY: 'openai-secret', + OPENAI_MODEL: 'test-openai-model', + }, + fetchImpl, + }) + + await expect(client.createReply({ system: 'system prompt', user: 'user prompt' })).resolves.toBe( + 'OpenAI reply', + ) + + const [url, options] = fetchImpl.mock.calls[0] + expect(url).toBe('https://api.openai.com/v1/responses') + expect(options.headers.Authorization).toBe('Bearer openai-secret') + expect(JSON.parse(options.body)).toMatchObject({ + model: 'test-openai-model', + instructions: 'system prompt', + input: 'user prompt', + }) + }) + + it('calls the Anthropic Messages API with server-side credentials', async () => { + const fetchImpl = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ content: [{ type: 'text', text: 'Anthropic reply' }] }), + }) + + const client = createModelClient({ + env: { + AI_PROVIDER: 'anthropic', + ANTHROPIC_API_KEY: 'anthropic-secret', + ANTHROPIC_MODEL: 'test-anthropic-model', + }, + fetchImpl, + }) + + await expect(client.createReply({ system: 'system prompt', user: 'user prompt' })).resolves.toBe( + 'Anthropic reply', + ) + + const [url, options] = fetchImpl.mock.calls[0] + expect(url).toBe('https://api.anthropic.com/v1/messages') + expect(options.headers['x-api-key']).toBe('anthropic-secret') + expect(options.headers['anthropic-version']).toBe('2023-06-01') + expect(JSON.parse(options.body)).toMatchObject({ + model: 'test-anthropic-model', + max_tokens: 900, + system: 'system prompt', + messages: [{ role: 'user', content: 'user prompt' }], + }) + }) + + it('wraps provider failures with a model request error', async () => { + const fetchImpl = vi.fn().mockResolvedValue({ + ok: false, + status: 401, + json: async () => ({ error: { message: 'bad key' } }), + }) + + const client = createModelClient({ + env: { + AI_PROVIDER: 'openai', + OPENAI_API_KEY: 'openai-secret', + OPENAI_MODEL: 'test-openai-model', + }, + fetchImpl, + }) + + await expect(client.createReply({ system: 'system', user: 'user' })).rejects.toThrow( + ModelRequestError, + ) + }) +}) diff --git a/vercel.json b/vercel.json index f07b71a..9e66d6b 100644 --- a/vercel.json +++ b/vercel.json @@ -2,6 +2,11 @@ "buildCommand": "npm run build", "outputDirectory": "dist", "framework": "vite", + "functions": { + "api/chat.js": { + "includeFiles": "prompts/playbooks/**" + } + }, "rewrites": [ { "source": "/(.*)", "destination": "/" } ]