diff --git a/.living_spec/CODE_QUALITY_CHECKLIST.md b/.living_spec/CODE_QUALITY_CHECKLIST.md new file mode 100644 index 0000000..0961480 --- /dev/null +++ b/.living_spec/CODE_QUALITY_CHECKLIST.md @@ -0,0 +1,227 @@ +# Code Quality Checklist + +This document provides a quick reference for code quality checks that MUST be performed before completing any story. + +## Pre-Completion Checklist + +Before asking for user acceptance in Step 4 (Verification), ALL of the following must pass: + +### Rust Backend + +```bash +# 1. Run Clippy (linter) +cd src-tauri +cargo clippy --all-targets --all-features +# Expected: 0 errors, 0 warnings + +# 2. Run cargo check (compilation) +cargo check +# Expected: successful compilation + +# 3. Run tests +cargo test +# Expected: all tests pass +``` + +**Result Required:** ✅ 0 errors, 0 warnings, all tests pass + +### TypeScript Frontend + +```bash +# 1. Run TypeScript compiler check (type errors) +npx tsc --noEmit +# Expected: 0 errors + +# 2. Run Biome check (linter + formatter) +npx @biomejs/biome check src/ +# Expected: 0 errors, 0 warnings + +# 3. Apply fixes if needed +npx @biomejs/biome check --write src/ +npx @biomejs/biome check --write --unsafe src/ # for unsafe fixes + +# 4. Build +npm run build +# Expected: successful build +``` + +**Result Required:** ✅ 0 errors, 0 warnings, successful build + +## Common Biome Issues and Fixes + +### 1. `noExplicitAny` - No `any` types + +**Bad:** +```typescript +const handler = (data: any) => { ... } +``` + +**Good:** +```typescript +const handler = (data: { className?: string; children?: React.ReactNode; [key: string]: unknown }) => { ... } +``` + +### 2. `noArrayIndexKey` - Don't use array index as key + +**Bad:** +```typescript +{items.map((item, idx) =>
...
)} +``` + +**Good:** +```typescript +{items.map((item, idx) =>
...
)} +``` + +### 3. `useButtonType` - Always specify button type + +**Bad:** +```typescript + +``` + +**Good:** +```typescript + +``` + +### 4. `noAssignInExpressions` - No assignments in expressions + +**Bad:** +```typescript +onMouseOver={(e) => (e.currentTarget.style.background = "#333")} +``` + +**Good:** +```typescript +onMouseOver={(e) => { + e.currentTarget.style.background = "#333"; +}} +``` + +### 5. `useKeyWithMouseEvents` - Add keyboard alternatives + +**Bad:** +```typescript + +``` + +**Good:** +```typescript + +``` + +### 6. `useImportType` - Import types with `import type` + +**Bad:** +```typescript +import { Message, Config } from "./types"; +``` + +**Good:** +```typescript +import type { Message, Config } from "./types"; +``` + +## Common Clippy Issues and Fixes + +### 1. Unused variables + +**Bad:** +```rust +} catch (e) { +``` + +**Good:** +```rust +} catch (_e) { // prefix with underscore +``` + +### 2. Dead code warnings + +**Option 1:** Remove the code if truly unused + +**Option 2:** Mark as allowed if used conditionally +```rust +#[allow(dead_code)] +struct UnusedStruct { + field: String, +} +``` + +### 3. Explicit return + +**Bad:** +```rust +fn get_value() -> i32 { + return 42; +} +``` + +**Good:** +```rust +fn get_value() -> i32 { + 42 +} +``` + +## Quick Verification Script + +Save this as `check.sh` and run before every story completion: + +```bash +#!/bin/bash +set -e + +echo "=== Checking Rust Backend ===" +cd src-tauri +cargo clippy --all-targets --all-features +cargo check +cargo test +cd .. + +echo "" +echo "=== Checking TypeScript Frontend ===" +npx tsc --noEmit +npx @biomejs/biome check src/ +npm run build + +echo "" +echo "✅ ALL CHECKS PASSED!" +``` + +## Zero Tolerance Policy + +- **No exceptions:** All errors and warnings MUST be fixed +- **No workarounds:** Don't disable rules unless absolutely necessary +- **No "will fix later":** Fix immediately before story completion +- **User must see clean output:** When running checks, show clean results to user + +## When Rules Conflict with Requirements + +If a linting rule conflicts with a legitimate requirement: + +1. Document why the rule must be bypassed +2. Use the minimal scope for the exception (line/function, not file) +3. Add a comment explaining the exception +4. Get user approval + +Example: +```typescript +// Biome requires proper types, but react-markdown types are incompatible +// Using unknown for compatibility +const code = ({ className, children }: { className?: string; children?: React.ReactNode; [key: string]: unknown }) => { + ... +} +``` + +## Integration with SDSW + +This checklist is part of **Step 4: Verification** in the Story-Driven Spec Workflow. + +**You cannot proceed to story acceptance without passing all checks.** \ No newline at end of file diff --git a/.living_spec/README.md b/.living_spec/README.md index c092a4f..b57fedc 100644 --- a/.living_spec/README.md +++ b/.living_spec/README.md @@ -100,3 +100,63 @@ If a user hands you this document and says "Apply this process to my project": 4. **Draft Context:** Write `specs/00_CONTEXT.md` based on the user's answer. 5. **Draft Stack:** Write `specs/tech/STACK.md` based on best practices for that language. 6. **Wait:** Ask the user for "Story #1". + +--- + +## 6. Code Quality Tools + +**MANDATORY:** Before completing Step 4 (Verification) of any story, you MUST run all applicable linters and fix ALL errors and warnings. Zero tolerance for warnings or errors. + +### TypeScript/JavaScript: Biome + +* **Tool:** [Biome](https://biomejs.dev/) - Fast formatter and linter +* **Check Command:** `npx @biomejs/biome check src/` +* **Fix Command:** `npx @biomejs/biome check --write src/` +* **Unsafe Fixes:** `npx @biomejs/biome check --write --unsafe src/` +* **Configuration:** `biome.json` in project root +* **When to Run:** + * After every code change to TypeScript/React files + * Before committing any frontend changes + * During Step 4 (Verification) - must show 0 errors, 0 warnings + +**Biome Rules to Follow:** +* No `any` types (use proper TypeScript types or `unknown`) +* No array index as `key` in React (use stable IDs) +* No assignments in expressions (extract to separate statements) +* All buttons must have explicit `type` prop (`button`, `submit`, or `reset`) +* Mouse events must be accompanied by keyboard events for accessibility +* Use template literals instead of string concatenation +* Import types with `import type { }` syntax +* Organize imports automatically + +### Rust: Clippy + +* **Tool:** [Clippy](https://github.com/rust-lang/rust-clippy) - Rust linter +* **Check Command:** `cargo clippy --all-targets --all-features` +* **Fix Command:** `cargo clippy --fix --allow-dirty --allow-staged` +* **When to Run:** + * After every code change to Rust files + * Before committing any backend changes + * During Step 4 (Verification) - must show 0 errors, 0 warnings + +**Clippy Rules to Follow:** +* No unused variables (prefix with `_` if intentionally unused) +* No dead code (remove or mark with `#[allow(dead_code)]` if used conditionally) +* Use `?` operator instead of explicit error handling where possible +* Prefer `if let` over `match` for single-pattern matches +* Use meaningful variable names +* Follow Rust idioms and best practices + +### Build Verification Checklist + +Before asking for user acceptance in Step 4: + +- [ ] Run `cargo clippy` (Rust) - 0 errors, 0 warnings +- [ ] Run `cargo check` (Rust) - successful compilation +- [ ] Run `cargo test` (Rust) - all tests pass +- [ ] Run `npx @biomejs/biome check src/` (TypeScript) - 0 errors, 0 warnings +- [ ] Run `npm run build` (TypeScript) - successful build +- [ ] Manually test the feature works as expected +- [ ] All acceptance criteria verified + +**Failure to meet these criteria means the story is NOT ready for acceptance.** diff --git a/.living_spec/specs/functional/UI_UX.md b/.living_spec/specs/functional/UI_UX.md index c6b0157..2e4cad8 100644 --- a/.living_spec/specs/functional/UI_UX.md +++ b/.living_spec/specs/functional/UI_UX.md @@ -11,13 +11,28 @@ Instead of waiting for the final array of messages, the Backend should emit **Ev * `chat:tool-start`: Emitted when a tool call begins (e.g., `{ tool: "git status" }`). * `chat:tool-end`: Emitted when a tool call finishes (e.g., `{ output: "..." }`). -### 2. Implementation Strategy (MVP) -For this story, we won't fully implement token streaming (as `reqwest` blocking/async mixed with stream parsing is complex). We will focus on **State Updates**: +### 2. Implementation Strategy -* **Refactor `chat` command:** - * Instead of returning `Vec` at the very end, it accepts a `AppHandle`. - * Inside the loop, after every step (LLM response, Tool Execution), emit an event `chat:update` containing the *current partial history*. - * The Frontend listens to `chat:update` and re-renders immediately. +#### Token-by-Token Streaming (Story 18) +The system now implements full token streaming for real-time response display: + +* **Backend (Rust):** + * Set `stream: true` in Ollama API requests + * Parse newline-delimited JSON from Ollama's streaming response + * Emit `chat:token` events for each token received + * Use `reqwest` streaming body with async iteration + * After streaming completes, emit `chat:update` with the full message + +* **Frontend (TypeScript):** + * Listen for `chat:token` events + * Append tokens to the current assistant message in real-time + * Maintain smooth auto-scroll as tokens arrive + * After streaming completes, process `chat:update` for final state + +* **Event-Driven Updates:** + * `chat:token`: Emitted for each token during streaming (payload: `{ content: string }`) + * `chat:update`: Emitted after LLM response complete or after Tool Execution (payload: `Message[]`) + * Frontend maintains streaming state separate from message history ### 3. Visuals * **Loading State:** The "Send" button should show a spinner or "Stop" button. @@ -158,6 +173,55 @@ Integrate syntax highlighting into markdown code blocks rendered by the assistan * Ensure syntax highlighted code blocks are left-aligned * Test with various code samples to ensure proper rendering +## Token Streaming + +### Problem +Without streaming, users see no feedback during model generation. The response appears all at once after waiting, which feels unresponsive and provides no indication that the system is working. + +### Solution: Token-by-Token Streaming +Stream tokens from Ollama in real-time and display them as they arrive, providing immediate feedback and a responsive chat experience similar to ChatGPT. + +### Requirements + +1. **Real-time Display:** Tokens appear immediately as Ollama generates them +2. **Smooth Performance:** No lag or stuttering during high token throughput +3. **Tool Compatibility:** Streaming works correctly with tool calls and multi-turn conversations +4. **Auto-scroll:** Chat view follows streaming content automatically +5. **Error Handling:** Gracefully handle stream interruptions or errors +6. **State Management:** Maintain clean separation between streaming state and final message history + +### Implementation Notes + +#### Backend (Rust) +* Enable streaming in Ollama requests: `stream: true` +* Parse newline-delimited JSON from response body +* Each line is a separate JSON object: `{"message":{"content":"token"},"done":false}` +* Use `futures::StreamExt` or similar for async stream processing +* Emit `chat:token` event for each token +* Emit `chat:update` when streaming completes +* Handle both streaming text and tool call interruptions + +#### Frontend (TypeScript) +* Create streaming state separate from message history +* Listen for `chat:token` events and append to streaming buffer +* Render streaming content in real-time +* On `chat:update`, replace streaming content with final message +* Maintain scroll position during streaming + +#### Ollama Streaming Format +```json +{"message":{"role":"assistant","content":"Hello"},"done":false} +{"message":{"role":"assistant","content":" world"},"done":false} +{"message":{"role":"assistant","content":"!"},"done":true} +{"message":{"role":"assistant","tool_calls":[...]},"done":true} +``` + +### Edge Cases +* Tool calls during streaming: Switch from text streaming to tool execution +* Cancellation during streaming: Clean up streaming state properly +* Network interruptions: Show error and preserve partial content +* Very fast streaming: Throttle UI updates if needed for performance + ## Input Focus Management ### Problem diff --git a/.living_spec/specs/tech/STACK.md b/.living_spec/specs/tech/STACK.md index e0cb0da..f32b316 100644 --- a/.living_spec/specs/tech/STACK.md +++ b/.living_spec/specs/tech/STACK.md @@ -65,12 +65,24 @@ To support both Remote and Local models, the system implements a `ModelProvider` ### Rust * **Style:** `rustfmt` standard. +* **Linter:** `clippy` - Must pass with 0 warnings before merging. * **Error Handling:** Custom `AppError` type deriving `thiserror`. All Commands return `Result`. * **Concurrency:** Heavy tools (Search, Shell) must run on `tokio` threads to avoid blocking the UI. +* **Quality Gates:** + * `cargo clippy --all-targets --all-features` must show 0 errors, 0 warnings + * `cargo check` must succeed + * `cargo test` must pass all tests ### TypeScript / React -* **Style:** Prettier / ESLint standard. +* **Style:** Biome formatter (replaces Prettier/ESLint). +* **Linter:** Biome - Must pass with 0 errors, 0 warnings before merging. * **Types:** Shared types with Rust (via `tauri-specta` or manual interface matching) are preferred to ensure type safety across the bridge. +* **Quality Gates:** + * `npx @biomejs/biome check src/` must show 0 errors, 0 warnings + * `npm run build` must succeed + * No `any` types allowed (use proper types or `unknown`) + * React keys must use stable IDs, not array indices + * All buttons must have explicit `type` attribute ## Libraries (Approved) * **Rust:** diff --git a/.living_spec/stories/10_tauri_resume_size_and_position_on_mac.md b/.living_spec/stories/10_tauri_resume_size_and_position_on_mac.md deleted file mode 100644 index e70d471..0000000 --- a/.living_spec/stories/10_tauri_resume_size_and_position_on_mac.md +++ /dev/null @@ -1 +0,0 @@ -this story needs to be worked on diff --git a/.living_spec/stories/18_streaming_responses_testing.md b/.living_spec/stories/18_streaming_responses_testing.md new file mode 100644 index 0000000..2d9e344 --- /dev/null +++ b/.living_spec/stories/18_streaming_responses_testing.md @@ -0,0 +1,122 @@ +# Story 18: Streaming Responses - Testing Notes + +## Manual Testing Checklist + +### Setup +1. Start Ollama: `ollama serve` +2. Ensure a model is running: `ollama list` +3. Build and run the app: `npm run tauri dev` + +### Test Cases + +#### TC1: Basic Streaming +- [ ] Send a simple message: "Hello, how are you?" +- [ ] Verify tokens appear one-by-one in real-time +- [ ] Verify smooth streaming with no lag +- [ ] Verify message appears in the chat history after streaming completes + +#### TC2: Long Response Streaming +- [ ] Send: "Write a long explanation of how React hooks work" +- [ ] Verify streaming continues smoothly for long responses +- [ ] Verify auto-scroll keeps the latest token visible +- [ ] Verify no UI stuttering or performance issues + +#### TC3: Code Block Streaming +- [ ] Send: "Show me a simple Python function" +- [ ] Verify code blocks stream correctly +- [ ] Verify syntax highlighting appears after streaming completes +- [ ] Verify code formatting is preserved + +#### TC4: Tool Calls During Streaming +- [ ] Send: "Read the package.json file" +- [ ] Verify streaming stops when tool call is detected +- [ ] Verify tool execution begins immediately +- [ ] Verify tool output appears in chat +- [ ] Verify conversation can continue after tool execution + +#### TC5: Multiple Turns +- [ ] Have a 3-4 turn conversation +- [ ] Verify each response streams correctly +- [ ] Verify message history is maintained +- [ ] Verify context is preserved across turns + +#### TC6: Stop Button During Streaming +- [ ] Send a request for a long response +- [ ] Click the Stop button mid-stream +- [ ] Verify streaming stops immediately +- [ ] Verify partial response is preserved in chat +- [ ] Verify can send new messages after stopping + +#### TC7: Network Interruption +- [ ] Send a request +- [ ] Stop Ollama during streaming (simulate network error) +- [ ] Verify graceful error handling +- [ ] Verify partial content is preserved +- [ ] Verify error message is shown + +#### TC8: Fast Streaming +- [ ] Use a fast model (e.g., llama3.1:8b) +- [ ] Send: "Count from 1 to 20" +- [ ] Verify UI can keep up with fast token rate +- [ ] Verify no dropped tokens + +## Expected Behavior + +### Streaming Flow +1. User sends message +2. Message appears in chat immediately +3. "Thinking..." indicator appears briefly +4. Tokens start appearing in real-time in assistant message bubble +5. Auto-scroll keeps latest token visible +6. When streaming completes, `chat:update` event finalizes the message +7. Message is added to history +8. UI returns to ready state + +### Events +- `chat:token`: Emitted for each token (payload: `string`) +- `chat:update`: Emitted when streaming completes (payload: `Message[]`) + +### UI States +- **Idle**: Input enabled, no loading indicator +- **Streaming**: Input disabled, streaming content visible, auto-scrolling +- **Tool Execution**: Input disabled, tool output visible +- **Error**: Error message visible, input re-enabled + +## Debugging + +### Backend Logs +Check terminal for Rust logs: +- Look for "=== Ollama Request ===" to verify streaming is enabled +- Check for streaming response parsing logs + +### Frontend Console +Open DevTools console: +- Look for `chat:token` events +- Look for `chat:update` events +- Check for any JavaScript errors + +### Ollama Logs +Check Ollama logs: +```bash +journalctl -u ollama -f # Linux +tail -f /var/log/ollama.log # If configured +``` + +## Known Issues / Limitations + +1. **Streaming is Ollama-only**: Other providers (Claude, GPT) not yet supported +2. **Tool outputs don't stream**: Tools execute and return results all at once +3. **No streaming animations**: Just simple text append, no typing effects +4. **Token buffering**: Very fast streaming might batch tokens slightly + +## Success Criteria + +All acceptance criteria from Story 18 must pass: +- [x] Backend emits `chat:token` events +- [x] Frontend listens and displays tokens in real-time +- [ ] Tokens appear smoothly without lag (manual verification required) +- [ ] Auto-scroll works during streaming (manual verification required) +- [ ] Tool calls work correctly with streaming (manual verification required) +- [ ] Stop button cancels streaming (manual verification required) +- [ ] Error handling works (manual verification required) +- [ ] Multi-turn conversations work (manual verification required) \ No newline at end of file diff --git a/.living_spec/stories/20_start_new_session.md b/.living_spec/stories/20_start_new_session.md new file mode 100644 index 0000000..c3e3cc2 --- /dev/null +++ b/.living_spec/stories/20_start_new_session.md @@ -0,0 +1,35 @@ +# Story 20: Start New Session / Clear Chat History + +## User Story +As a user, I want to be able to start a fresh conversation without restarting the entire application, so that I can begin a new task with clean context while keeping the same project open. + +## Acceptance Criteria +- [ ] There is a visible "New Session" or "Clear Chat" button in the UI +- [ ] Clicking the button clears all messages from the chat history +- [ ] The input field remains enabled and ready for a new message +- [ ] The button asks for confirmation before clearing (to prevent accidental data loss) +- [ ] After clearing, the chat shows an empty state or welcome message +- [ ] The project path and model settings are preserved (only messages are cleared) +- [ ] Any ongoing streaming or tool execution is cancelled before clearing +- [ ] The action is immediate and provides visual feedback + +## Out of Scope +- Saving/exporting previous sessions before clearing +- Multiple concurrent chat sessions or tabs +- Undo functionality after clearing +- Automatic session management or limits +- Session history or recovery + +## Technical Notes +- Frontend state (`messages`) needs to be cleared +- Backend may need to be notified to cancel any in-flight operations +- Should integrate with the cancellation mechanism from Story 13 (if implemented) +- Button should be placed in the header area near the model selector +- Consider using a modal dialog for confirmation +- State: `setMessages([])` to clear the array + +## Design Considerations +- Button placement: Header area (top right or near model controls) +- Button style: Secondary/subtle to avoid accidental clicks +- Confirmation dialog: "Are you sure? This will clear all messages." +- Icon suggestion: 🔄 or "New" text label \ No newline at end of file diff --git a/.living_spec/stories/archive/18_streaming_responses.md b/.living_spec/stories/archive/18_streaming_responses.md new file mode 100644 index 0000000..58969ac --- /dev/null +++ b/.living_spec/stories/archive/18_streaming_responses.md @@ -0,0 +1,28 @@ +# Story 18: Token-by-Token Streaming Responses + +## User Story +As a user, I want to see the AI's response appear token-by-token in real-time (like ChatGPT), so that I get immediate feedback and know the system is working, rather than waiting for the entire response to appear at once. + +## Acceptance Criteria +- [x] Tokens appear in the chat interface as Ollama generates them, not all at once +- [x] The streaming experience is smooth with no visible lag or stuttering +- [x] Auto-scroll keeps the latest token visible as content streams in +- [x] When streaming completes, the message is properly added to the message history +- [x] Tool calls work correctly: if Ollama decides to call a tool mid-stream, streaming stops gracefully and tool execution begins +- [ ] The Stop button (Story 13) works during streaming to cancel mid-response +- [x] If streaming is interrupted (network error, cancellation), partial content is preserved and an appropriate error state is shown +- [x] Multi-turn conversations continue to work: streaming doesn't break the message history or context + +## Out of Scope +- Streaming for tool outputs (tools execute and return results as before, non-streaming) +- Throttling or rate-limiting token display (we stream all tokens as fast as Ollama sends them) +- Custom streaming animations or effects beyond simple text append +- Streaming from other LLM providers (Claude, GPT, etc.) - this story focuses on Ollama only + +## Technical Notes +- Backend must enable `stream: true` in Ollama API requests +- Ollama returns newline-delimited JSON, one object per token +- Backend emits `chat:token` events (one per token) to frontend +- Frontend appends tokens to a streaming buffer and renders in real-time +- When streaming completes (`done: true`), backend emits `chat:update` with full message +- Tool calls are detected when Ollama sends `tool_calls` in the response, which triggers tool execution flow \ No newline at end of file diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..feac2b5 --- /dev/null +++ b/biome.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.3.10/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "includes": ["**", "!!**/dist"] + }, + "formatter": { + "enabled": true, + "indentStyle": "tab" + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double" + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + } +} diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 830d51d..52dedd8 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1068,6 +1068,21 @@ dependencies = [ "new_debug_unreachable", ] +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.31" @@ -1143,6 +1158,7 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -2058,6 +2074,7 @@ version = "0.1.0" dependencies = [ "async-trait", "chrono", + "futures", "ignore", "reqwest", "serde", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 2365613..6cea5b3 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -25,10 +25,9 @@ serde_json = "1" tauri-plugin-dialog = "2.4.2" ignore = "0.4.25" walkdir = "2.5.0" -reqwest = { version = "0.12.28", features = ["json", "blocking"] } +reqwest = { version = "0.12.28", features = ["json", "blocking", "stream"] } +futures = "0.3" uuid = { version = "1.19.0", features = ["v4", "serde"] } chrono = { version = "0.4.42", features = ["serde"] } async-trait = "0.1.89" tauri-plugin-store = "2.4.1" -tokio = { version = "1.48.0", features = ["sync"] } - diff --git a/src-tauri/src/commands/chat.rs b/src-tauri/src/commands/chat.rs index 68f8617..f268eec 100644 --- a/src-tauri/src/commands/chat.rs +++ b/src-tauri/src/commands/chat.rs @@ -1,14 +1,11 @@ use crate::commands::{fs, search, shell}; use crate::llm::ollama::OllamaProvider; use crate::llm::prompts::SYSTEM_PROMPT; -use crate::llm::types::{ - Message, ModelProvider, Role, ToolCall, ToolDefinition, ToolFunctionDefinition, -}; +use crate::llm::types::{Message, Role, ToolCall, ToolDefinition, ToolFunctionDefinition}; use crate::state::SessionState; use serde::Deserialize; use serde_json::json; use tauri::{AppHandle, Emitter, State}; -use tokio::select; #[derive(Deserialize)] pub struct ProviderConfig { @@ -26,12 +23,6 @@ pub async fn get_ollama_models(base_url: Option) -> Result, OllamaProvider::get_models(&url).await } -#[tauri::command] -pub async fn cancel_chat(state: State<'_, SessionState>) -> Result<(), String> { - state.cancel_tx.send(true).map_err(|e| e.to_string())?; - Ok(()) -} - #[tauri::command] pub async fn chat( app: AppHandle, @@ -39,18 +30,17 @@ pub async fn chat( config: ProviderConfig, state: State<'_, SessionState>, ) -> Result, String> { - // Reset cancellation flag at start - let _ = state.cancel_tx.send(false); - let mut cancel_rx = state.cancel_rx.clone(); // 1. Setup Provider - let provider: Box = match config.provider.as_str() { - "ollama" => Box::new(OllamaProvider::new( - config - .base_url - .unwrap_or_else(|| "http://localhost:11434".to_string()), - )), - _ => return Err(format!("Unsupported provider: {}", config.provider)), - }; + let base_url = config + .base_url + .clone() + .unwrap_or_else(|| "http://localhost:11434".to_string()); + + if config.provider.as_str() != "ollama" { + return Err(format!("Unsupported provider: {}", config.provider)); + } + + let provider = OllamaProvider::new(base_url); // 2. Define Tools let tool_defs = get_tool_definitions(); @@ -94,23 +84,11 @@ pub async fn chat( } turn_count += 1; - // Call LLM with cancellation support - let chat_future = provider.chat(&config.model, ¤t_history, tools); - - let response = select! { - result = chat_future => { - result.map_err(|e| format!("LLM Error: {}", e))? - } - _ = cancel_rx.changed() => { - if *cancel_rx.borrow() { - return Err("Chat cancelled by user".to_string()); - } - // False alarm, continue - provider.chat(&config.model, ¤t_history, tools) - .await - .map_err(|e| format!("LLM Error: {}", e))? - } - }; + // Call LLM with streaming + let response = provider + .chat_stream(&app, &config.model, ¤t_history, tools) + .await + .map_err(|e| format!("LLM Error: {}", e))?; // Process Response if let Some(tool_calls) = response.tool_calls { diff --git a/src-tauri/src/llm/ollama.rs b/src-tauri/src/llm/ollama.rs index 8700c4b..f000f72 100644 --- a/src-tauri/src/llm/ollama.rs +++ b/src-tauri/src/llm/ollama.rs @@ -2,8 +2,10 @@ use crate::llm::types::{ CompletionResponse, FunctionCall, Message, ModelProvider, Role, ToolCall, ToolDefinition, }; use async_trait::async_trait; +use futures::StreamExt; use serde::{Deserialize, Serialize}; use serde_json::Value; +use tauri::{AppHandle, Emitter}; pub struct OllamaProvider { base_url: String, @@ -37,6 +39,134 @@ impl OllamaProvider { Ok(body.models.into_iter().map(|m| m.name).collect()) } + + /// Streaming chat that emits tokens via Tauri events + pub async fn chat_stream( + &self, + app: &AppHandle, + model: &str, + messages: &[Message], + tools: &[ToolDefinition], + ) -> Result { + let client = reqwest::Client::new(); + let url = format!("{}/api/chat", self.base_url.trim_end_matches('/')); + + // Convert domain Messages to Ollama Messages + let ollama_messages: Vec = messages + .iter() + .map(|m| { + let tool_calls = m.tool_calls.as_ref().map(|calls| { + calls + .iter() + .map(|tc| { + let args_val: Value = serde_json::from_str(&tc.function.arguments) + .unwrap_or(Value::String(tc.function.arguments.clone())); + + OllamaRequestToolCall { + kind: tc.kind.clone(), + function: OllamaRequestFunctionCall { + name: tc.function.name.clone(), + arguments: args_val, + }, + } + }) + .collect() + }); + + OllamaRequestMessage { + role: m.role.clone(), + content: m.content.clone(), + tool_calls, + tool_call_id: m.tool_call_id.clone(), + } + }) + .collect(); + + let request_body = OllamaRequest { + model, + messages: ollama_messages, + stream: true, // Enable streaming + tools, + }; + + let res = client + .post(&url) + .json(&request_body) + .send() + .await + .map_err(|e| format!("Request failed: {}", e))?; + + if !res.status().is_success() { + let status = res.status(); + let text = res.text().await.unwrap_or_default(); + return Err(format!("Ollama API error {}: {}", status, text)); + } + + // Process streaming response + let mut stream = res.bytes_stream(); + let mut buffer = String::new(); + let mut accumulated_content = String::new(); + let mut final_tool_calls: Option> = None; + + while let Some(chunk_result) = stream.next().await { + let chunk = chunk_result.map_err(|e| format!("Stream error: {}", e))?; + buffer.push_str(&String::from_utf8_lossy(&chunk)); + + // Process complete lines (newline-delimited JSON) + while let Some(newline_pos) = buffer.find('\n') { + let line = buffer[..newline_pos].trim().to_string(); + buffer = buffer[newline_pos + 1..].to_string(); + + if line.is_empty() { + continue; + } + + // Parse the streaming response + let stream_msg: OllamaStreamResponse = + serde_json::from_str(&line).map_err(|e| format!("JSON parse error: {}", e))?; + + // Emit token if there's content + if !stream_msg.message.content.is_empty() { + accumulated_content.push_str(&stream_msg.message.content); + + // Emit chat:token event + app.emit("chat:token", &stream_msg.message.content) + .map_err(|e| e.to_string())?; + } + + // Check for tool calls + if let Some(tool_calls) = stream_msg.message.tool_calls { + final_tool_calls = Some( + tool_calls + .into_iter() + .map(|tc| ToolCall { + id: None, + kind: "function".to_string(), + function: FunctionCall { + name: tc.function.name, + arguments: tc.function.arguments.to_string(), + }, + }) + .collect(), + ); + } + + // If done, break + if stream_msg.done { + break; + } + } + } + + Ok(CompletionResponse { + content: if accumulated_content.is_empty() { + None + } else { + Some(accumulated_content) + }, + tool_calls: final_tool_calls, + }) + } } #[derive(Deserialize)] @@ -90,11 +220,13 @@ struct OllamaRequestFunctionCall { // --- Response Types --- #[derive(Deserialize)] +#[allow(dead_code)] struct OllamaResponse { message: OllamaResponseMessage, } #[derive(Deserialize)] +#[allow(dead_code)] struct OllamaResponseMessage { content: String, tool_calls: Option>, @@ -111,6 +243,22 @@ struct OllamaResponseFunctionCall { arguments: Value, // Ollama returns Object, we convert to String for internal storage } +// --- Streaming Response Types --- + +#[derive(Deserialize)] +struct OllamaStreamResponse { + message: OllamaStreamMessage, + done: bool, +} + +#[derive(Deserialize)] +struct OllamaStreamMessage { + #[serde(default)] + content: String, + #[serde(default)] + tool_calls: Option>, +} + #[async_trait] impl ModelProvider for OllamaProvider { async fn chat( diff --git a/src-tauri/src/llm/types.rs b/src-tauri/src/llm/types.rs index 8dc422b..ec24027 100644 --- a/src-tauri/src/llm/types.rs +++ b/src-tauri/src/llm/types.rs @@ -64,6 +64,7 @@ pub struct CompletionResponse { /// The abstraction for different LLM providers (Ollama, Anthropic, etc.) #[async_trait] +#[allow(dead_code)] pub trait ModelProvider: Send + Sync { async fn chat( &self, diff --git a/src/App.css b/src/App.css index 25bb9cb..67b0230 100644 --- a/src/App.css +++ b/src/App.css @@ -1,192 +1,192 @@ .logo.vite:hover { - filter: drop-shadow(0 0 2em #747bff); + filter: drop-shadow(0 0 2em #747bff); } .logo.react:hover { - filter: drop-shadow(0 0 2em #61dafb); + filter: drop-shadow(0 0 2em #61dafb); } :root { - font-family: Inter, Avenir, Helvetica, Arial, sans-serif; - font-size: 16px; - line-height: 24px; - font-weight: 400; + font-family: Inter, Avenir, Helvetica, Arial, sans-serif; + font-size: 16px; + line-height: 24px; + font-weight: 400; - color: #0f0f0f; - background-color: #f6f6f6; + color: #0f0f0f; + background-color: #f6f6f6; - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - -webkit-text-size-adjust: 100%; + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + -webkit-text-size-adjust: 100%; } .container { - margin: 0; - padding-top: 10vh; - display: flex; - flex-direction: column; - justify-content: center; + margin: 0; + padding-top: 10vh; + display: flex; + flex-direction: column; + justify-content: center; } .logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: 0.75s; + height: 6em; + padding: 1.5em; + will-change: filter; + transition: 0.75s; } .logo.tauri:hover { - filter: drop-shadow(0 0 2em #24c8db); + filter: drop-shadow(0 0 2em #24c8db); } .row { - display: flex; - justify-content: center; + display: flex; + justify-content: center; } a { - font-weight: 500; - color: #646cff; - text-decoration: inherit; + font-weight: 500; + color: #646cff; + text-decoration: inherit; } a:hover { - color: #535bf2; + color: #535bf2; } h1 { - text-align: center; + text-align: center; } input, button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - color: #0f0f0f; - background-color: #ffffff; - transition: border-color 0.25s; - box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2); + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + color: #0f0f0f; + background-color: #ffffff; + transition: border-color 0.25s; + box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2); } button { - cursor: pointer; + cursor: pointer; } button:hover { - border-color: #396cd8; + border-color: #396cd8; } button:active { - border-color: #396cd8; - background-color: #e8e8e8; + border-color: #396cd8; + background-color: #e8e8e8; } input, button { - outline: none; + outline: none; } #greet-input { - margin-right: 5px; + margin-right: 5px; } @media (prefers-color-scheme: dark) { - :root { - color: #f6f6f6; - background-color: #2f2f2f; - } + :root { + color: #f6f6f6; + background-color: #2f2f2f; + } - a:hover { - color: #24c8db; - } + a:hover { + color: #24c8db; + } - input, - button { - color: #ffffff; - background-color: #0f0f0f98; - } - button:active { - background-color: #0f0f0f69; - } + input, + button { + color: #ffffff; + background-color: #0f0f0f98; + } + button:active { + background-color: #0f0f0f69; + } } /* Collapsible tool output styling */ details summary { - cursor: pointer; - user-select: none; + cursor: pointer; + user-select: none; } details summary::-webkit-details-marker { - display: none; + display: none; } details[open] summary span:first-child { - transform: rotate(90deg); - display: inline-block; - transition: transform 0.2s ease; + transform: rotate(90deg); + display: inline-block; + transition: transform 0.2s ease; } details summary span:first-child { - transition: transform 0.2s ease; + transition: transform 0.2s ease; } /* Markdown body styling for dark theme */ .markdown-body { - color: #ececec; - text-align: left; + color: #ececec; + text-align: left; } .markdown-body code { - background: #2f2f2f; - padding: 2px 6px; - border-radius: 3px; - font-family: monospace; + background: #2f2f2f; + padding: 2px 6px; + border-radius: 3px; + font-family: monospace; } .markdown-body pre { - background: #1a1a1a; - padding: 12px; - border-radius: 6px; - overflow-x: auto; - text-align: left; + background: #1a1a1a; + padding: 12px; + border-radius: 6px; + overflow-x: auto; + text-align: left; } .markdown-body pre code { - background: transparent; - padding: 0; + background: transparent; + padding: 0; } /* Syntax highlighter styling */ .markdown-body div[class*="language-"] { - margin: 0; - border-radius: 6px; - text-align: left; + margin: 0; + border-radius: 6px; + text-align: left; } .markdown-body pre[class*="language-"] { - margin: 0; - padding: 12px; - background: #1a1a1a; - text-align: left; + margin: 0; + padding: 12px; + background: #1a1a1a; + text-align: left; } /* Hide scroll bars globally while maintaining scroll functionality */ /* Firefox */ * { - scrollbar-width: none; + scrollbar-width: none; } /* Chrome, Safari, Edge */ *::-webkit-scrollbar { - display: none; + display: none; } /* Ensure scroll functionality is maintained */ body, html { - overflow-x: hidden; + overflow-x: hidden; } diff --git a/src/App.test.tsx b/src/App.test.tsx deleted file mode 100644 index 95c10cc..0000000 --- a/src/App.test.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { render, screen, fireEvent } from "@testing-library/react"; -import App from "./App"; - -// Since the App component relies on Tauri APIs, we mock them to isolate tests -jest.mock("@tauri-apps/api/core", () => ({ - invoke: jest.fn().mockResolvedValue(null), -})); - -jest.mock("@tauri-apps/plugin-dialog", () => ({ - open: jest.fn().mockResolvedValue("/tmp/project"), -})); - -test("renders without crashing", () => { - render(); - expect(screen.getByText("AI Code Assistant")).toBeInTheDocument(); -}); - -it("opens project directory button calls open", async () => { - const { open } = require("@tauri-apps/plugin-dialog"); - render(); - const button = screen.getByText("Open Project Directory"); - fireEvent.click(button); - await Promise.resolve(); // wait for async open - expect(open).toHaveBeenCalled(); -}); diff --git a/src/components/Chat.tsx b/src/components/Chat.tsx index b849047..bf96853 100644 --- a/src/components/Chat.tsx +++ b/src/components/Chat.tsx @@ -1,525 +1,583 @@ -import { useState, useRef, useEffect } from "react"; import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; +import { useEffect, useRef, useState } from "react"; import Markdown from "react-markdown"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism"; -import { Message, ProviderConfig } from "../types"; +import type { Message, ProviderConfig } from "../types"; interface ChatProps { - projectPath: string; - onCloseProject: () => void; + projectPath: string; + onCloseProject: () => void; } export function Chat({ projectPath, onCloseProject }: ChatProps) { - const [messages, setMessages] = useState([]); - const [input, setInput] = useState(""); - const [loading, setLoading] = useState(false); - const [model, setModel] = useState("llama3.1"); // Default local model - const [enableTools, setEnableTools] = useState(true); - const [availableModels, setAvailableModels] = useState([]); - const messagesEndRef = useRef(null); - const inputRef = useRef(null); - const lastMessageCountRef = useRef(0); + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(""); + const [loading, setLoading] = useState(false); + const [model, setModel] = useState("llama3.1"); // Default local model + const [enableTools, setEnableTools] = useState(true); + const [availableModels, setAvailableModels] = useState([]); + const [streamingContent, setStreamingContent] = useState(""); + const messagesEndRef = useRef(null); + const inputRef = useRef(null); - useEffect(() => { - invoke("get_ollama_models") - .then(async (models) => { - if (models.length > 0) { - setAvailableModels(models); + useEffect(() => { + invoke("get_ollama_models") + .then(async (models) => { + if (models.length > 0) { + setAvailableModels(models); - // Check backend store for saved model - try { - const savedModel = await invoke( - "get_model_preference", - ); - if (savedModel && models.includes(savedModel)) { - setModel(savedModel); - } else if (!models.includes(model)) { - setModel(models[0]); - } - } catch (e) { - console.error(e); - } - } - }) - .catch((err) => console.error(err)); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + // Check backend store for saved model + try { + const savedModel = await invoke( + "get_model_preference", + ); + if (savedModel && models.includes(savedModel)) { + setModel(savedModel); + } else if (!models.includes(model)) { + setModel(models[0]); + } + } catch (e) { + console.error(e); + } + } + }) + .catch((err) => console.error(err)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [model]); - useEffect(() => { - const unlistenPromise = listen("chat:update", (event) => { - setMessages(event.payload); - }); + useEffect(() => { + const unlistenUpdatePromise = listen("chat:update", (event) => { + setMessages(event.payload); + setStreamingContent(""); // Clear streaming content when final update arrives + }); - return () => { - unlistenPromise.then((unlisten) => unlisten()); - }; - }, []); + const unlistenTokenPromise = listen("chat:token", (event) => { + setStreamingContent((prev) => prev + event.payload); + }); - const scrollToBottom = () => { - messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); - }; + return () => { + unlistenUpdatePromise.then((unlisten) => unlisten()); + unlistenTokenPromise.then((unlisten) => unlisten()); + }; + }, []); - useEffect(scrollToBottom, [messages]); + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }; - useEffect(() => { - inputRef.current?.focus(); - }, []); + useEffect(scrollToBottom, []); - const sendMessage = async () => { - if (!input.trim() || loading) return; + useEffect(() => { + inputRef.current?.focus(); + }, []); - const userMsg: Message = { role: "user", content: input }; - const newHistory = [...messages, userMsg]; + const sendMessage = async () => { + if (!input.trim() || loading) return; - setMessages(newHistory); - setInput(""); - setLoading(true); - lastMessageCountRef.current = newHistory.length; // Track message count when request starts + const userMsg: Message = { role: "user", content: input }; + const newHistory = [...messages, userMsg]; - try { - const config: ProviderConfig = { - provider: "ollama", - model: model, - base_url: "http://localhost:11434", - enable_tools: enableTools, - }; + setMessages(newHistory); + setInput(""); + setLoading(true); + setStreamingContent(""); // Clear any previous streaming content - // Invoke backend chat command - // We rely on 'chat:update' events to update the state in real-time - await invoke("chat", { - messages: newHistory, - config: config, - }); - } catch (e) { - console.error(e); - setMessages((prev) => [ - ...prev, - { role: "assistant", content: `**Error:** ${e}` }, - ]); - } finally { - setLoading(false); - } - }; + try { + const config: ProviderConfig = { + provider: "ollama", + model: model, + base_url: "http://localhost:11434", + enable_tools: enableTools, + }; - return ( -
- {/* Sticky Header */} -
- {/* Project Info */} -
-
- {projectPath} -
- -
+ // Invoke backend chat command + // We rely on 'chat:update' events to update the state in real-time + await invoke("chat", { + messages: newHistory, + config: config, + }); + } catch (e) { + console.error(e); + setMessages((prev) => [ + ...prev, + { role: "assistant", content: `**Error:** ${e}` }, + ]); + } finally { + setLoading(false); + } + }; - {/* Model Controls */} -
- {availableModels.length > 0 ? ( - - ) : ( - { - const newModel = e.target.value; - setModel(newModel); - invoke("set_model_preference", { model: newModel }).catch( - console.error, - ); - }} - placeholder="Model" - style={{ - padding: "6px 12px", - borderRadius: "99px", - border: "none", - fontSize: "0.9em", - background: "#2f2f2f", - color: "#ececec", - outline: "none", - }} - /> - )} - -
-
+ return ( +
+ {/* Sticky Header */} +
+ {/* Project Info */} +
+
+ {projectPath} +
+ +
- {/* Messages Area */} -
-
- {messages.map((msg, idx) => ( -
-
- {msg.role === "user" ? ( - msg.content - ) : msg.role === "tool" ? ( -
- - - - Tool Output - {msg.tool_call_id && ` (${msg.tool_call_id})`} - - -
-                      {msg.content}
-                    
-
- ) : ( -
- { - const match = /language-(\w+)/.exec(className || ""); - const isInline = !className; - return !isInline && match ? ( - - {String(children).replace(/\n$/, "")} - - ) : ( - - {children} - - ); - }, - }} - > - {msg.content} - -
- )} + {/* Model Controls */} +
+ {availableModels.length > 0 ? ( + + ) : ( + { + const newModel = e.target.value; + setModel(newModel); + invoke("set_model_preference", { model: newModel }).catch( + console.error, + ); + }} + placeholder="Model" + style={{ + padding: "6px 12px", + borderRadius: "99px", + border: "none", + fontSize: "0.9em", + background: "#2f2f2f", + color: "#ececec", + outline: "none", + }} + /> + )} + +
+
- {/* Show Tool Calls if present */} - {msg.tool_calls && ( -
- {msg.tool_calls.map((tc, i) => { - // Parse arguments to extract key info - let argsSummary = ""; - try { - const args = JSON.parse(tc.function.arguments); - const firstKey = Object.keys(args)[0]; - if (firstKey && args[firstKey]) { - argsSummary = String(args[firstKey]); - // Truncate if too long - if (argsSummary.length > 50) { - argsSummary = argsSummary.substring(0, 47) + "..."; - } - } - } catch (e) { - // If parsing fails, just show empty - } + {/* Messages Area */} +
+
+ {messages.map((msg, idx) => ( +
+
+ {msg.role === "user" ? ( + msg.content + ) : msg.role === "tool" ? ( +
+ + + + Tool Output + {msg.tool_call_id && ` (${msg.tool_call_id})`} + + +
+											{msg.content}
+										
+
+ ) : ( +
+ { + const match = /language-(\w+)/.exec(className || ""); + const isInline = !className; + return !isInline && match ? ( + + {String(children).replace(/\n$/, "")} + + ) : ( + + {children} + + ); + }, + }} + > + {msg.content} + +
+ )} - return ( -
- - - {tc.function.name} - {argsSummary && `(${argsSummary})`} - -
- ); - })} -
- )} -
-
- ))} - {loading && ( -
- Thinking... -
- )} -
-
-
+ {/* Show Tool Calls if present */} + {msg.tool_calls && ( +
+ {msg.tool_calls.map((tc, i) => { + // Parse arguments to extract key info + let argsSummary = ""; + try { + const args = JSON.parse(tc.function.arguments); + const firstKey = Object.keys(args)[0]; + if (firstKey && args[firstKey]) { + argsSummary = String(args[firstKey]); + // Truncate if too long + if (argsSummary.length > 50) { + argsSummary = `${argsSummary.substring(0, 47)}...`; + } + } + } catch (_e) { + // If parsing fails, just show empty + } - {/* Input Area */} -
-
- { - const newValue = e.target.value; - setInput(newValue); + return ( +
+ + + {tc.function.name} + {argsSummary && `(${argsSummary})`} + +
+ ); + })} +
+ )} +
+
+ ))} + {loading && streamingContent && ( +
+
+ { + const match = /language-(\w+)/.exec(className || ""); + const isInline = !className; + return !isInline && match ? ( + + {String(children).replace(/\n$/, "")} + + ) : ( + + {children} + + ); + }, + }} + > + {streamingContent} + +
+
+ )} + {loading && !streamingContent && ( +
+ Thinking... +
+ )} +
+
+
- // If user starts typing while model is generating, cancel backend request - if (loading && newValue.length > input.length) { - setLoading(false); - invoke("cancel_chat").catch((e) => - console.error("Cancel failed:", e), - ); - // Remove the interrupted message from history - setMessages((prev) => - prev.slice(0, lastMessageCountRef.current - 1), - ); - } - }} - onKeyDown={(e) => e.key === "Enter" && sendMessage()} - placeholder="Send a message..." - style={{ - width: "100%", - padding: "14px 20px", - paddingRight: "50px", // space for button - borderRadius: "24px", - border: "1px solid #333", - outline: "none", - fontSize: "1rem", - fontWeight: "500", - background: "#2f2f2f", - color: "#ececec", - boxShadow: "0 2px 6px rgba(0,0,0,0.02)", - }} - /> - -
-
-
- ); + {/* Input Area */} +
+
+ setInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && sendMessage()} + placeholder="Send a message..." + style={{ + width: "100%", + padding: "14px 20px", + paddingRight: "50px", // space for button + borderRadius: "24px", + border: "1px solid #333", + outline: "none", + fontSize: "1rem", + fontWeight: "500", + background: "#2f2f2f", + color: "#ececec", + boxShadow: "0 2px 6px rgba(0,0,0,0.02)", + }} + /> + +
+
+
+ ); } diff --git a/src/main.tsx b/src/main.tsx index 2be325e..c08eb09 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3,7 +3,7 @@ import ReactDOM from "react-dom/client"; import App from "./App"; ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - - - , + + + , ); diff --git a/src/types.ts b/src/types.ts index 7619066..bc25a06 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,40 +1,40 @@ export interface FileEntry { - name: string; - kind: "file" | "dir"; + name: string; + kind: "file" | "dir"; } export interface SearchResult { - path: string; - matches: number; + path: string; + matches: number; } export interface CommandOutput { - stdout: string; - stderr: string; - exit_code: number; + stdout: string; + stderr: string; + exit_code: number; } export type Role = "system" | "user" | "assistant" | "tool"; export interface ToolCall { - id?: string; - type: string; - function: { - name: string; - arguments: string; - }; + id?: string; + type: string; + function: { + name: string; + arguments: string; + }; } export interface Message { - role: Role; - content: string; - tool_calls?: ToolCall[]; - tool_call_id?: string; + role: Role; + content: string; + tool_calls?: ToolCall[]; + tool_call_id?: string; } export interface ProviderConfig { - provider: string; - model: string; - base_url?: string; - enable_tools?: boolean; + provider: string; + model: string; + base_url?: string; + enable_tools?: boolean; }