diff --git a/OZWELL_SETUP.md b/OZWELL_SETUP.md new file mode 100644 index 0000000..cdab373 --- /dev/null +++ b/OZWELL_SETUP.md @@ -0,0 +1,253 @@ +# Ozwell AI Assistant Integration + +This guide explains how to integrate the Ozwell AI chat widget into TimeHarbor. + +--- + +## Prerequisites + +You need an Ozwell endpoint URL. This can be either: + +**Option 1: Local Development** +- Ozwell reference server running at `http://localhost:3000` +- (See Ozwell reference server repo for setup instructions) + +**Option 2: Hosted Ozwell (Recommended)** +- Production URL: `https://ozwell.timeharbor.com` *(URL coming soon)* +- Contact your team admin for the endpoint URL + +--- + +## Quick Start + +**Time required:** ~2 minutes + +1. **Configure the endpoint** in `/public/ozwell-config.js`: + ```javascript + window.OzwellChatConfig = { + widgetUrl: 'https://ozwell.timeharbor.com/embed/ozwell.html', // Or http://localhost:3000/embed/ozwell.html + endpoint: 'https://ozwell.timeharbor.com/embed/chat', // Or http://localhost:3000/embed/chat + model: 'llama3.1:8b', + // ... rest of config + }; + ``` + +2. **Start TimeHarbor**: + ```bash + meteor --port 3001 + ``` + +3. **Test the widget**: + - Login to TimeHarbor + - Look for chat button (bottom-right corner) + - Click to open chat + - Try: `suggest some ticket titles` + +--- + +## Architecture + +```mermaid +graph LR + A[TimeHarbor
localhost:3001] -->|Script Tag| B[Ozwell Widget
ozwell-loader.js] + B -->|HTTP POST| C[Ozwell Server
localhost:3000 or hosted] + C -->|API Call| D[LLM
Ollama/OpenAI] + D -->|Response| C + C -->|Streaming| B + B -->|MCP Tool Calls| A + A -->|Tool Results| B +``` + +**Key Points:** +- TimeHarbor loads Ozwell via script tag (iframe-sync bundled, no separate import needed) +- MCP tools run in TimeHarbor, controlled by postMessage +- All AI processing happens on Ozwell server (local or hosted) + +--- + +## Available MCP Tools + +The AI has access to these tools when chatting: + +| Tool | Purpose | Example Usage | +|------|---------|---------------| +| `get_project_history` | Retrieves recent tickets from current project | "suggest some ticket titles" | +| `get_project_time_stats` | Calculates total time spent on project | "how much time have I spent?" | +| `get_current_ticket_form` | Reads current form field values | "what's filled in right now?" | +| `update_ticket_title` | Auto-fills the title field | "set title to Fix login bug" | +| `update_ticket_description` | Auto-fills description field | "add description: Fixed auth issue" | +| `update_ticket_time` | Sets hours/minutes/seconds | "set time to 2 hours 30 minutes" | +| `get_conversation_history` | Retrieves past conversations | *(currently unused)* | + +**How it works:** +1. User asks a question +2. AI decides which tool(s) to call +3. Widget sends tool call to TimeHarbor via postMessage +4. TimeHarbor executes tool, returns result +5. AI uses result to respond + +--- + +## Configuration + +### System Prompt + +Edit `/public/ozwell-config.js` to customize AI behavior: + +```javascript +window.OzwellChatConfig = { + system: `Your custom instructions here...`, + // ... +}; +``` + +The system prompt defines: +- How AI should use tools (READ vs UPDATE) +- When to call get_project_history +- Context reset rules +- Tool usage examples + +### Widget Appearance + +```javascript +window.OzwellChatConfig = { + welcomeMessage: 'Hi! I can help you track time...', + title: 'TimeHarbor Assistant', + placeholder: 'Ask about your tickets...', + // ... +}; +``` + +### Model Selection + +```javascript +window.OzwellChatConfig = { + model: 'llama3.1:8b', // Must be available on Ozwell server + // ... +}; +``` + +--- + +## Optional Features + +### Real-Time Form Sync + +**Status:** Currently disabled (works fine without it) + +Syncs form changes to AI in real-time for proactive suggestions. + +**To enable:** Uncomment code block in `/public/ozwell-mcp-tools.js` (~line 550) + +**Uses:** `OzwellChat.updateContext()` API (bundled in ozwell-loader.js) + +**Example:** AI says "I see you're working on auth issues, want related tickets?" as you type + +--- + +## Adding Custom Tools + +Want to add custom functionality? Here's how: + +**1. Define tool in `/public/ozwell-mcp-tools.js`:** +```javascript +{ + type: 'function', + function: { + name: 'get_ticket_count', + description: 'Counts total tickets in current project', + parameters: { + type: 'object', + properties: {}, + required: [] + } + } +} +``` + +**2. Add handler in same file:** +```javascript +const toolHandlers = { + get_ticket_count: async (params) => { + const teamId = document.querySelector('select[name="team"]')?.value; + + const count = await new Promise((resolve, reject) => { + Meteor.call('getTicketCount', { teamId }, (error, result) => { + if (error) reject(error); + else resolve(result); + }); + }); + + return { success: true, count }; + } +}; +``` + +**3. Add server method in `/server/methods/ozwell.js`:** +```javascript +async getTicketCount({ teamId }) { + check(teamId, String); + if (!this.userId) throw new Meteor.Error('not-authorized'); + + return await Tickets.find({ teamId }).countAsync(); +} +``` + +That's it! The AI can now use your custom tool. + +--- + +## File Reference + +### Configuration +- `/public/ozwell-config.js` - Widget config (system prompt, model, endpoints) + +### Client-Side +- `/public/chat-wrapper.js` - Widget UI and drag behavior +- `/public/chat-wrapper.css` - Widget styles +- `/public/ozwell-mcp-tools.js` - MCP tool definitions and handlers +- `/client/main.html` - Loads Ozwell scripts + +### Server-Side +- `/server/methods/ozwell.js` - Meteor server methods for MCP tools +- `/server/main.js` - Registers ozwellMethods + +### External (Ozwell Server) +- `http://localhost:3000/embed/ozwell-loader.js` - Main widget loader +- `http://localhost:3000/embed/ozwell.html` - Widget iframe + +--- + +## Troubleshooting + +**Chat widget not appearing?** +- Check Ozwell server is running: `curl http://localhost:3000` (for local) +- Verify browser console for errors (F12 → Console) +- Check `/public/ozwell-config.js` has correct endpoint URLs + +**AI not calling tools?** +- Open browser console and look for tool call logs: `[MCP Tools] Tool call requested` +- Verify system prompt is loaded: `window.OzwellChatConfig.system` +- Check Ozwell server logs for errors + +**Tools returning errors?** +- Check browser console for specific error messages +- Verify Meteor methods exist in `/server/methods/ozwell.js` +- Test Meteor method directly in browser console: `Meteor.call('getProjectHistory', ...)` + +**Widget styling broken?** +- Verify `/public/chat-wrapper.css` loaded (check Network tab) +- Check for CSS conflicts with TimeHarbor styles + +--- + +## Next Steps + +After setup works: +1. Try different queries to see tool capabilities +2. Check browser console to see which tools get called +3. Customize system prompt in `/public/ozwell-config.js` if needed +4. Add your own tools using the example above +5. (Optional) Enable real-time form sync for proactive suggestions + +For issues or questions, create an issue in the TimeHarbor repository. diff --git a/README.md b/README.md index e7fba3d..4d2c1ce 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,9 @@ Quick start: [click here](./HOW_TO_USE.md) 4. **Access the app:** Open your browser and navigate to `http://localhost:3000` +5. **Optional: Configure Ozwell AI assistant** + - If you plan to use the Ozwell writing assistant, follow the [Ozwell configuration](#configuring-ozwell-ai-assistant) steps to add your API key + --- ## Usage @@ -121,6 +124,33 @@ Quick start: [click here](./HOW_TO_USE.md) - **Control Your Privacy:** All data stays private unless you choose to share specific summaries or reports - **Generate Reports:** Create summaries to share with mentors, teachers, or supervisors when beneficial +### Configuring Ozwell AI Assistant + +Ozwell provides AI-assisted writing for tickets and reflections. Setup is optional, but required to use the Ozwell modal or Settings automation. + +1. **Obtain credentials** + - Create an account at [BlueHive AI developers](https://ai.bluehive.com/developers) and generate an Ozwell API key (`BHSK-...`). + - If you plan to use Google or GitHub login, add the following to a `.env` file at the project root: + ``` + GOOGLE_CLIENT_ID=your-google-client-id + GOOGLE_CLIENT_SECRET=your-google-client-secret + GITHUB_CLIENT_ID=your-github-client-id + GITHUB_CLIENT_SECRET=your-github-client-secret + ``` + (These are optional for Ozwell itself—they only power OAuth login.) + +2. **Start a reference server (optional)** + If you have an Ozwell-compatible reference server running locally, note its base URL (defaults to `http://localhost:3000/v1`). This is only needed if you want the Ozwell modal to use a local inference endpoint. + +3. **Configure inside TimeHarbor** + - Sign in and navigate to **Settings** (`/settings`). + - In the “Ozwell AI Writing Assistant” card, click **Configure Ozwell**. + - Fill in: + - **API Key** – the BlueHive key you generated (required). + - **Reference Server Base URL** – defaults to `http://localhost:3000/v1`; use your MCP server URL if different. + - **Model** – defaults to `llama3`. + - Submit to test and save. On success, Ozwell is enabled for your account. You can re-test or disable it from the same page. + --- ## Privacy & Sharing diff --git a/client/main.html b/client/main.html index e62de81..2bd0f3d 100644 --- a/client/main.html +++ b/client/main.html @@ -4,8 +4,20 @@ + + + + + + {{> Template.dynamic template=currentScreen}} - \ No newline at end of file + + + + + + + diff --git a/package-lock.json b/package-lock.json index ecbcfd4..19a8157 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "dotenv": "^17.2.1", "jquery": "^3.6.0", "meteor-node-stubs": "^1.2.1", + "ozwellai": "^1.0.0", "postcss-load-config": "^6.0.1", "web-push": "^3.6.7" }, @@ -118,6 +119,10 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mieweb/ozwellai-spec": { + "resolved": "spec", + "link": true + }, "node_modules/@tailwindcss/node": { "version": "4.1.7", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.7.tgz", @@ -2684,6 +2689,18 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, + "node_modules/ozwellai": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ozwellai/-/ozwellai-1.0.0.tgz", + "integrity": "sha512-Uw80ORHjyfbWzNC3f5LfrsRIT9Uw+cjR7hC6gV0Q0MAZxeK3qdrYZ8KpZIhYEEvv4r1eOgd0dV3gAMYj2q4jcA==", + "license": "Apache-2.0", + "dependencies": { + "@mieweb/ozwellai-spec": "file:../../spec" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/parse5": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", @@ -2977,6 +2994,7 @@ "engines": { "node": ">=18" } - } + }, + "spec": {} } } diff --git a/package.json b/package.json index 2cb587b..7113260 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "dotenv": "^17.2.1", "jquery": "^3.6.0", "meteor-node-stubs": "^1.2.1", + "ozwellai": "^1.0.0", "postcss-load-config": "^6.0.1", "web-push": "^3.6.7" }, diff --git a/public/chat-wrapper.js b/public/chat-wrapper.js new file mode 100644 index 0000000..a58944d --- /dev/null +++ b/public/chat-wrapper.js @@ -0,0 +1,514 @@ +/** + * Ozwell Chat Widget - Draggable Wrapper + * Provides floating button and draggable chat window + */ + +// Inject widget styles +(function() { + const styles = ` +/* Ozwell Chat Widget - Floating Wrapper Styles */ + +/* Floating Chat Button */ +#ozwell-chat-button { + position: fixed; + bottom: 20px; + right: 20px; + width: 60px; + height: 60px; + border-radius: 50%; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + border: none; + cursor: pointer; + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); + display: flex; + align-items: center; + justify-content: center; + transition: transform 0.2s, box-shadow 0.2s; + z-index: 9998; + font-size: 28px; +} + +#ozwell-chat-button:hover { + transform: scale(1.1); + box-shadow: 0 6px 16px rgba(102, 126, 234, 0.6); +} + +#ozwell-chat-button.hidden { + display: none; +} + +/* Chat Container */ +#ozwell-chat-container { + position: fixed; + bottom: 100px; + right: 20px; + width: 400px; + height: 600px; + background: white; + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15); + display: none; + flex-direction: column; + z-index: 9999; + overflow: hidden; + transition: opacity 0.3s ease, box-shadow 0.3s ease; + transform: translate(0, 0); +} + +#ozwell-chat-container.open { + display: flex; +} + +#ozwell-chat-container.minimized { + height: 60px; + overflow: hidden; +} + +#ozwell-chat-container.dragging { + opacity: 0.9; + cursor: move; + user-select: none; + will-change: transform; + pointer-events: auto; + transition: none; +} + +#ozwell-chat-container.dragging * { + pointer-events: none; +} + +/* Chat Header */ +#ozwell-chat-header { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + padding: 16px; + display: flex; + align-items: center; + justify-content: space-between; + cursor: move; + user-select: none; +} + +#ozwell-chat-header h3 { + margin: 0; + font-size: 16px; + font-weight: 600; + flex: 1; +} + +#ozwell-chat-controls { + display: flex; + gap: 8px; +} + +#ozwell-chat-controls button { + background: rgba(255, 255, 255, 0.2); + border: none; + color: white; + width: 28px; + height: 28px; + border-radius: 4px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.2s; + font-size: 16px; +} + +#ozwell-chat-controls button:hover { + background: rgba(255, 255, 255, 0.3); +} + +/* Chat Content (iframe container) */ +#ozwell-chat-content { + flex: 1; + overflow: hidden; + background: white; +} + +#ozwell-chat-content iframe { + width: 100%; + height: 100%; + border: none; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + #ozwell-chat-container { + width: calc(100vw - 40px); + height: calc(100vh - 140px); + right: 20px; + bottom: 100px; + } + + #ozwell-chat-button { + bottom: 20px; + right: 20px; + } +} + +/* Animation for opening */ +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +#ozwell-chat-container.open { + animation: slideIn 0.3s ease; +} +`; + + const styleElement = document.createElement('style'); + styleElement.textContent = styles; + document.head.appendChild(styleElement); +})(); + +class ChatWrapper { + constructor() { + this.isOpen = false; + this.isMinimized = false; + this.isDragging = false; + this.dragOffset = { x: 0, y: 0 }; + this.currentPosition = { x: 0, y: 0 }; + this.rafId = null; + + this.init(); + } + + init() { + // Immediately hide any auto-created Ozwell iframes (they load before our wrapper) + this.hideAutoCreatedOzwellIframes(); + + // Only create widget if user is logged in (Meteor specific check) + if (typeof Meteor !== 'undefined' && !Meteor.userId()) { + // User not logged in, don't create widget + // Check again when user logs in + const checkLogin = setInterval(() => { + if (Meteor.userId()) { + clearInterval(checkLogin); + this.createElements(); + this.attachEventListeners(); + } + }, 500); + return; + } + + this.createElements(); + this.attachEventListeners(); + } + + hideAutoCreatedOzwellIframes() { + // Watch for Ozwell iframes being created and hide them temporarily + const hideIframeIfNotInContainer = (iframe) => { + // Only hide if it's not already in our container + if (!iframe.closest('#ozwell-chat-content')) { + iframe.style.visibility = 'hidden'; + iframe.style.position = 'fixed'; + iframe.style.left = '-9999px'; + iframe.style.top = '-9999px'; + } + }; + + // Check for existing iframes + const checkExisting = () => { + const iframes = document.querySelectorAll('iframe[src*="ozwell.html"]'); + iframes.forEach(hideIframeIfNotInContainer); + }; + + // Initial check + checkExisting(); + + // Watch for new iframes (but only run once per iframe) + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + mutation.addedNodes.forEach((node) => { + if (node.tagName === 'IFRAME' && node.src && node.src.includes('ozwell.html')) { + hideIframeIfNotInContainer(node); + } + }); + }); + }); + + observer.observe(document.body, { childList: true, subtree: true }); + this.iframeObserver = observer; + } + + createElements() { + // Create floating button + this.button = document.createElement('button'); + this.button.id = 'ozwell-chat-button'; + this.button.innerHTML = '💬'; + this.button.title = 'Open TimeHarbor Assistant'; + document.body.appendChild(this.button); + + // Create chat container + this.container = document.createElement('div'); + this.container.id = 'ozwell-chat-container'; + this.container.innerHTML = ` +
+

TimeHarbor Assistant

+
+ + +
+
+
+ +
+ `; + document.body.appendChild(this.container); + + // Store references + this.header = document.getElementById('ozwell-chat-header'); + this.content = document.getElementById('ozwell-chat-content'); + this.minimizeBtn = document.getElementById('ozwell-minimize-btn'); + this.closeBtn = document.getElementById('ozwell-close-btn'); + } + + attachEventListeners() { + // Button click - toggle open/close + this.button.addEventListener('click', () => this.toggle()); + + // Header drag functionality + this.header.addEventListener('mousedown', (e) => this.startDrag(e)); + document.addEventListener('mousemove', (e) => this.drag(e)); + document.addEventListener('mouseup', () => this.stopDrag()); + + // Control buttons + this.minimizeBtn.addEventListener('click', () => this.toggleMinimize()); + this.closeBtn.addEventListener('click', () => this.close()); + + // Prevent text selection while dragging + this.header.addEventListener('selectstart', (e) => e.preventDefault()); + + // Window resize - keep chat within viewport bounds (throttled) + let resizeTimeout; + window.addEventListener('resize', () => { + clearTimeout(resizeTimeout); + resizeTimeout = setTimeout(() => this.constrainToViewport(), 250); + }); + } + + toggle() { + if (this.isOpen) { + this.close(); + } else { + this.open(); + } + } + + open() { + this.isOpen = true; + this.container.classList.add('open'); + this.button.classList.add('hidden'); + + // Load Ozwell widget if configured + this.loadWidget(); + } + + close() { + this.isOpen = false; + this.isMinimized = false; + this.container.classList.remove('open', 'minimized'); + this.button.classList.remove('hidden'); + } + + toggleMinimize() { + this.isMinimized = !this.isMinimized; + if (this.isMinimized) { + this.container.classList.add('minimized'); + this.minimizeBtn.innerHTML = '□'; + this.minimizeBtn.title = 'Maximize'; + } else { + this.container.classList.remove('minimized'); + this.minimizeBtn.innerHTML = '−'; + this.minimizeBtn.title = 'Minimize'; + } + } + + startDrag(e) { + if (e.target.closest('button')) return; // Don't drag when clicking buttons + + this.isDragging = true; + this.container.classList.add('dragging'); + + // Cache container dimensions (they don't change during drag) + this.containerWidth = this.container.offsetWidth; + this.containerHeight = this.container.offsetHeight; + + const rect = this.container.getBoundingClientRect(); + this.dragOffset.x = e.clientX - rect.left; + this.dragOffset.y = e.clientY - rect.top; + + // Store current absolute position (where it actually is on screen) + this.currentPosition.x = rect.left; + this.currentPosition.y = rect.top; + + // Remove bottom/right positioning to prevent conflicts with transform + this.container.style.bottom = 'auto'; + this.container.style.right = 'auto'; + this.container.style.left = '0'; + this.container.style.top = '0'; + } + + drag(e) { + if (!this.isDragging) return; + + e.preventDefault(); + + // Just store the mouse position, don't update DOM yet + const newX = e.clientX - this.dragOffset.x; + const newY = e.clientY - this.dragOffset.y; + + // Keep within viewport bounds (use cached dimensions) + const maxX = window.innerWidth - this.containerWidth; + const maxY = window.innerHeight - this.containerHeight; + + this.currentPosition.x = Math.max(0, Math.min(newX, maxX)); + this.currentPosition.y = Math.max(0, Math.min(newY, maxY)); + + // Request animation frame for smooth updates + if (!this.rafId) { + this.rafId = requestAnimationFrame(() => this.updatePosition()); + } + } + + updatePosition() { + // Update DOM only once per frame using transform (GPU accelerated) + this.container.style.transform = `translate(${this.currentPosition.x}px, ${this.currentPosition.y}px)`; + this.rafId = null; + } + + stopDrag() { + if (this.isDragging) { + this.isDragging = false; + this.container.classList.remove('dragging'); + + // Cancel any pending animation frame + if (this.rafId) { + cancelAnimationFrame(this.rafId); + this.rafId = null; + } + } + } + + constrainToViewport() { + // Only constrain if chat is open + if (!this.isOpen) { + return; + } + + // Get current position + const rect = this.container.getBoundingClientRect(); + const currentLeft = rect.left; + const currentTop = rect.top; + + // Calculate max allowed positions + const maxX = window.innerWidth - this.container.offsetWidth; + const maxY = window.innerHeight - this.container.offsetHeight; + + // Clamp to viewport bounds + const newLeft = Math.max(0, Math.min(currentLeft, maxX)); + const newTop = Math.max(0, Math.min(currentTop, maxY)); + + // Only update if position changed + if (newLeft !== currentLeft || newTop !== currentTop) { + // Remove bottom/right positioning to prevent conflicts with transform + // (same approach as startDrag) + this.container.style.bottom = 'auto'; + this.container.style.right = 'auto'; + this.container.style.left = '0'; + this.container.style.top = '0'; + + this.currentPosition.x = newLeft; + this.currentPosition.y = newTop; + this.container.style.transform = `translate(${newLeft}px, ${newTop}px)`; + console.log(`[ChatWrapper] Position adjusted to stay in viewport: (${newLeft}, ${newTop})`); + } + } + + loadWidget() { + // Check if Ozwell widget is configured + if (!window.OzwellChatConfig) { + console.warn('OzwellChatConfig not found. Widget will not load.'); + this.content.innerHTML = '
Chat widget configuration missing.
'; + return; + } + + // Check if widget already loaded + if (this.content.querySelector('iframe')) { + return; + } + + // Wait for Ozwell embed script to load, then mount the widget + const waitForOzwell = setInterval(() => { + if (window.OzwellChat && typeof window.OzwellChat.mount === 'function') { + clearInterval(waitForOzwell); + + // Mount the widget iframe (new loading pattern) + console.log('[TimeHarbor] Mounting Ozwell widget...'); + window.OzwellChat.mount(); + + // Now wait for the iframe to be created + const waitForIframe = setInterval(() => { + if (window.OzwellChat.iframe) { + clearInterval(waitForIframe); + + // Get the Ozwell iframe + const ozwellIframe = window.OzwellChat.iframe; + + // Remove from its current location + if (ozwellIframe.parentElement && ozwellIframe.parentElement !== this.content) { + ozwellIframe.remove(); + } + + // Move Ozwell iframe into our container + this.content.appendChild(ozwellIframe); + + // IMPORTANT: Reset all styles to make it visible in our container + ozwellIframe.style.width = '100%'; + ozwellIframe.style.height = '100%'; + ozwellIframe.style.border = 'none'; + ozwellIframe.style.display = 'block'; + ozwellIframe.style.visibility = 'visible'; + ozwellIframe.style.position = 'relative'; + ozwellIframe.style.left = '0'; + ozwellIframe.style.top = '0'; + + console.log('Ozwell widget loaded successfully'); + } + }, 100); + + // Timeout for iframe creation + setTimeout(() => { + clearInterval(waitForIframe); + }, 5000); + } + }, 100); + + // Timeout after 5 seconds + setTimeout(() => { + clearInterval(waitForOzwell); + if (!this.content.querySelector('iframe')) { + console.error('Ozwell widget failed to load'); + this.content.innerHTML = '
Failed to load chat widget. Please refresh.
'; + } + }, 5000); + } +} + +// Auto-initialize when DOM is ready +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { + window.chatWrapper = new ChatWrapper(); + }); +} else { + window.chatWrapper = new ChatWrapper(); +} diff --git a/public/ozwell-config.js b/public/ozwell-config.js new file mode 100644 index 0000000..24e34e9 --- /dev/null +++ b/public/ozwell-config.js @@ -0,0 +1,102 @@ +/** + * ============================================ + * OZWELL CHAT WIDGET CONFIGURATION + * ============================================ + * + * This file configures the Ozwell AI chat widget for TimeHarbor. + * It defines the system prompt, model settings, and widget appearance. + * + * WHAT THIS DOES: + * - Sets up the AI's behavior and tool usage rules + * - Configures which LLM model to use + * - Customizes widget appearance (title, placeholder, welcome message) + * + * MODIFYING THE SYSTEM PROMPT: + * - The system prompt teaches the AI how to use MCP tools correctly + * - Be careful when modifying - test thoroughly after changes + * - The AI needs clear rules to distinguish READ vs UPDATE tools + * + * CONFIGURATION OPTIONS: + * - widgetUrl: URL to the Ozwell widget iframe + * - endpoint: Chat API endpoint for LLM requests + * - model: LLM model name (must be available in Ollama) + * - system: System prompt (AI instructions) + * - welcomeMessage: First message shown to user + * - title: Widget header title + * - placeholder: Input field placeholder text + */ + +window.OzwellChatConfig = { + widgetUrl: 'http://localhost:3000/embed/ozwell.html', + endpoint: 'http://localhost:3000/v1/chat/completions', + headers: { 'Authorization': 'Bearer ollama' }, + welcomeMessage: 'Hi! I can help you track time, suggest tickets, and fill forms. Just ask!', + title: 'TimeHarbor Assistant', + placeholder: 'Ask about your tickets...', + model: 'llama3.1:8b', + autoMount: false, // Prevent auto-mounting - chat-wrapper.js controls when iframe appears + + /** + * SYSTEM PROMPT: AI Instructions for TimeHarbor + * + * This prompt teaches the AI how to use MCP tools correctly. + * It emphasizes the difference between READ tools (getting info) + * and UPDATE tools (modifying data). + */ + system: `You are a helpful assistant for TimeHarbor ticket tracking. + +CRITICAL TOOL USAGE RULES - READ CAREFULLY: + +You have TWO types of tools: + +A) READ TOOLS (for getting information): + - get_project_history: Gets recent tickets from this project + - get_current_ticket_form: Gets current form field values + - get_project_time_stats: Gets time statistics (total time, ticket count) + +B) UPDATE TOOLS (for modifying data): + - update_ticket_title: Updates the title field + - update_ticket_description: Updates the description field + +WHEN TO USE READ TOOLS: +✅ User asks: "suggest", "give examples", "what are some", "recommend", "show options" +✅ User checks: "what is the current...", "show me what's filled", "check..." +✅ User asks about time: "how much time", "total time", "time spent", "hours logged" +✅ ALWAYS call get_project_history FIRST when providing suggestions - this gives context about their past work +✅ Call get_current_ticket_form when they ask about current values +✅ Call get_project_time_stats when they ask about time tracking or hours spent + +WHEN TO USE UPDATE TOOLS: +✅ User says: "update", "change", "set", "modify", "fill in", "add to", "use this" +✅ User confirms: "yes", "ok do it", "proceed", "apply it" +❌ NEVER use UPDATE tools for suggestions or questions + +CONTEXT RESET RULE: +After providing suggestions, DO NOT assume the next message wants to apply them. +Each message is independent - check if it contains an action verb. + +EXAMPLES: + +Correct flow: +User: "suggest some titles" +You: [call get_project_history] → [provide context-aware suggestions based on history] + +User: "what time is set?" +You: [call get_current_ticket_form] → [show current values] + +User: "how much time have I spent?" +You: [call get_project_time_stats] → [show total time and ticket count] + +User: "ok use option 3" +You: [call update_ticket_title] ✓ + +Common mistakes to AVOID: +❌ User asks for suggestions → You give generic examples without calling get_project_history (WRONG) +❌ User says "examples?" → You call update tool (WRONG - that's a READ operation) +❌ You just gave suggestions → Next message auto-updates (WRONG - reset context!) + +Remember: +- Suggestions/Questions = READ TOOLS first, then respond +- Updates/Actions = UPDATE TOOLS +- Always get context from get_project_history before suggesting titles/descriptions` +}; diff --git a/public/ozwell-mcp-tools.js b/public/ozwell-mcp-tools.js new file mode 100644 index 0000000..b43ae83 --- /dev/null +++ b/public/ozwell-mcp-tools.js @@ -0,0 +1,359 @@ +/** + * Ozwell MCP Tools for TimeHarbor + * Provides tools for the AI assistant to interact with ticket forms and retrieve history + */ + +// MCP Tools Definitions (OpenAI function calling format) +const mcpTools = [ + { + type: 'function', + function: { + name: 'get_current_ticket_form', + description: 'Retrieves the current values from all ticket form fields (title, description, team). Use this when the user asks what is currently filled in or to check current values.', + parameters: { + type: 'object', + properties: {}, + required: [] + } + } + }, + { + type: 'function', + function: { + name: 'update_ticket_title', + description: 'Updates the title field of the current ticket form', + parameters: { + type: 'object', + properties: { + title: { + type: 'string', + description: 'The new title for the ticket' + } + }, + required: ['title'] + } + } + }, + { + type: 'function', + function: { + name: 'update_ticket_description', + description: 'Updates the description/reference notes field of the current ticket form', + parameters: { + type: 'object', + properties: { + description: { + type: 'string', + description: 'The new description/reference notes for the ticket' + } + }, + required: ['description'] + } + } + }, + { + type: 'function', + function: { + name: 'get_project_history', + description: 'Retrieves recent tickets for the current project/team to provide context about past work', + parameters: { + type: 'object', + properties: { + days: { + type: 'number', + description: 'Number of days to look back (default: 7)', + default: 7 + }, + limit: { + type: 'number', + description: 'Maximum number of tickets to retrieve (default: 20)', + default: 20 + } + }, + required: [] + } + } + }, + { + type: 'function', + function: { + name: 'get_project_time_stats', + description: 'Gets time statistics for the current project (total time spent, number of tickets). Use this when the user asks about time spent, hours logged, or time tracking.', + parameters: { + type: 'object', + properties: { + days: { + type: 'number', + description: 'Number of days to look back (default: 30)', + default: 30 + } + }, + required: [] + } + } + } +]; + +// Add tools to OzwellChatConfig (widget reads from here) +if (window.OzwellChatConfig) { + window.OzwellChatConfig.tools = mcpTools; + console.log('[MCP Tools] Added', mcpTools.length, 'tools to OzwellChatConfig:', mcpTools.map(t => t.function.name).join(', ')); +} else { + console.error('[MCP Tools] window.OzwellChatConfig not found! Tools will not be available.'); +} + +// Tool Handler Functions +const toolHandlers = { + get_current_ticket_form: async (params) => { + const titleInput = document.querySelector('[name="title"]'); + const descInput = document.querySelector('[name="github"]'); + const teamSelect = document.querySelector('#teamSelect'); + + const result = { + success: true, + data: { + title: titleInput?.value || '', + description: descInput?.value || '', + team: teamSelect?.value || '', + teamName: teamSelect?.selectedOptions[0]?.text || 'No team selected' + }, + message: 'Retrieved current ticket form values' + }; + + console.log('[MCP Tools] get_current_ticket_form result:', result); + return result; + }, + + update_ticket_title: async (params) => { + const titleInput = document.querySelector('[name="title"]'); + if (!titleInput) { + return { success: false, error: 'Title input field not found' }; + } + + titleInput.value = params.title; + + // Trigger change event for any listeners + titleInput.dispatchEvent(new Event('input', { bubbles: true })); + titleInput.dispatchEvent(new Event('change', { bubbles: true })); + + // Sync state with widget + if (window.ozwellStateSync) { + window.ozwellStateSync.syncCurrentState(); + } + + return { + success: true, + message: `Title updated to: ${params.title}` + }; + }, + + update_ticket_description: async (params) => { + const descInput = document.querySelector('[name="github"]'); + if (!descInput) { + return { success: false, error: 'Description input field not found' }; + } + + descInput.value = params.description; + + // Trigger change event for any listeners + descInput.dispatchEvent(new Event('input', { bubbles: true })); + descInput.dispatchEvent(new Event('change', { bubbles: true })); + + // Sync state with widget + if (window.ozwellStateSync) { + window.ozwellStateSync.syncCurrentState(); + } + + return { + success: true, + message: `Description updated` + }; + }, + + get_project_history: async (params) => { + // Get current team ID from the page + const teamSelect = document.querySelector('#teamSelect'); + if (!teamSelect || !teamSelect.value) { + return { + success: false, + error: 'No team selected. Please select a team first.' + }; + } + + const teamId = teamSelect.value; + // Convert to numbers (model might send as strings) + const days = params.days ? Number(params.days) : 30; + const limit = params.limit ? Number(params.limit) : 20; + + try { + // Call Meteor method to get project history + const history = await new Promise((resolve, reject) => { + Meteor.call('getRecentProjectTickets', { teamId, days, limit }, (error, result) => { + if (error) reject(error); + else resolve(result); + }); + }); + + return { + success: true, + tickets: history, + message: `Retrieved ${history.length} recent tickets from the last ${days} days` + }; + } catch (error) { + return { + success: false, + error: `Failed to retrieve project history: ${error.message}` + }; + } + }, + + get_project_time_stats: async (params) => { + // Get current team ID from the page + const teamSelect = document.querySelector('#teamSelect'); + if (!teamSelect || !teamSelect.value) { + return { + success: false, + error: 'No team selected. Please select a team first.' + }; + } + + const teamId = teamSelect.value; + // Convert days to number (model might send as string) + const days = params.days ? Number(params.days) : 30; + + try { + // Call Meteor method to get time statistics + const stats = await new Promise((resolve, reject) => { + Meteor.call('getProjectTimeStats', { teamId, days }, (error, result) => { + if (error) reject(error); + else resolve(result); + }); + }); + + return { + success: true, + ...stats, + message: `Retrieved time stats: ${stats.totalFormatted} across ${stats.ticketCount} tickets in the last ${stats.days} days` + }; + } catch (error) { + return { + success: false, + error: `Failed to retrieve time statistics: ${error.message}` + }; + } + } +}; + +// Initialize MCP Tools Integration +class OzwellMCPIntegration { + constructor() { + this.widgetIframe = null; + this.init(); + } + + init() { + // Wait for widget to send tool execution requests + window.addEventListener('message', (event) => { + // Security check: Only accept messages from our widget iframe or reference server + const validOrigins = [ + 'http://localhost:3000', + 'http://127.0.0.1:3000', + 'http://localhost:3001', // TimeHarbor's port (if widget is proxied through it) + 'null', // Widget iframe may have null origin due to CORS/iframe loading + 'about:' // about:srcdoc origin for sandboxed iframes + ]; + + // Check if origin starts with valid prefixes (for about:srcdoc, about:blank, etc.) + const isValidOrigin = validOrigins.includes(event.origin) || + event.origin.startsWith('about:'); + + if (!isValidOrigin) { + // Silently ignore Meteor and other internal messages + return; + } + + // For null or about: origins, verify it's from our widget iframe + if (event.origin === 'null' || event.origin.startsWith('about:')) { + const widgetIframe = document.querySelector('iframe[src*="ozwell.html"]'); + if (!widgetIframe || event.source !== widgetIframe.contentWindow) { + return; + } + } + + const { source, type, tool, payload } = event.data; + + // Match Ozwell's convention: type='tool_call', tool=name, payload=args + if (source === 'ozwell-chat-widget' && type === 'tool_call') { + // Convert to expected format + const toolCall = { + name: tool, + arguments: payload + }; + console.log('[MCP Tools] Tool call requested:', toolCall); + this.executeTool(toolCall); + } + }); + + console.log('[MCP Tools] Integration initialized, listening for tool calls'); + } + + async executeTool(toolCall) { + const { name, arguments: args } = toolCall; + console.log(`[MCP Tools] Executing: ${name}`, args); + + const handler = toolHandlers[name]; + + if (!handler) { + this.sendToolResult({ + success: false, + error: `Unknown tool: ${name}` + }); + return; + } + + try { + const result = await handler(args); + this.sendToolResult(result); + } catch (error) { + this.sendToolResult({ + success: false, + error: `Tool execution failed: ${error.message}` + }); + } + } + + sendToolResult(result) { + // Use the global OzwellChat object (created by ozwell-loader.js) + if (!window.OzwellChat || !window.OzwellChat.iframe) { + console.warn('[MCP Tools] window.OzwellChat.iframe not found, cannot send result'); + console.warn('[MCP Tools] Make sure Ozwell widget is loaded before executing tools'); + return; + } + + // Send result back to widget (Ozwell's recommended pattern) + window.OzwellChat.iframe.contentWindow.postMessage( + { + source: 'ozwell-chat-parent', + type: 'tool_result', + result: result + }, + '*' + ); + + console.log('[MCP Tools] Tool result sent:', result.message || result.error); + } +} + +// Auto-initialize when DOM is ready +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { + window.ozwellMCPIntegration = new OzwellMCPIntegration(); + }); +} else { + window.ozwellMCPIntegration = new OzwellMCPIntegration(); +} + +/** + * OPTIONAL: Real-Time Form Sync (currently disabled) + * For implementation: See OZWELL_SETUP.md or https://ozwellai-embedtest.opensource.mieweb.org (view source) + */ diff --git a/server/main.js b/server/main.js index 6726287..16822b8 100644 --- a/server/main.js +++ b/server/main.js @@ -21,6 +21,9 @@ import { notifyTeamAdmins, notifyUser } from './utils/pushNotifications.js'; import dotenv from 'dotenv'; dotenv.config({ path: '.env' }); +// Import Ozwell methods +import { ozwellMethods } from './methods/ozwell.js'; + Meteor.startup(async () => { // Configure Google OAuth from environment variables const googleClientId = process.env.GOOGLE_CLIENT_ID; @@ -382,6 +385,7 @@ Meteor.methods({ ...teamMethods, ...ticketMethods, ...clockEventMethods, + ...ozwellMethods, ...notificationMethods, 'participants.create'(name) { diff --git a/server/methods/ozwell.js b/server/methods/ozwell.js new file mode 100644 index 0000000..db32ecc --- /dev/null +++ b/server/methods/ozwell.js @@ -0,0 +1,74 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import { Teams, Tickets } from '../../collections.js'; + +export const ozwellMethods = { + // Get recent project tickets for MCP tool (context-aware suggestions) + async getRecentProjectTickets({ teamId, days = 30, limit = 20 }) { + check(teamId, String); + check(days, Number); + check(limit, Number); + if (!this.userId) throw new Meteor.Error('not-authorized'); + + // Verify team membership + const team = await Teams.findOneAsync({ _id: teamId, members: this.userId }); + if (!team) throw new Meteor.Error('not-authorized', 'Not a team member'); + + // Calculate cutoff date + const cutoffDate = new Date(Date.now() - days * 24 * 60 * 60 * 1000); + + // Query tickets created within the time window + const tickets = await Tickets.find({ + teamId, + createdAt: { $gte: cutoffDate } + }, { + sort: { createdAt: -1 }, + limit + }).fetchAsync(); + + // Return formatted data for AI context + return tickets.map(t => ({ + title: t.title, + description: t.github || '', + time: t.accumulatedTime || 0, + createdAt: t.createdAt + })); + }, + + // Get project time statistics for MCP tool (time tracking queries) + async getProjectTimeStats({ teamId, days = 30 }) { + check(teamId, String); + check(days, Number); + if (!this.userId) throw new Meteor.Error('not-authorized'); + + // Verify team membership + const team = await Teams.findOneAsync({ _id: teamId, members: this.userId }); + if (!team) throw new Meteor.Error('not-authorized', 'Not a team member'); + + // Calculate cutoff date + const cutoffDate = new Date(Date.now() - days * 24 * 60 * 60 * 1000); + + // Query tickets created within the time window + const tickets = await Tickets.find({ + teamId, + createdAt: { $gte: cutoffDate } + }).fetchAsync(); + + // Calculate total time in seconds + const totalSeconds = tickets.reduce((sum, ticket) => { + return sum + (ticket.accumulatedTime || 0); + }, 0); + + // Format as "Xh Ym" + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const totalFormatted = `${hours}h ${minutes}m`; + + return { + totalSeconds, + totalFormatted, + ticketCount: tickets.length, + days + }; + } +};