diff options
Diffstat (limited to 'makima')
| -rw-r--r-- | makima/docs/PLAN-discuss-contract.md | 953 | ||||
| -rw-r--r-- | makima/frontend/src/components/listen/ContractPickerModal.tsx | 15 | ||||
| -rw-r--r-- | makima/frontend/src/components/listen/ControlPanel.tsx | 3 | ||||
| -rw-r--r-- | makima/frontend/src/components/listen/DiscussContractModal.tsx | 287 | ||||
| -rw-r--r-- | makima/frontend/src/lib/api.ts | 54 | ||||
| -rw-r--r-- | makima/frontend/src/routes/listen.tsx | 38 | ||||
| -rw-r--r-- | makima/src/llm/discuss_tools.rs | 210 | ||||
| -rw-r--r-- | makima/src/llm/mod.rs | 4 | ||||
| -rw-r--r-- | makima/src/server/handlers/contract_discuss.rs | 592 | ||||
| -rw-r--r-- | makima/src/server/handlers/mod.rs | 1 | ||||
| -rw-r--r-- | makima/src/server/mod.rs | 3 | ||||
| -rw-r--r-- | makima/src/server/openapi.rs | 10 |
12 files changed, 2167 insertions, 3 deletions
diff --git a/makima/docs/PLAN-discuss-contract.md b/makima/docs/PLAN-discuss-contract.md new file mode 100644 index 0000000..7543bdb --- /dev/null +++ b/makima/docs/PLAN-discuss-contract.md @@ -0,0 +1,953 @@ +# Discuss Contract Feature - Implementation Plan + +## Overview + +Add a "Discuss Contract" feature to the listen page that enables users to have a natural conversation with Makima (in character) to flesh out and ultimately create a contract specification. This feature bridges the gap between raw transcript/requirements and a well-defined contract through interactive dialogue. + +**Goal:** Enable users to talk through their project idea with Makima, refine requirements conversationally, and then have the LLM create a properly-structured contract when the conversation reaches a natural conclusion. + +## Key Design Principles + +1. **Ephemeral conversation** - Does not require an existing contract (creates one at the end) +2. **Character-aware** - Makima responds in character, aware of the makima.jp platform +3. **Contextual** - Can incorporate transcript context from the current session +4. **Audible** - Each response can be spoken using the TTS functionality +5. **Natural conclusion** - The LLM decides when to create the contract via tool use +6. **Reuse existing infrastructure** - Leverage chatWithContract API patterns and useSpeakWebSocket + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Listen Page │ +├─────────────────────────────────────────────────────────────────┤ +│ ControlPanel │ +│ ├── Contract Dropdown │ +│ │ ├── Ephemeral │ +│ │ ├── [existing contracts...] │ +│ │ └── ** "Discuss Contract" ** <-- NEW OPTION │ +│ └── ... │ +├─────────────────────────────────────────────────────────────────┤ +│ DiscussContractModal (NEW) │ +│ ├── Conversation history (chat bubbles) │ +│ ├── Text input │ +│ ├── Speak button per response │ +│ └── Contract created notification │ +└─────────────────────────────────────────────────────────────────┘ + │ + │ POST /api/v1/contracts/discuss + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Backend: discuss_contract_handler │ +├─────────────────────────────────────────────────────────────────┤ +│ 1. Receive message + history + optional transcript context │ +│ 2. Build Makima-character system prompt │ +│ 3. Run agentic loop with discussion tools │ +│ 4. When ready, call create_contract tool │ +│ 5. Return response + tool calls + created contract info │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## Implementation Tasks + +--- + +### Task 1: Backend - New Discussion Endpoint + +**File:** `makima/src/server/handlers/contract_discuss.rs` (NEW) + +Create a new handler for ephemeral contract discussions that doesn't require an existing contract. + +#### 1.1 Request/Response Types + +```rust +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct DiscussContractRequest { + /// The user's message + pub message: String, + /// Optional model selection (default: claude-sonnet) + #[serde(default)] + pub model: Option<String>, + /// Conversation history for context continuity + #[serde(default)] + pub history: Option<Vec<ChatMessage>>, + /// Optional transcript context from current session + #[serde(default)] + pub transcript_context: Option<String>, +} + +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct DiscussContractResponse { + /// Makima's response message + pub response: String, + /// Tool calls that were executed (e.g., create_contract) + pub tool_calls: Vec<ToolCallInfo>, + /// If a contract was created, its details + #[serde(skip_serializing_if = "Option::is_none")] + pub created_contract: Option<CreatedContractInfo>, + /// Pending questions (if LLM needs clarification) + #[serde(skip_serializing_if = "Option::is_none")] + pub pending_questions: Option<Vec<UserQuestion>>, +} + +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct CreatedContractInfo { + pub id: String, + pub name: String, + pub description: Option<String>, + pub contract_type: String, + pub initial_phase: String, +} +``` + +#### 1.2 System Prompt - Makima Character + +```rust +const DISCUSS_SYSTEM_PROMPT: &str = r#" +You are Makima, an AI assistant on the makima.jp platform. You help users define and create contracts for their projects through natural conversation. + +## Your Personality +- Professional yet personable +- Focused on understanding the user's actual needs +- Ask clarifying questions when requirements are vague +- Guide the conversation toward actionable outcomes +- Comfortable making recommendations based on experience + +## Your Goal +Help the user flesh out their project idea into a well-defined contract. A contract on makima.jp includes: +- A clear name and description +- The right contract type (simple, specification, or execute) +- Understanding of the scope and requirements + +## Contract Types +- **simple**: Quick tasks with minimal planning (plan -> execute phases only) +- **specification**: Full lifecycle projects (research -> specify -> plan -> execute -> review) +- **execute**: Direct implementation when requirements are already clear (execute phase only) + +## Guidelines +1. **Start by understanding**: Ask about what they want to build +2. **Clarify scope**: Is this a quick fix, a new feature, or a full project? +3. **Gather requirements**: What are the must-haves vs nice-to-haves? +4. **Identify context**: Is there existing code? Which repository? +5. **Recommend type**: Suggest the appropriate contract type +6. **Confirm and create**: When the user is satisfied, create the contract + +## When to Create the Contract +Create the contract when: +- You have a clear understanding of what the user wants +- The user has confirmed they're ready to proceed +- You've gathered enough information for a meaningful contract + +Do NOT create the contract if: +- The user is still exploring ideas +- Key information is missing +- The user hasn't indicated readiness + +{transcript_context} +"#; +``` + +#### 1.3 Tool Definitions + +**File:** `makima/src/llm/discuss_tools.rs` (NEW) + +```rust +pub static DISCUSS_TOOLS: once_cell::sync::Lazy<Vec<Tool>> = once_cell::sync::Lazy::new(|| { + vec![ + Tool { + name: "create_contract".to_string(), + description: "Create a new contract based on the discussion. Only call this when the user has confirmed they're ready to create the contract.".to_string(), + parameters: json!({ + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name for the contract" + }, + "description": { + "type": "string", + "description": "Detailed description of what the contract is for" + }, + "contract_type": { + "type": "string", + "enum": ["simple", "specification", "execute"], + "description": "Type of contract workflow" + }, + "repository_url": { + "type": "string", + "description": "Optional repository URL if discussed" + }, + "local_only": { + "type": "boolean", + "description": "If true, tasks won't auto-push or create PRs" + } + }, + "required": ["name", "description", "contract_type"] + }), + }, + Tool { + name: "ask_clarification".to_string(), + description: "Ask the user a clarifying question with multiple choice options.".to_string(), + parameters: json!({ + "type": "object", + "properties": { + "question": { + "type": "string", + "description": "The question to ask" + }, + "options": { + "type": "array", + "items": { "type": "string" }, + "description": "Multiple choice options" + }, + "allow_custom": { + "type": "boolean", + "description": "Allow user to provide a custom answer" + } + }, + "required": ["question", "options"] + }), + }, + ] +}); +``` + +#### 1.4 Handler Implementation + +```rust +pub async fn discuss_contract_handler( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Json(request): Json<DiscussContractRequest>, +) -> impl IntoResponse { + // 1. Build system prompt with optional transcript context + let transcript_section = match &request.transcript_context { + Some(ctx) => format!( + "\n## Current Session Context\nThe user has been recording a session. Here's the transcript:\n\n{}\n", + ctx + ), + None => String::new(), + }; + + let system_prompt = DISCUSS_SYSTEM_PROMPT.replace("{transcript_context}", &transcript_section); + + // 2. Initialize LLM client + let llm_client = match model { + LlmModel::ClaudeSonnet => ClaudeClient::from_env(ClaudeModel::Sonnet)?, + // ... + }; + + // 3. Run agentic loop with DISCUSS_TOOLS + run_discuss_agentic_loop( + pool, + &state, + &llm_client, + system_prompt, + &request, + auth.owner_id, + ).await +} +``` + +#### 1.5 Register the Endpoint + +**File:** `makima/src/server/mod.rs` + +Add route: +```rust +.route("/api/v1/contracts/discuss", post(handlers::contract_discuss::discuss_contract_handler)) +``` + +--- + +### Task 2: Frontend - DiscussContractModal Component + +**File:** `makima/frontend/src/components/listen/DiscussContractModal.tsx` (NEW) + +Create a chat modal component for the discussion interface. + +#### 2.1 Types + +```typescript +interface Message { + id: string; + role: "user" | "assistant"; + content: string; + timestamp: Date; + toolCalls?: ToolCallInfo[]; +} + +interface DiscussContractModalProps { + isOpen: boolean; + onClose: () => void; + transcriptContext?: string; // Current session transcript + onContractCreated: (contract: CreatedContractInfo) => void; +} + +interface DiscussContractState { + messages: Message[]; + isLoading: boolean; + error: string | null; + createdContract: CreatedContractInfo | null; +} +``` + +#### 2.2 Component Structure + +```tsx +export function DiscussContractModal({ + isOpen, + onClose, + transcriptContext, + onContractCreated, +}: DiscussContractModalProps) { + const [messages, setMessages] = useState<Message[]>([]); + const [input, setInput] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [createdContract, setCreatedContract] = useState<CreatedContractInfo | null>(null); + + const { speak, isSpeaking, cancel } = useSpeakWebSocket(); + const messagesEndRef = useRef<HTMLDivElement>(null); + + // Auto-scroll to bottom on new messages + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages]); + + // Initial greeting when modal opens + useEffect(() => { + if (isOpen && messages.length === 0) { + // Add initial assistant message + const greeting = transcriptContext + ? "I've reviewed your session transcript. What would you like to build based on this discussion?" + : "Hello! I'm Makima. Tell me about what you'd like to build, and I'll help you create a contract for it."; + + setMessages([{ + id: crypto.randomUUID(), + role: "assistant", + content: greeting, + timestamp: new Date(), + }]); + } + }, [isOpen, transcriptContext]); + + const handleSend = async () => { + if (!input.trim() || isLoading) return; + + const userMessage: Message = { + id: crypto.randomUUID(), + role: "user", + content: input, + timestamp: new Date(), + }; + + setMessages(prev => [...prev, userMessage]); + setInput(""); + setIsLoading(true); + + try { + const history = messages.map(m => ({ + role: m.role, + content: m.content, + })); + + const response = await discussContract( + input, + undefined, // model + history, + transcriptContext + ); + + const assistantMessage: Message = { + id: crypto.randomUUID(), + role: "assistant", + content: response.response, + timestamp: new Date(), + toolCalls: response.toolCalls, + }; + + setMessages(prev => [...prev, assistantMessage]); + + if (response.createdContract) { + setCreatedContract(response.createdContract); + onContractCreated(response.createdContract); + } + } catch (err) { + // Handle error + } finally { + setIsLoading(false); + } + }; + + const handleSpeak = (text: string) => { + if (isSpeaking) { + cancel(); + } else { + speak(text); + } + }; + + return ( + <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"> + <div className="panel w-full max-w-2xl h-[600px] flex flex-col"> + {/* Header */} + <div className="flex items-center justify-between p-4 border-b border-[rgba(117,170,252,0.25)]"> + <h2 className="font-mono text-sm text-[#dbe7ff] uppercase tracking-wide"> + Discuss Contract with Makima + </h2> + <button onClick={onClose} className="..."> + [X] + </button> + </div> + + {/* Messages */} + <div className="flex-1 overflow-y-auto p-4 space-y-4"> + {messages.map((message) => ( + <ChatBubble + key={message.id} + message={message} + onSpeak={() => handleSpeak(message.content)} + isSpeaking={isSpeaking} + /> + ))} + <div ref={messagesEndRef} /> + + {isLoading && ( + <div className="flex items-center gap-2 text-[#9bc3ff]"> + <span className="animate-pulse">Makima is thinking...</span> + </div> + )} + </div> + + {/* Contract Created Banner */} + {createdContract && ( + <div className="p-3 bg-green-400/10 border-t border-green-400/50"> + <div className="font-mono text-xs text-green-400"> + Contract "{createdContract.name}" created successfully! + </div> + </div> + )} + + {/* Input */} + <div className="p-4 border-t border-[rgba(117,170,252,0.25)]"> + <div className="flex gap-2"> + <input + type="text" + value={input} + onChange={(e) => setInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleSend()} + placeholder="Describe your project..." + disabled={isLoading || !!createdContract} + className="flex-1 px-3 py-2 bg-[#0d1b2d] border border-[#0f3c78] text-[#dbe7ff] font-mono text-sm focus:border-[#3f6fb3] outline-none" + /> + <button + onClick={handleSend} + disabled={isLoading || !input.trim() || !!createdContract} + className="px-4 py-2 bg-[#0f3c78] text-[#dbe7ff] font-mono text-xs uppercase hover:bg-[#153667] disabled:opacity-50" + > + Send + </button> + </div> + </div> + </div> + </div> + ); +} +``` + +#### 2.3 ChatBubble Component + +```tsx +interface ChatBubbleProps { + message: Message; + onSpeak: () => void; + isSpeaking: boolean; +} + +function ChatBubble({ message, onSpeak, isSpeaking }: ChatBubbleProps) { + const isUser = message.role === "user"; + + return ( + <div className={`flex ${isUser ? "justify-end" : "justify-start"}`}> + <div + className={`max-w-[80%] p-3 font-mono text-sm ${ + isUser + ? "bg-[#0f3c78] text-[#dbe7ff]" + : "bg-[#0d1b2d] border border-[rgba(117,170,252,0.25)] text-[#dbe7ff]" + }`} + > + <div className="whitespace-pre-wrap">{message.content}</div> + + {!isUser && ( + <div className="mt-2 flex items-center gap-2"> + <button + onClick={onSpeak} + className="text-[10px] text-[#9bc3ff] hover:text-[#dbe7ff] uppercase" + > + {isSpeaking ? "[Stop]" : "[Speak]"} + </button> + </div> + )} + + {message.toolCalls && message.toolCalls.length > 0 && ( + <div className="mt-2 pt-2 border-t border-[rgba(117,170,252,0.25)]"> + {message.toolCalls.map((tc, i) => ( + <div key={i} className="text-[10px] text-[#75aafc]"> + {tc.result.success ? "+" : "-"} {tc.name}: {tc.result.message} + </div> + ))} + </div> + )} + </div> + </div> + ); +} +``` + +--- + +### Task 3: Frontend - API Integration + +**File:** `makima/frontend/src/lib/api.ts` + +Add the discuss contract API function. + +```typescript +// ============================================================================= +// Contract Discussion Types and API +// ============================================================================= + +export interface DiscussContractRequest { + message: string; + model?: LlmModel; + history?: ChatMessage[]; + transcriptContext?: string; +} + +export interface CreatedContractInfo { + id: string; + name: string; + description: string | null; + contractType: string; + initialPhase: string; +} + +export interface DiscussContractResponse { + response: string; + toolCalls: ContractToolCallInfo[]; + createdContract?: CreatedContractInfo; + pendingQuestions?: UserQuestion[]; +} + +/** + * Discuss a potential contract with Makima. + * This is an ephemeral conversation that can result in contract creation. + */ +export async function discussContract( + message: string, + model?: LlmModel, + history?: ChatMessage[], + transcriptContext?: string +): Promise<DiscussContractResponse> { + const body: DiscussContractRequest = { message }; + if (model) body.model = model; + if (history && history.length > 0) body.history = history; + if (transcriptContext) body.transcriptContext = transcriptContext; + + const res = await authFetch(`${API_BASE}/api/v1/contracts/discuss`, { + method: "POST", + body: JSON.stringify(body), + }); + + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`Discussion failed: ${errorText || res.statusText}`); + } + + return res.json(); +} +``` + +--- + +### Task 4: Frontend - Integration into Listen Page + +#### 4.1 Update ContractPickerModal + +**File:** `makima/frontend/src/components/listen/ContractPickerModal.tsx` + +Add "Discuss Contract" option to the picker. + +```tsx +interface ContractPickerModalProps { + isOpen: boolean; + onClose: () => void; + contracts: ContractOption[]; + selectedContractId: string | null; + onSelect: (contractId: string | null) => void; + onDiscussContract: () => void; // NEW + loading?: boolean; +} + +export function ContractPickerModal({ + // ... existing props + onDiscussContract, +}: ContractPickerModalProps) { + return ( + <div className="..."> + {/* ... existing content ... */} + + {/* Ephemeral option */} + <button onClick={() => handleSelect(null)} className="..."> + <span className="uppercase tracking-wide">Ephemeral</span> + <span className="...">Transcript not saved</span> + </button> + + {/* NEW: Discuss Contract option */} + <button + onClick={() => { + onClose(); + onDiscussContract(); + }} + className="w-full text-left px-3 py-2 font-mono text-xs border bg-[#0d1b2d] border-[#3f6fb3] text-[#dbe7ff] hover:bg-[#153667] transition-colors" + > + <span className="uppercase tracking-wide">Discuss Contract</span> + <span className="block text-[10px] text-[#75aafc] mt-0.5"> + Chat with Makima to define a new contract + </span> + </button> + + {/* Existing contracts */} + {contracts.map((contract) => ( + // ... existing contract buttons + ))} + </div> + ); +} +``` + +#### 4.2 Update ControlPanel + +**File:** `makima/frontend/src/components/listen/ControlPanel.tsx` + +Add prop for discuss contract callback. + +```tsx +interface ControlPanelProps { + // ... existing props + onDiscussContract: () => void; // NEW +} + +export function ControlPanel({ + // ... existing props + onDiscussContract, +}: ControlPanelProps) { + return ( + <div className="..."> + {/* ... existing content ... */} + + <ContractPickerModal + isOpen={isModalOpen} + onClose={() => setIsModalOpen(false)} + contracts={contracts} + selectedContractId={selectedContractId} + onSelect={onContractChange} + onDiscussContract={onDiscussContract} + loading={contractsLoading} + /> + </div> + ); +} +``` + +#### 4.3 Update Listen Route + +**File:** `makima/frontend/src/routes/listen.tsx` + +Integrate the DiscussContractModal. + +```tsx +import { DiscussContractModal } from "../components/listen/DiscussContractModal"; + +export default function ListenPage() { + // ... existing state ... + + // NEW: Discuss contract modal state + const [isDiscussModalOpen, setIsDiscussModalOpen] = useState(false); + + // Get current transcript context for discussion + const transcriptContext = useMemo(() => { + if (ws.transcripts.length === 0) return undefined; + return ws.transcripts + .map(t => `[${t.speaker}]: ${t.text}`) + .join("\n"); + }, [ws.transcripts]); + + const handleOpenDiscussModal = useCallback(() => { + setIsDiscussModalOpen(true); + }, []); + + const handleContractCreated = useCallback((contract: CreatedContractInfo) => { + // Add to contracts list and select it + setContracts(prev => [ + { id: contract.id, name: contract.name }, + ...prev, + ]); + setSelectedContractId(contract.id); + // Close the modal after a short delay to show success + setTimeout(() => setIsDiscussModalOpen(false), 2000); + }, []); + + return ( + <div className="..."> + {/* ... existing content ... */} + + <ControlPanel + // ... existing props ... + onDiscussContract={handleOpenDiscussModal} + /> + + {/* NEW: Discuss Contract Modal */} + <DiscussContractModal + isOpen={isDiscussModalOpen} + onClose={() => setIsDiscussModalOpen(false)} + transcriptContext={transcriptContext} + onContractCreated={handleContractCreated} + /> + + {/* ... existing TranscriptAnalysisPanel ... */} + </div> + ); +} +``` + +--- + +### Task 5: Integration Testing + +#### 5.1 Backend Tests + +**File:** `makima/src/server/handlers/contract_discuss_test.rs` + +```rust +#[tokio::test] +async fn test_discuss_contract_basic_conversation() { + // Test basic message/response flow +} + +#[tokio::test] +async fn test_discuss_contract_creates_contract() { + // Test that create_contract tool works +} + +#[tokio::test] +async fn test_discuss_contract_with_transcript_context() { + // Test transcript context is included +} +``` + +#### 5.2 Frontend Tests + +- Test modal opens/closes correctly +- Test message sending and receiving +- Test speak button functionality +- Test contract creation flow + +--- + +## UI/UX Flow + +### User Journey + +1. **User records a transcript** (optional) + - Captures meeting or brainstorming session + +2. **User clicks "Contract" dropdown** + - Sees: Ephemeral, existing contracts, "Discuss Contract" option + +3. **User selects "Discuss Contract"** + - Modal opens with greeting from Makima + - If transcript exists, Makima references it + +4. **Conversation flows naturally** + - User describes what they want to build + - Makima asks clarifying questions + - User can click [Speak] to hear Makima's responses + +5. **When ready, user confirms** + - "Yes, let's create the contract" + - Makima calls create_contract tool + +6. **Contract created** + - Success banner appears + - Modal closes, contract selected automatically + - User can continue with normal workflow + +### Example Conversation + +``` +Makima: Hello! I'm Makima. Tell me about what you'd like to build, + and I'll help you create a contract for it. + +User: I want to add dark mode to my React app + +Makima: Great! Dark mode is a common feature. Let me understand your + situation better: + + 1. Is this for an existing React application, or are you + starting fresh? + 2. Do you have a specific design system or color palette in mind? + 3. Should users be able to toggle between light/dark, or should + it follow system preferences? + +User: Existing app. I want both - toggle and follow system. + No specific design yet. + +Makima: Perfect. For an existing app adding dark mode with toggle + AND system preference support, I'd recommend a "simple" contract + since the requirements are clear. + + Here's what I'm thinking: + - **Name:** "Add Dark Mode Support" + - **Description:** Implement theme switching (light/dark mode) + with manual toggle and system preference detection + - **Type:** Simple (plan -> execute) + + Does this look right? Should I create this contract? + +User: Yes, looks good! + +Makima: Done! I've created the contract "Add Dark Mode Support". + You can now add your repository and start planning the + implementation. + + [Contract created: Add Dark Mode Support] +``` + +--- + +## Technical Details + +### API Contract + +#### Request +``` +POST /api/v1/contracts/discuss +Content-Type: application/json +Authorization: Bearer <token> + +{ + "message": "I want to add dark mode to my app", + "model": "claude-sonnet", + "history": [ + { "role": "assistant", "content": "Hello! I'm Makima..." }, + { "role": "user", "content": "I want to add dark mode" } + ], + "transcriptContext": "[Speaker A]: Let's discuss the dark mode feature..." +} +``` + +#### Response +```json +{ + "response": "Great! Let me understand your requirements...", + "toolCalls": [], + "createdContract": null, + "pendingQuestions": null +} +``` + +#### Response (with contract creation) +```json +{ + "response": "Done! I've created the contract...", + "toolCalls": [ + { + "name": "create_contract", + "result": { + "success": true, + "message": "Contract 'Add Dark Mode Support' created" + } + } + ], + "createdContract": { + "id": "uuid-here", + "name": "Add Dark Mode Support", + "description": "Implement theme switching...", + "contractType": "simple", + "initialPhase": "plan" + } +} +``` + +### State Management + +The DiscussContractModal manages its own state: + +```typescript +interface ModalState { + messages: Message[]; // Conversation history + isLoading: boolean; // API call in progress + error: string | null; // Error message + createdContract: CreatedContractInfo | null; // Created contract info +} +``` + +The parent (ListenPage) only needs to know: +- Is modal open? +- When was a contract created? + +### Error Handling + +1. **API errors** - Display in modal, allow retry +2. **Network errors** - Display error, suggest retry +3. **LLM errors** - Display gracefully, offer to restart conversation + +--- + +## Files to Create/Modify + +### New Files +1. `makima/src/server/handlers/contract_discuss.rs` - Backend handler +2. `makima/src/llm/discuss_tools.rs` - Tool definitions +3. `makima/frontend/src/components/listen/DiscussContractModal.tsx` - Chat UI + +### Modified Files +1. `makima/src/server/mod.rs` - Register new route +2. `makima/src/server/openapi.rs` - Add OpenAPI docs +3. `makima/src/llm/mod.rs` - Export discuss tools +4. `makima/frontend/src/lib/api.ts` - Add API function +5. `makima/frontend/src/components/listen/ContractPickerModal.tsx` - Add discuss option +6. `makima/frontend/src/components/listen/ControlPanel.tsx` - Pass callback +7. `makima/frontend/src/routes/listen.tsx` - Integrate modal + +--- + +## Key Design Decisions + +1. **New endpoint vs. existing chatWithContract** + - **Decision:** New endpoint `/api/v1/contracts/discuss` + - **Rationale:** chatWithContract requires an existing contract; this feature creates one + +2. **System prompt character** + - **Decision:** Makima-character aware of makima.jp platform + - **Rationale:** Consistent brand experience, engaging conversation + +3. **Tool for contract creation** + - **Decision:** `create_contract` tool the LLM invokes + - **Rationale:** Natural conversation flow; LLM decides when ready + +4. **Transcript context** + - **Decision:** Pass as optional context in system prompt + - **Rationale:** Seamless integration with listen page recording + +5. **Speak functionality** + - **Decision:** Per-message speak button using useSpeakWebSocket + - **Rationale:** Reuses existing TTS infrastructure + +6. **Conversation history** + - **Decision:** Frontend manages history, sends with each request + - **Rationale:** Stateless backend, consistent with existing chat patterns diff --git a/makima/frontend/src/components/listen/ContractPickerModal.tsx b/makima/frontend/src/components/listen/ContractPickerModal.tsx index 961ccba..f3c72d0 100644 --- a/makima/frontend/src/components/listen/ContractPickerModal.tsx +++ b/makima/frontend/src/components/listen/ContractPickerModal.tsx @@ -7,6 +7,7 @@ interface ContractPickerModalProps { contracts: ContractOption[]; selectedContractId: string | null; onSelect: (contractId: string | null) => void; + onDiscussContract: () => void; loading?: boolean; } @@ -16,6 +17,7 @@ export function ContractPickerModal({ contracts, selectedContractId, onSelect, + onDiscussContract, loading, }: ContractPickerModalProps) { const modalRef = useRef<HTMLDivElement>(null); @@ -90,6 +92,19 @@ export function ContractPickerModal({ </span> </button> + <button + onClick={() => { + onClose(); + onDiscussContract(); + }} + className="w-full text-left px-3 py-2 font-mono text-xs border bg-[#0d1b2d] border-[#3f6fb3] text-[#dbe7ff] hover:bg-[#153667] transition-colors" + > + <span className="uppercase tracking-wide">Discuss Contract</span> + <span className="block text-[10px] text-[#75aafc] mt-0.5"> + Chat with Makima to define a new contract + </span> + </button> + {contracts.map((contract) => ( <button key={contract.id} diff --git a/makima/frontend/src/components/listen/ControlPanel.tsx b/makima/frontend/src/components/listen/ControlPanel.tsx index f482ec4..ab2bcee 100644 --- a/makima/frontend/src/components/listen/ControlPanel.tsx +++ b/makima/frontend/src/components/listen/ControlPanel.tsx @@ -22,6 +22,7 @@ interface ControlPanelProps { contracts: ContractOption[]; selectedContractId: string | null; onContractChange: (contractId: string | null) => void; + onDiscussContract: () => void; contractsLoading?: boolean; // Connection status for loading state connectionStatus?: ConnectionStatus; @@ -56,6 +57,7 @@ export function ControlPanel({ contracts, selectedContractId, onContractChange, + onDiscussContract, contractsLoading, connectionStatus, }: ControlPanelProps) { @@ -190,6 +192,7 @@ export function ControlPanel({ contracts={contracts} selectedContractId={selectedContractId} onSelect={onContractChange} + onDiscussContract={onDiscussContract} loading={contractsLoading} /> </div> diff --git a/makima/frontend/src/components/listen/DiscussContractModal.tsx b/makima/frontend/src/components/listen/DiscussContractModal.tsx new file mode 100644 index 0000000..984f505 --- /dev/null +++ b/makima/frontend/src/components/listen/DiscussContractModal.tsx @@ -0,0 +1,287 @@ +import { useState, useEffect, useRef, useCallback } from "react"; +import { useSpeakWebSocket } from "../../hooks/useSpeakWebSocket"; +import { + discussContract, + type CreatedContractInfo, + type ContractToolCallInfo, + type ChatMessage, +} from "../../lib/api"; + +interface Message { + id: string; + role: "user" | "assistant"; + content: string; + timestamp: Date; + toolCalls?: ContractToolCallInfo[]; +} + +interface DiscussContractModalProps { + isOpen: boolean; + onClose: () => void; + transcriptContext?: string; + onContractCreated: (contract: CreatedContractInfo) => void; +} + +interface ChatBubbleProps { + message: Message; + onSpeak: () => void; + isSpeaking: boolean; +} + +function ChatBubble({ message, onSpeak, isSpeaking }: ChatBubbleProps) { + const isUser = message.role === "user"; + + return ( + <div className={`flex ${isUser ? "justify-end" : "justify-start"}`}> + <div + className={`max-w-[80%] p-3 font-mono text-sm ${ + isUser + ? "bg-[#0f3c78] text-[#dbe7ff]" + : "bg-[#0d1b2d] border border-[rgba(117,170,252,0.25)] text-[#dbe7ff]" + }`} + > + <div className="whitespace-pre-wrap">{message.content}</div> + + {!isUser && ( + <div className="mt-2 flex items-center gap-2"> + <button + onClick={onSpeak} + className="text-[10px] text-[#9bc3ff] hover:text-[#dbe7ff] uppercase" + > + {isSpeaking ? "[Stop]" : "[Speak]"} + </button> + </div> + )} + + {message.toolCalls && message.toolCalls.length > 0 && ( + <div className="mt-2 pt-2 border-t border-[rgba(117,170,252,0.25)]"> + {message.toolCalls.map((tc, i) => ( + <div key={i} className="text-[10px] text-[#75aafc]"> + {tc.result.success ? "+" : "-"} {tc.name}: {tc.result.message} + </div> + ))} + </div> + )} + </div> + </div> + ); +} + +export function DiscussContractModal({ + isOpen, + onClose, + transcriptContext, + onContractCreated, +}: DiscussContractModalProps) { + const [messages, setMessages] = useState<Message[]>([]); + const [input, setInput] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState<string | null>(null); + const [createdContract, setCreatedContract] = useState<CreatedContractInfo | null>(null); + + const { speak, isSpeaking, cancel } = useSpeakWebSocket(); + const messagesEndRef = useRef<HTMLDivElement>(null); + const modalRef = useRef<HTMLDivElement>(null); + + // Auto-scroll to bottom on new messages + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages]); + + // Initial greeting when modal opens + useEffect(() => { + if (isOpen && messages.length === 0) { + const greeting = transcriptContext + ? "I've reviewed your session transcript. What would you like to build based on this discussion?" + : "Hello! I'm Makima. Tell me about what you'd like to build, and I'll help you create a contract for it."; + + setMessages([{ + id: crypto.randomUUID(), + role: "assistant", + content: greeting, + timestamp: new Date(), + }]); + } + }, [isOpen, transcriptContext, messages.length]); + + // Handle escape key and click outside + useEffect(() => { + if (!isOpen) return; + + function handleKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") { + onClose(); + } + } + + function handleClickOutside(e: MouseEvent) { + if (modalRef.current && !modalRef.current.contains(e.target as Node)) { + onClose(); + } + } + + document.addEventListener("keydown", handleKeyDown); + document.addEventListener("mousedown", handleClickOutside); + + return () => { + document.removeEventListener("keydown", handleKeyDown); + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [isOpen, onClose]); + + // Reset state when modal closes + useEffect(() => { + if (!isOpen) { + setMessages([]); + setInput(""); + setIsLoading(false); + setError(null); + setCreatedContract(null); + } + }, [isOpen]); + + const handleSend = useCallback(async () => { + if (!input.trim() || isLoading) return; + + const userMessage: Message = { + id: crypto.randomUUID(), + role: "user", + content: input, + timestamp: new Date(), + }; + + setMessages(prev => [...prev, userMessage]); + setInput(""); + setIsLoading(true); + setError(null); + + try { + // Build history from existing messages (excluding the greeting) + const history: ChatMessage[] = messages.map(m => ({ + role: m.role, + content: m.content, + })); + + const response = await discussContract( + input, + undefined, // model + history, + transcriptContext + ); + + const assistantMessage: Message = { + id: crypto.randomUUID(), + role: "assistant", + content: response.response, + timestamp: new Date(), + toolCalls: response.toolCalls, + }; + + setMessages(prev => [...prev, assistantMessage]); + + if (response.createdContract) { + setCreatedContract(response.createdContract); + onContractCreated(response.createdContract); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to get response"); + } finally { + setIsLoading(false); + } + }, [input, isLoading, messages, transcriptContext, onContractCreated]); + + const handleSpeak = useCallback((text: string) => { + if (isSpeaking) { + cancel(); + } else { + speak(text); + } + }, [isSpeaking, cancel, speak]); + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }, [handleSend]); + + if (!isOpen) return null; + + return ( + <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"> + <div + ref={modalRef} + className="panel w-full max-w-2xl h-[600px] flex flex-col mx-4" + > + {/* Header */} + <div className="flex items-center justify-between p-4 border-b border-[rgba(117,170,252,0.25)]"> + <h2 className="font-mono text-sm text-[#dbe7ff] uppercase tracking-wide"> + Discuss Contract with Makima + </h2> + <button + onClick={onClose} + className="font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] transition-colors" + > + [X] + </button> + </div> + + {/* Messages */} + <div className="flex-1 overflow-y-auto p-4 space-y-4"> + {messages.map((message) => ( + <ChatBubble + key={message.id} + message={message} + onSpeak={() => handleSpeak(message.content)} + isSpeaking={isSpeaking} + /> + ))} + <div ref={messagesEndRef} /> + + {isLoading && ( + <div className="flex items-center gap-2 text-[#9bc3ff]"> + <span className="animate-pulse font-mono text-sm">Makima is thinking...</span> + </div> + )} + + {error && ( + <div className="font-mono text-xs text-red-400 px-2 py-2 border border-red-400/50 bg-red-400/10"> + {error} + </div> + )} + </div> + + {/* Contract Created Banner */} + {createdContract && ( + <div className="p-3 bg-green-400/10 border-t border-green-400/50"> + <div className="font-mono text-xs text-green-400"> + Contract "{createdContract.name}" created successfully! + </div> + </div> + )} + + {/* Input */} + <div className="p-4 border-t border-[rgba(117,170,252,0.25)]"> + <div className="flex gap-2"> + <input + type="text" + value={input} + onChange={(e) => setInput(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Describe your project..." + disabled={isLoading || !!createdContract} + className="flex-1 px-3 py-2 bg-[#0d1b2d] border border-[#0f3c78] text-[#dbe7ff] font-mono text-sm focus:border-[#3f6fb3] outline-none disabled:opacity-50" + /> + <button + onClick={handleSend} + disabled={isLoading || !input.trim() || !!createdContract} + className="px-4 py-2 bg-[#0f3c78] text-[#dbe7ff] font-mono text-xs uppercase hover:bg-[#153667] disabled:opacity-50 disabled:cursor-not-allowed transition-colors" + > + Send + </button> + </div> + </div> + </div> + </div> + ); +} diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts index 56491fd..bdaedf9 100644 --- a/makima/frontend/src/lib/api.ts +++ b/makima/frontend/src/lib/api.ts @@ -2137,6 +2137,60 @@ export async function clearContractChatHistory( } // ============================================================================= +// Contract Discussion Types and API +// ============================================================================= + +export interface DiscussContractRequest { + message: string; + model?: LlmModel; + history?: ChatMessage[]; + transcriptContext?: string; +} + +export interface CreatedContractInfo { + id: string; + name: string; + description: string | null; + contractType: string; + initialPhase: string; +} + +export interface DiscussContractResponse { + response: string; + toolCalls: ContractToolCallInfo[]; + createdContract?: CreatedContractInfo; + pendingQuestions?: UserQuestion[]; +} + +/** + * Discuss a potential contract with Makima. + * This is an ephemeral conversation that can result in contract creation. + */ +export async function discussContract( + message: string, + model?: LlmModel, + history?: ChatMessage[], + transcriptContext?: string +): Promise<DiscussContractResponse> { + const body: DiscussContractRequest = { message }; + if (model) body.model = model; + if (history && history.length > 0) body.history = history; + if (transcriptContext) body.transcriptContext = transcriptContext; + + const res = await authFetch(`${API_BASE}/api/v1/contracts/discuss`, { + method: "POST", + body: JSON.stringify(body), + }); + + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`Discussion failed: ${errorText || res.statusText}`); + } + + return res.json(); +} + +// ============================================================================= // Template Types and API // ============================================================================= diff --git a/makima/frontend/src/routes/listen.tsx b/makima/frontend/src/routes/listen.tsx index 8af538e..a53cbd9 100644 --- a/makima/frontend/src/routes/listen.tsx +++ b/makima/frontend/src/routes/listen.tsx @@ -4,9 +4,10 @@ import { SpeakerPanel } from "../components/listen/SpeakerPanel"; import { TranscriptPanel } from "../components/listen/TranscriptPanel"; import { ControlPanel, type ContractOption } from "../components/listen/ControlPanel"; import { TranscriptAnalysisPanel } from "../components/listen/TranscriptAnalysisPanel"; +import { DiscussContractModal } from "../components/listen/DiscussContractModal"; import { useMicrophone } from "../hooks/useMicrophone"; import { useWebSocket } from "../hooks/useWebSocket"; -import { listContracts } from "../lib/api"; +import { listContracts, type CreatedContractInfo } from "../lib/api"; import { useAuth } from "../contexts/AuthContext"; export default function ListenPage() { @@ -27,6 +28,9 @@ export default function ListenPage() { contractId: string; } | null>(null); + // Discuss contract modal state + const [isDiscussModalOpen, setIsDiscussModalOpen] = useState(false); + // Fetch contracts on mount useEffect(() => { if (!isAuthenticated) { @@ -175,6 +179,29 @@ export default function ListenPage() { setSavedTranscript(null); }, []); + // Get current transcript context for discussion + const transcriptContext = useMemo(() => { + if (ws.transcripts.length === 0) return undefined; + return ws.transcripts + .map(t => `[${t.speaker}]: ${t.text}`) + .join("\n"); + }, [ws.transcripts]); + + const handleOpenDiscussModal = useCallback(() => { + setIsDiscussModalOpen(true); + }, []); + + const handleContractCreated = useCallback((contract: CreatedContractInfo) => { + // Add to contracts list and select it + setContracts(prev => [ + { id: contract.id, name: contract.name }, + ...prev, + ]); + setSelectedContractId(contract.id); + // Close the modal after a short delay to show success + setTimeout(() => setIsDiscussModalOpen(false), 2000); + }, []); + const error = ws.error || mic.error; return ( @@ -206,6 +233,7 @@ export default function ListenPage() { contracts={contracts} selectedContractId={selectedContractId} onContractChange={setSelectedContractId} + onDiscussContract={handleOpenDiscussModal} contractsLoading={contractsLoading} connectionStatus={ws.status} /> @@ -236,6 +264,14 @@ export default function ListenPage() { </div> </div> )} + + {/* Discuss Contract Modal */} + <DiscussContractModal + isOpen={isDiscussModalOpen} + onClose={() => setIsDiscussModalOpen(false)} + transcriptContext={transcriptContext} + onContractCreated={handleContractCreated} + /> </div> ); } diff --git a/makima/src/llm/discuss_tools.rs b/makima/src/llm/discuss_tools.rs new file mode 100644 index 0000000..7330db3 --- /dev/null +++ b/makima/src/llm/discuss_tools.rs @@ -0,0 +1,210 @@ +//! Tool definitions for contract discussion via LLM. +//! +//! These tools allow Makima to help users define and create contracts +//! through natural conversation. + +use serde_json::json; + +use super::tools::Tool; + +/// Available tools for contract discussion +pub static DISCUSS_TOOLS: once_cell::sync::Lazy<Vec<Tool>> = once_cell::sync::Lazy::new(|| { + vec![ + Tool { + name: "create_contract".to_string(), + description: "Create a new contract based on the discussion. Only call this when the user has confirmed they're ready to create the contract.".to_string(), + parameters: json!({ + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name for the contract" + }, + "description": { + "type": "string", + "description": "Detailed description of what the contract is for" + }, + "contract_type": { + "type": "string", + "enum": ["simple", "specification", "execute"], + "description": "Type of contract workflow" + }, + "repository_url": { + "type": "string", + "description": "Optional repository URL if discussed" + }, + "local_only": { + "type": "boolean", + "description": "If true, tasks won't auto-push or create PRs" + } + }, + "required": ["name", "description", "contract_type"] + }), + }, + Tool { + name: "ask_clarification".to_string(), + description: "Ask the user a clarifying question with multiple choice options.".to_string(), + parameters: json!({ + "type": "object", + "properties": { + "question": { + "type": "string", + "description": "The question to ask" + }, + "options": { + "type": "array", + "items": { "type": "string" }, + "description": "Multiple choice options" + }, + "allow_custom": { + "type": "boolean", + "description": "Allow user to provide a custom answer" + } + }, + "required": ["question", "options"] + }), + }, + ] +}); + +/// Request for discussion tool operations that require async database access +#[derive(Debug, Clone)] +pub enum DiscussToolRequest { + /// Create a new contract + CreateContract { + name: String, + description: String, + contract_type: String, + repository_url: Option<String>, + local_only: bool, + }, +} + +/// Result from executing a discussion tool +#[derive(Debug)] +pub struct DiscussToolExecutionResult { + pub success: bool, + pub message: String, + pub data: Option<serde_json::Value>, + /// Request for async operations (handled by discuss handler) + pub request: Option<DiscussToolRequest>, + /// Questions to ask the user (pauses conversation) + pub pending_questions: Option<Vec<super::tools::UserQuestion>>, +} + +/// Parse and validate a discussion tool call, returning a DiscussToolRequest for async handling +pub fn parse_discuss_tool_call(call: &super::tools::ToolCall) -> DiscussToolExecutionResult { + match call.name.as_str() { + "create_contract" => parse_create_contract(call), + "ask_clarification" => parse_ask_clarification(call), + _ => DiscussToolExecutionResult { + success: false, + message: format!("Unknown discussion tool: {}", call.name), + data: None, + request: None, + pending_questions: None, + }, + } +} + +fn parse_create_contract(call: &super::tools::ToolCall) -> DiscussToolExecutionResult { + let name = call.arguments.get("name").and_then(|v| v.as_str()); + let description = call.arguments.get("description").and_then(|v| v.as_str()); + let contract_type = call.arguments.get("contract_type").and_then(|v| v.as_str()); + + let Some(name) = name else { + return error_result("Missing required parameter: name"); + }; + let Some(description) = description else { + return error_result("Missing required parameter: description"); + }; + let Some(contract_type) = contract_type else { + return error_result("Missing required parameter: contract_type"); + }; + + let valid_types = ["simple", "specification", "execute"]; + if !valid_types.contains(&contract_type) { + return error_result("Invalid contract_type. Must be one of: simple, specification, execute"); + } + + let repository_url = call + .arguments + .get("repository_url") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let local_only = call + .arguments + .get("local_only") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + DiscussToolExecutionResult { + success: true, + message: format!("Creating contract '{}'...", name), + data: None, + request: Some(DiscussToolRequest::CreateContract { + name: name.to_string(), + description: description.to_string(), + contract_type: contract_type.to_string(), + repository_url, + local_only, + }), + pending_questions: None, + } +} + +fn parse_ask_clarification(call: &super::tools::ToolCall) -> DiscussToolExecutionResult { + let question = call.arguments.get("question").and_then(|v| v.as_str()); + let options = call.arguments.get("options").and_then(|v| v.as_array()); + + let Some(question) = question else { + return error_result("Missing required parameter: question"); + }; + let Some(options) = options else { + return error_result("Missing required parameter: options"); + }; + + let options: Vec<String> = options + .iter() + .filter_map(|o| o.as_str()) + .map(|s| s.to_string()) + .collect(); + + if options.is_empty() { + return error_result("Options array cannot be empty"); + } + + let allow_custom = call + .arguments + .get("allow_custom") + .and_then(|v| v.as_bool()) + .unwrap_or(true); + + // Create a UserQuestion for the ask_clarification tool + let user_question = super::tools::UserQuestion { + id: "clarification".to_string(), + question: question.to_string(), + options, + allow_multiple: false, + allow_custom, + }; + + DiscussToolExecutionResult { + success: true, + message: format!("Asking clarification: {}", question), + data: None, + request: None, + pending_questions: Some(vec![user_question]), + } +} + +fn error_result(message: &str) -> DiscussToolExecutionResult { + DiscussToolExecutionResult { + success: false, + message: message.to_string(), + data: None, + request: None, + pending_questions: None, + } +} diff --git a/makima/src/llm/mod.rs b/makima/src/llm/mod.rs index a3a3daf..4c84ced 100644 --- a/makima/src/llm/mod.rs +++ b/makima/src/llm/mod.rs @@ -2,6 +2,7 @@ pub mod claude; pub mod contract_tools; +pub mod discuss_tools; pub mod groq; pub mod markdown; pub mod mesh_tools; @@ -16,6 +17,9 @@ pub use contract_tools::{ parse_contract_tool_call, ChainedTaskDef, ContractToolExecutionResult, ContractToolRequest, CONTRACT_TOOLS, }; +pub use discuss_tools::{ + parse_discuss_tool_call, DiscussToolExecutionResult, DiscussToolRequest, DISCUSS_TOOLS, +}; pub use groq::GroqClient; pub use mesh_tools::{parse_mesh_tool_call, MeshToolExecutionResult, MeshToolRequest, MESH_TOOLS}; pub use phase_guidance::{ diff --git a/makima/src/server/handlers/contract_discuss.rs b/makima/src/server/handlers/contract_discuss.rs new file mode 100644 index 0000000..1f98f53 --- /dev/null +++ b/makima/src/server/handlers/contract_discuss.rs @@ -0,0 +1,592 @@ +//! Discussion endpoint for LLM-powered contract creation. +//! +//! This handler provides an ephemeral conversation with Makima to help users +//! define and create contracts through natural dialogue. + +use axum::{ + extract::State, + http::StatusCode, + response::IntoResponse, + Json, +}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::db::{models::CreateContractRequest, repository}; +use crate::llm::{ + claude::{self, ClaudeClient, ClaudeError, ClaudeModel}, + groq::{GroqClient, GroqError, Message, ToolCallResponse}, + discuss_tools::{parse_discuss_tool_call, DiscussToolRequest, DISCUSS_TOOLS}, + LlmModel, ToolCall, ToolResult, UserQuestion, +}; +use crate::server::auth::Authenticated; +use crate::server::state::SharedState; + +/// Maximum number of tool-calling rounds to prevent infinite loops +const MAX_TOOL_ROUNDS: usize = 10; + +/// System prompt for Makima character in contract discussions +const DISCUSS_SYSTEM_PROMPT: &str = r#" +You are Makima, an AI assistant on the makima.jp platform. You help users define and create contracts for their projects through natural conversation. + +## Your Personality +- Professional yet personable +- Focused on understanding the user's actual needs +- Ask clarifying questions when requirements are vague +- Guide the conversation toward actionable outcomes +- Comfortable making recommendations based on experience + +## Your Goal +Help the user flesh out their project idea into a well-defined contract. A contract on makima.jp includes: +- A clear name and description +- The right contract type (simple, specification, or execute) +- Understanding of the scope and requirements + +## Contract Types +- **simple**: Quick tasks with minimal planning (plan -> execute phases only) +- **specification**: Full lifecycle projects (research -> specify -> plan -> execute -> review) +- **execute**: Direct implementation when requirements are already clear (execute phase only) + +## Guidelines +1. **Start by understanding**: Ask about what they want to build +2. **Clarify scope**: Is this a quick fix, a new feature, or a full project? +3. **Gather requirements**: What are the must-haves vs nice-to-haves? +4. **Identify context**: Is there existing code? Which repository? +5. **Recommend type**: Suggest the appropriate contract type +6. **Confirm and create**: When the user is satisfied, create the contract + +## When to Create the Contract +Create the contract when: +- You have a clear understanding of what the user wants +- The user has confirmed they're ready to proceed +- You've gathered enough information for a meaningful contract + +Do NOT create the contract if: +- The user is still exploring ideas +- Key information is missing +- The user hasn't indicated readiness + +{transcript_context} +"#; + +/// Chat message in history +#[derive(Debug, Clone, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ChatMessage { + /// Role: "user" or "assistant" + pub role: String, + /// Message content + pub content: String, +} + +/// Request to discuss a potential contract +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct DiscussContractRequest { + /// The user's message + pub message: String, + /// Optional model selection (default: claude-sonnet) + #[serde(default)] + pub model: Option<String>, + /// Conversation history for context continuity + #[serde(default)] + pub history: Option<Vec<ChatMessage>>, + /// Optional transcript context from current session + #[serde(default)] + pub transcript_context: Option<String>, +} + +/// Response from the discussion endpoint +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct DiscussContractResponse { + /// Makima's response message + pub response: String, + /// Tool calls that were executed (e.g., create_contract) + pub tool_calls: Vec<ToolCallInfo>, + /// If a contract was created, its details + #[serde(skip_serializing_if = "Option::is_none")] + pub created_contract: Option<CreatedContractInfo>, + /// Pending questions (if LLM needs clarification) + #[serde(skip_serializing_if = "Option::is_none")] + pub pending_questions: Option<Vec<UserQuestion>>, +} + +/// Information about a tool call that was executed +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ToolCallInfo { + pub name: String, + pub result: ToolResult, +} + +/// Information about a created contract +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct CreatedContractInfo { + pub id: String, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option<String>, + pub contract_type: String, + pub initial_phase: String, +} + +/// Enum to hold LLM clients +enum LlmClient { + Groq(GroqClient), + Claude(ClaudeClient), +} + +/// Unified result from LLM call +struct LlmResult { + content: Option<String>, + tool_calls: Vec<ToolCall>, + raw_tool_calls: Vec<ToolCallResponse>, + finish_reason: String, +} + +/// Discuss a potential contract with Makima +#[utoipa::path( + post, + path = "/api/v1/contracts/discuss", + request_body = DiscussContractRequest, + responses( + (status = 200, description = "Discussion completed successfully", body = DiscussContractResponse), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error") + ), + security( + ("bearer_auth" = []), + ("api_key" = []) + ), + tag = "Contracts" +)] +pub async fn discuss_contract_handler( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Json(request): Json<DiscussContractRequest>, +) -> impl IntoResponse { + // Check if database is configured + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(json!({ "error": "Database not configured" })), + ) + .into_response(); + }; + + // Parse model selection (default to Claude Sonnet) + let model = request + .model + .as_ref() + .and_then(|m| LlmModel::from_str(m)) + .unwrap_or(LlmModel::ClaudeSonnet); + + tracing::info!("Contract discussion using LLM model: {:?}", model); + + // Initialize the appropriate LLM client + let llm_client = match model { + LlmModel::ClaudeSonnet => match ClaudeClient::from_env(ClaudeModel::Sonnet) { + Ok(client) => LlmClient::Claude(client), + Err(ClaudeError::MissingApiKey) => { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(json!({ "error": "ANTHROPIC_API_KEY not configured" })), + ) + .into_response(); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": format!("Claude client error: {}", e) })), + ) + .into_response(); + } + }, + LlmModel::ClaudeOpus => match ClaudeClient::from_env(ClaudeModel::Opus) { + Ok(client) => LlmClient::Claude(client), + Err(ClaudeError::MissingApiKey) => { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(json!({ "error": "ANTHROPIC_API_KEY not configured" })), + ) + .into_response(); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": format!("Claude client error: {}", e) })), + ) + .into_response(); + } + }, + LlmModel::GroqKimi => match GroqClient::from_env() { + Ok(client) => LlmClient::Groq(client), + Err(GroqError::MissingApiKey) => { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(json!({ "error": "GROQ_API_KEY not configured" })), + ) + .into_response(); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": format!("Groq client error: {}", e) })), + ) + .into_response(); + } + }, + }; + + // Build system prompt with optional transcript context + let transcript_section = match &request.transcript_context { + Some(ctx) => format!( + "\n## Current Session Context\nThe user has been recording a session. Here's the transcript:\n\n{}\n", + ctx + ), + None => String::new(), + }; + + let system_prompt = DISCUSS_SYSTEM_PROMPT.replace("{transcript_context}", &transcript_section); + + // Run the discussion agentic loop + run_discuss_agentic_loop( + pool, + &llm_client, + system_prompt, + &request, + auth.owner_id, + ) + .await +} + +/// Run the agentic loop for contract discussion +async fn run_discuss_agentic_loop( + pool: &sqlx::PgPool, + llm_client: &LlmClient, + system_prompt: String, + request: &DiscussContractRequest, + owner_id: Uuid, +) -> axum::response::Response { + // Build initial messages + let mut messages = vec![Message { + role: "system".to_string(), + content: Some(system_prompt), + tool_calls: None, + tool_call_id: None, + }]; + + // Add conversation history if provided + if let Some(history) = &request.history { + for msg in history { + messages.push(Message { + role: msg.role.clone(), + content: Some(msg.content.clone()), + tool_calls: None, + tool_call_id: None, + }); + } + } + + // Add current user message + messages.push(Message { + role: "user".to_string(), + content: Some(request.message.clone()), + tool_calls: None, + tool_call_id: None, + }); + + // State for tracking + let mut all_tool_call_infos: Vec<ToolCallInfo> = Vec::new(); + let mut final_response: Option<String> = None; + let mut created_contract: Option<CreatedContractInfo> = None; + let mut pending_questions: Option<Vec<UserQuestion>> = None; + + // Multi-turn agentic tool calling loop + for round in 0..MAX_TOOL_ROUNDS { + tracing::info!( + round = round, + total_tool_calls = all_tool_call_infos.len(), + "Contract discussion loop iteration" + ); + + // Call the appropriate LLM API + let result = match llm_client { + LlmClient::Groq(groq) => { + match groq.chat_with_tools(messages.clone(), &DISCUSS_TOOLS).await { + Ok(r) => LlmResult { + content: r.content, + tool_calls: r.tool_calls, + raw_tool_calls: r.raw_tool_calls, + finish_reason: r.finish_reason, + }, + Err(e) => { + tracing::error!("Groq API error: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": format!("LLM API error: {}", e) })), + ) + .into_response(); + } + } + } + LlmClient::Claude(claude_client) => { + let claude_messages = claude::groq_messages_to_claude(&messages); + match claude_client + .chat_with_tools(claude_messages, &DISCUSS_TOOLS) + .await + { + Ok(r) => { + let raw_tool_calls: Vec<ToolCallResponse> = r + .tool_calls + .iter() + .map(|tc| ToolCallResponse { + id: tc.id.clone(), + call_type: "function".to_string(), + function: crate::llm::groq::FunctionCall { + name: tc.name.clone(), + arguments: tc.arguments.to_string(), + }, + }) + .collect(); + + LlmResult { + content: r.content, + tool_calls: r.tool_calls, + raw_tool_calls, + finish_reason: r.stop_reason, + } + } + Err(e) => { + tracing::error!("Claude API error: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": format!("LLM API error: {}", e) })), + ) + .into_response(); + } + } + } + }; + + // Check if there are tool calls to execute + if result.tool_calls.is_empty() { + final_response = result.content; + break; + } + + // Add assistant message with tool calls to conversation + messages.push(Message { + role: "assistant".to_string(), + content: result.content.clone(), + tool_calls: Some(result.raw_tool_calls.clone()), + tool_call_id: None, + }); + + // Execute each tool call + for (i, tool_call) in result.tool_calls.iter().enumerate() { + tracing::info!(tool = %tool_call.name, round = round, "Executing discussion tool call"); + + // Parse the tool call + let mut execution_result = parse_discuss_tool_call(tool_call); + + // Handle async discussion tool requests + if let Some(discuss_request) = execution_result.request.take() { + let async_result = + handle_discuss_request(pool, discuss_request, owner_id).await; + execution_result.success = async_result.success; + execution_result.message = async_result.message; + execution_result.data = async_result.data; + + // Check if a contract was created + if let Some(ref data) = execution_result.data { + if let Some(contract_info) = data.get("createdContract") { + created_contract = Some(CreatedContractInfo { + id: contract_info["id"].as_str().unwrap_or("").to_string(), + name: contract_info["name"].as_str().unwrap_or("").to_string(), + description: contract_info["description"].as_str().map(|s| s.to_string()), + contract_type: contract_info["contractType"].as_str().unwrap_or("").to_string(), + initial_phase: contract_info["initialPhase"].as_str().unwrap_or("").to_string(), + }); + } + } + } + + // Check for pending user questions + if let Some(questions) = execution_result.pending_questions { + tracing::info!( + question_count = questions.len(), + "Discussion LLM requesting user input" + ); + pending_questions = Some(questions); + all_tool_call_infos.push(ToolCallInfo { + name: tool_call.name.clone(), + result: ToolResult { + success: execution_result.success, + message: execution_result.message.clone(), + }, + }); + break; + } + + // Build tool result message + let result_content = if let Some(data) = &execution_result.data { + json!({ + "success": execution_result.success, + "message": execution_result.message, + "data": data + }) + .to_string() + } else { + json!({ + "success": execution_result.success, + "message": execution_result.message + }) + .to_string() + }; + + // Add tool result message + let tool_call_id = match llm_client { + LlmClient::Groq(_) => result.raw_tool_calls[i].id.clone(), + LlmClient::Claude(_) => tool_call.id.clone(), + }; + + messages.push(Message { + role: "tool".to_string(), + content: Some(result_content), + tool_calls: None, + tool_call_id: Some(tool_call_id), + }); + + // Track for response + all_tool_call_infos.push(ToolCallInfo { + name: tool_call.name.clone(), + result: ToolResult { + success: execution_result.success, + message: execution_result.message, + }, + }); + } + + // If user questions are pending, pause + if pending_questions.is_some() { + final_response = result.content; + break; + } + + // If finish reason indicates completion, exit loop + let finish_lower = result.finish_reason.to_lowercase(); + if finish_lower == "stop" || finish_lower == "end_turn" { + final_response = result.content; + break; + } + } + + // Build response + let response_text = final_response.unwrap_or_else(|| { + if all_tool_call_infos.is_empty() { + "I couldn't understand your request. Please try rephrasing.".to_string() + } else { + "Done!".to_string() + } + }); + + ( + StatusCode::OK, + Json(DiscussContractResponse { + response: response_text, + tool_calls: all_tool_call_infos, + created_contract, + pending_questions, + }), + ) + .into_response() +} + +/// Result from handling an async discussion tool request +struct DiscussRequestResult { + success: bool, + message: String, + data: Option<serde_json::Value>, +} + +/// Handle async discussion tool requests that require database access +async fn handle_discuss_request( + pool: &sqlx::PgPool, + request: DiscussToolRequest, + owner_id: Uuid, +) -> DiscussRequestResult { + match request { + DiscussToolRequest::CreateContract { + name, + description, + contract_type, + repository_url, + local_only, + } => { + // Create the contract request + let create_req = CreateContractRequest { + name: name.clone(), + description: Some(description.clone()), + contract_type: Some(contract_type.clone()), + template_id: None, + initial_phase: None, + autonomous_loop: None, + phase_guard: None, + local_only: Some(local_only), + auto_merge_local: None, + }; + + match repository::create_contract_for_owner(pool, owner_id, create_req).await { + Ok(contract) => { + // If repository URL was provided, try to add it + if let Some(repo_url) = repository_url { + // Try to add as remote repository + let add_result = repository::add_remote_repository( + pool, + contract.id, + &format!("{} Repository", name), + &repo_url, + true, // is_primary + ) + .await; + + if let Err(e) = add_result { + tracing::warn!( + "Failed to add repository to contract {}: {}", + contract.id, + e + ); + } + } + + DiscussRequestResult { + success: true, + message: format!("Contract '{}' created successfully!", contract.name), + data: Some(json!({ + "createdContract": { + "id": contract.id.to_string(), + "name": contract.name, + "description": contract.description, + "contractType": contract.contract_type, + "initialPhase": contract.phase, + } + })), + } + } + Err(e) => { + tracing::error!("Failed to create contract: {}", e); + DiscussRequestResult { + success: false, + message: format!("Failed to create contract: {}", e), + data: None, + } + } + } + } + } +} diff --git a/makima/src/server/handlers/mod.rs b/makima/src/server/handlers/mod.rs index 3e01a3e..5e172bc 100644 --- a/makima/src/server/handlers/mod.rs +++ b/makima/src/server/handlers/mod.rs @@ -5,6 +5,7 @@ pub mod chains; pub mod chat; pub mod contract_chat; pub mod contract_daemon; +pub mod contract_discuss; pub mod contracts; pub mod file_ws; pub mod files; diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs index d110c18..553797f 100644 --- a/makima/src/server/mod.rs +++ b/makima/src/server/mod.rs @@ -18,7 +18,7 @@ use tower_http::trace::TraceLayer; use utoipa::OpenApi; use utoipa_swagger_ui::SwaggerUi; -use crate::server::handlers::{api_keys, chains, chat, contract_chat, contract_daemon, contracts, file_ws, files, history, listen, mesh, mesh_chat, mesh_daemon, mesh_merge, mesh_supervisor, mesh_ws, repository_history, speak, templates, transcript_analysis, users, versions}; +use crate::server::handlers::{api_keys, chains, chat, contract_chat, contract_daemon, contract_discuss, contracts, file_ws, files, history, listen, mesh, mesh_chat, mesh_daemon, mesh_merge, mesh_supervisor, mesh_ws, repository_history, speak, templates, transcript_analysis, users, versions}; use crate::server::openapi::ApiDoc; use crate::server::state::SharedState; @@ -151,6 +151,7 @@ pub fn make_router(state: SharedState) -> Router { .route("/users/me/password", axum::routing::put(users::change_password_handler)) .route("/users/me/email", axum::routing::put(users::change_email_handler)) // Contract endpoints + .route("/contracts/discuss", post(contract_discuss::discuss_contract_handler)) .route( "/contracts", get(contracts::list_contracts).post(contracts::create_contract), diff --git a/makima/src/server/openapi.rs b/makima/src/server/openapi.rs index f8c5474..a70342b 100644 --- a/makima/src/server/openapi.rs +++ b/makima/src/server/openapi.rs @@ -20,7 +20,7 @@ use crate::server::auth::{ ApiKey, ApiKeyInfoResponse, CreateApiKeyRequest, CreateApiKeyResponse, RefreshApiKeyRequest, RefreshApiKeyResponse, RevokeApiKeyResponse, }; -use crate::server::handlers::{api_keys, contract_chat, contracts, files, listen, mesh, mesh_chat, mesh_merge, repository_history, users}; +use crate::server::handlers::{api_keys, contract_chat, contract_discuss, contracts, files, listen, mesh, mesh_chat, mesh_merge, repository_history, users}; use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage, TranscriptMessage}; #[derive(OpenApi)] @@ -97,6 +97,8 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage contract_chat::contract_chat_handler, contract_chat::get_contract_chat_history, contract_chat::clear_contract_chat_history, + // Contract discuss endpoint + contract_discuss::discuss_contract_handler, // Repository history/settings endpoints repository_history::list_repository_history, repository_history::get_repository_suggestions, @@ -137,6 +139,12 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage // Contract chat schemas ContractChatMessageRecord, ContractChatHistoryResponse, + // Contract discuss schemas + contract_discuss::ChatMessage, + contract_discuss::DiscussContractRequest, + contract_discuss::DiscussContractResponse, + contract_discuss::ToolCallInfo, + contract_discuss::CreatedContractInfo, // Merge schemas BranchInfo, BranchListResponse, |
