diff options
| author | Adam Malczewski <[email protected]> | 2026-03-24 14:52:40 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-03-24 14:52:40 +0900 |
| commit | 4e43498aec88c3f974fd179bef9e81d1cef63e9c (patch) | |
| tree | 7b73e6f54cf3ecffa087f89aeb78ccb9414e7581 | |
| parent | c34eca01c8fc8bc8ff6a14d0c48a9c2323daf915 (diff) | |
| download | ai-pulse-obsidian-plugin-4e43498aec88c3f974fd179bef9e81d1cef63e9c.tar.gz ai-pulse-obsidian-plugin-4e43498aec88c3f974fd179bef9e81d1cef63e9c.zip | |
Fix mobile load error, add FAB and collapse UI
| -rw-r--r-- | .gitignore | 2 | ||||
| -rw-r--r-- | .rules/changelog/2026-03/24/09.md | 34 | ||||
| -rw-r--r-- | src/chat-view.ts | 83 | ||||
| -rw-r--r-- | src/ollama-client.ts | 155 | ||||
| -rw-r--r-- | src/settings-modal.ts | 2 | ||||
| -rw-r--r-- | styles.css | 196 |
6 files changed, 417 insertions, 55 deletions
@@ -21,4 +21,4 @@ data.json # Exclude macOS Finder (System Explorer) View States .DS_Store -obsidian-developer-docs +reference diff --git a/.rules/changelog/2026-03/24/09.md b/.rules/changelog/2026-03/24/09.md new file mode 100644 index 0000000..e9a39b3 --- /dev/null +++ b/.rules/changelog/2026-03/24/09.md @@ -0,0 +1,34 @@ +# Changelog — 2026-03-24 #09 + +## Mobile "Load failed" Fix + +- **`src/ollama-client.ts`**: Imported `Platform` from Obsidian for runtime mobile detection. +- Split `sendChatMessageStreaming` into three functions: + - `sendChatMessageStreaming()` — dispatcher that checks `Platform.isMobile` + - `sendChatMessageStreamingMobile()` — uses `requestUrl()` (non-streaming) to bypass WebView sandbox restrictions on mobile + - `sendChatMessageStreamingDesktop()` — preserves native `fetch()` for real token-by-token streaming on desktop +- Enhanced error messages in `testConnection()` and the mobile chat path with mobile-specific hints (e.g., "use your computer's LAN IP instead of localhost"). +- Added `"load failed"` to the list of caught network error patterns. + +## UI: DaisyUI-inspired Collapse for Tool Call Details + +- **`src/chat-view.ts`**: Replaced native `<details>/<summary>` in `appendToolCall` with a checkbox-driven CSS grid collapse. +- **`styles.css`**: Added `.ai-organizer-collapse` styles using `grid-template-rows: 0fr → 1fr` transition with a rotating arrow indicator, inspired by DaisyUI's collapse component. + +## UI: FAB / Speed Dial + +- **`src/chat-view.ts`**: Removed Settings and Tools buttons from the input row. Added a floating action button (FAB) in the top-right of the messages area. + - Main trigger: gear icon, rotates 90° on open. + - Actions fan downward with staggered scale/opacity animations. + - Three actions: **AI Settings** (sliders icon), **Tools** (wrench icon), **Clear Chat** (trash icon). + - Clicking an action opens the modal/clears chat and blurs focus to auto-close the FAB. +- **`styles.css`**: Added FAB positioning, animation, label, and action button styles using Obsidian CSS variables. + +## UI: Settings Modal Rename + +- **`src/settings-modal.ts`**: Changed modal title from "AI Organizer Settings" to "AI Settings". + +## Removed + +- Removed the `.ai-organizer-tools-active` accent styling from the tools FAB button. +- Removed the old inline `.ai-organizer-input-buttons`, `.ai-organizer-settings-btn`, and `.ai-organizer-tools-btn` styles. diff --git a/src/chat-view.ts b/src/chat-view.ts index 4ba89f9..524c731 100644 --- a/src/chat-view.ts +++ b/src/chat-view.ts @@ -45,26 +45,36 @@ export class ChatView extends ItemView { const messagesArea = contentEl.createDiv({ cls: "ai-organizer-messages-area" }); this.messageContainer = messagesArea.createDiv({ cls: "ai-organizer-messages" }); - const inputRow = messagesArea.createDiv({ cls: "ai-organizer-input-row" }); - this.textarea = inputRow.createEl("textarea", { - attr: { placeholder: "Type a message...", rows: "2" }, - }); - - const buttonGroup = inputRow.createDiv({ cls: "ai-organizer-input-buttons" }); + // --- FAB Speed Dial --- + const fab = messagesArea.createDiv({ cls: "ai-organizer-fab" }); - // Settings button - const settingsBtn = buttonGroup.createEl("button", { - cls: "ai-organizer-settings-btn", + // Main FAB trigger button (first child) + const fabTrigger = fab.createEl("button", { + cls: "ai-organizer-fab-trigger", + attr: { "aria-label": "Actions", tabindex: "0" }, + }); + setIcon(fabTrigger, "settings"); + + // Speed dial actions (revealed on focus-within) + const settingsAction = fab.createDiv({ cls: "ai-organizer-fab-action" }); + const settingsLabel = settingsAction.createSpan({ cls: "ai-organizer-fab-label", text: "AI Settings" }); + void settingsLabel; + const settingsBtn = settingsAction.createEl("button", { + cls: "ai-organizer-fab-btn", attr: { "aria-label": "Settings" }, }); - setIcon(settingsBtn, "settings"); + setIcon(settingsBtn, "sliders-horizontal"); settingsBtn.addEventListener("click", () => { new SettingsModal(this.plugin).open(); + // Blur to close the FAB + (document.activeElement as HTMLElement)?.blur(); }); - // Tools button - this.toolsButton = buttonGroup.createEl("button", { - cls: "ai-organizer-tools-btn", + const toolsAction = fab.createDiv({ cls: "ai-organizer-fab-action" }); + const toolsLabel = toolsAction.createSpan({ cls: "ai-organizer-fab-label", text: "Tools" }); + void toolsLabel; + this.toolsButton = toolsAction.createEl("button", { + cls: "ai-organizer-fab-btn", attr: { "aria-label": "Tools" }, }); setIcon(this.toolsButton, "wrench"); @@ -75,10 +85,32 @@ export class ChatView extends ItemView { this.updateToolsButtonState(); }; modal.open(); + (document.activeElement as HTMLElement)?.blur(); + }); + + const clearAction = fab.createDiv({ cls: "ai-organizer-fab-action" }); + const clearLabel = clearAction.createSpan({ cls: "ai-organizer-fab-label", text: "Clear Chat" }); + void clearLabel; + const clearBtn = clearAction.createEl("button", { + cls: "ai-organizer-fab-btn", + attr: { "aria-label": "Clear Chat" }, + }); + setIcon(clearBtn, "trash-2"); + clearBtn.addEventListener("click", () => { + this.messages = []; + if (this.messageContainer !== null) { + this.messageContainer.empty(); + } + (document.activeElement as HTMLElement)?.blur(); + }); + + const inputRow = messagesArea.createDiv({ cls: "ai-organizer-input-row" }); + this.textarea = inputRow.createEl("textarea", { + attr: { placeholder: "Type a message...", rows: "2" }, }); // Send button - this.sendButton = buttonGroup.createEl("button", { text: "Send" }); + this.sendButton = inputRow.createEl("button", { text: "Send" }); this.textarea.addEventListener("keydown", (e: KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { @@ -290,16 +322,31 @@ export class ChatView extends ItemView { container.createDiv({ text: event.summary, cls: "ai-organizer-tool-call-summary" }); container.createDiv({ text: event.resultSummary, cls: "ai-organizer-tool-call-result-summary" }); - const details = container.createEl("details", { cls: "ai-organizer-tool-call-details" }); - details.createEl("summary", { text: "Details" }); + // DaisyUI-style collapse with checkbox + const collapse = container.createDiv({ cls: "ai-organizer-collapse ai-organizer-collapse-arrow" }); + const collapseId = `tool-collapse-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; + const checkbox = collapse.createEl("input", { + type: "checkbox", + attr: { id: collapseId }, + }); + checkbox.addClass("ai-organizer-collapse-toggle"); + const titleEl = collapse.createEl("label", { + cls: "ai-organizer-collapse-title", + attr: { for: collapseId }, + text: "Details", + }); + void titleEl; // suppress unused warning + + const collapseContent = collapse.createDiv({ cls: "ai-organizer-collapse-content" }); + const contentInner = collapseContent.createDiv({ cls: "ai-organizer-collapse-content-inner" }); const argsStr = JSON.stringify(event.args, null, 2); - details.createEl("pre", { text: argsStr, cls: "ai-organizer-tool-call-args" }); + contentInner.createEl("pre", { text: argsStr, cls: "ai-organizer-tool-call-args" }); const resultPreview = event.result.length > 500 ? event.result.substring(0, 500) + "..." : event.result; - details.createEl("pre", { text: resultPreview, cls: "ai-organizer-tool-call-result" }); + contentInner.createEl("pre", { text: resultPreview, cls: "ai-organizer-tool-call-result" }); } private scrollToBottom(): void { diff --git a/src/ollama-client.ts b/src/ollama-client.ts index c9e4042..6ada18a 100644 --- a/src/ollama-client.ts +++ b/src/ollama-client.ts @@ -1,4 +1,4 @@ -import { requestUrl } from "obsidian"; +import { Platform, requestUrl } from "obsidian"; import type { App } from "obsidian"; import type { OllamaToolDefinition } from "./tools"; import { findToolByName } from "./tools"; @@ -48,7 +48,13 @@ export async function testConnection(ollamaUrl: string): Promise<string> { } catch (err: unknown) { if (err instanceof Error) { const msg = err.message.toLowerCase(); - if (msg.includes("net") || msg.includes("fetch") || msg.includes("failed to fetch")) { + if (msg.includes("net") || msg.includes("fetch") || msg.includes("failed to fetch") || msg.includes("load failed")) { + if (Platform.isMobile) { + throw new Error( + "Ollama is unreachable. On mobile, use your computer's LAN IP " + + "(e.g. http://192.168.1.x:11434) instead of localhost." + ); + } throw new Error("Ollama is unreachable. Is the server running?"); } throw err; @@ -257,17 +263,153 @@ async function* readNdjsonStream( * Streams text chunks via onChunk callback. Supports tool-calling agent loop: * tool execution rounds are non-streamed, only the final text response streams. * Returns the full accumulated response text. + * + * On mobile platforms, falls back to non-streaming via Obsidian's requestUrl() + * because native fetch() cannot reach local network addresses from the mobile + * WebView sandbox. */ export async function sendChatMessageStreaming( opts: StreamingChatOptions, ): Promise<string> { + if (Platform.isMobile) { + return sendChatMessageStreamingMobile(opts); + } + return sendChatMessageStreamingDesktop(opts); +} + +/** + * Mobile fallback: uses Obsidian's requestUrl() (non-streaming) so the request + * goes through the native networking layer and can reach localhost / LAN. + */ +async function sendChatMessageStreamingMobile( + opts: StreamingChatOptions, +): Promise<string> { + const { ollamaUrl, model, messages, tools, app, onChunk, onToolCall, onCreateBubble } = opts; + const maxIterations = 10; + let iterations = 0; + + const workingMessages = messages.map((m) => ({ ...m })); + + if (tools !== undefined && tools.length > 0) { + const systemPrompt: ChatMessage = { + role: "system", + content: + "You are a helpful assistant with access to tools for interacting with an Obsidian vault. " + + "When you use the search_files tool, the results contain exact file paths. " + + "You MUST use these exact paths when calling read_file or referencing files. " + + "NEVER guess or modify file paths — always use the paths returned by search_files verbatim.", + }; + workingMessages.unshift(systemPrompt); + } + + while (iterations < maxIterations) { + iterations++; + + onCreateBubble(); + + const body: Record<string, unknown> = { + model, + messages: workingMessages, + stream: false, + }; + + if (tools !== undefined && tools.length > 0) { + body.tools = tools; + } + + try { + const response = await requestUrl({ + url: `${ollamaUrl}/api/chat`, + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + const messageObj = (response.json as Record<string, unknown>).message; + if (typeof messageObj !== "object" || messageObj === null) { + throw new Error("Unexpected response format: missing message."); + } + + const msg = messageObj as Record<string, unknown>; + const content = typeof msg.content === "string" ? msg.content : ""; + const toolCalls = Array.isArray(msg.tool_calls) ? msg.tool_calls as ToolCallResponse[] : []; + + // Deliver the full content as a single chunk to the UI + if (content !== "") { + onChunk(content); + } + + if (toolCalls.length === 0) { + return content; + } + + const assistantMsg: ChatMessage = { + role: "assistant", + content, + tool_calls: toolCalls, + }; + workingMessages.push(assistantMsg); + + if (app === undefined) { + throw new Error("App reference required for tool execution."); + } + + for (const tc of toolCalls) { + const fnName = tc.function.name; + const fnArgs = tc.function.arguments; + const toolEntry = findToolByName(fnName); + + let result: string; + if (toolEntry === undefined) { + result = `Error: Unknown tool "${fnName}".`; + } else { + result = await toolEntry.execute(app, fnArgs); + } + + if (onToolCall !== undefined) { + const friendlyName = toolEntry !== undefined ? toolEntry.friendlyName : fnName; + const summary = toolEntry !== undefined ? toolEntry.summarize(fnArgs) : `Called ${fnName}`; + const resultSummary = toolEntry !== undefined ? toolEntry.summarizeResult(result) : ""; + onToolCall({ toolName: fnName, friendlyName, summary, resultSummary, args: fnArgs, result }); + } + + workingMessages.push({ + role: "tool", + tool_name: fnName, + content: result, + }); + } + } catch (err: unknown) { + if (err instanceof Error) { + const msg = err.message.toLowerCase(); + if (msg.includes("net") || msg.includes("fetch") || msg.includes("load") || msg.includes("failed")) { + throw new Error( + `Cannot reach Ollama at ${ollamaUrl}. ` + + "On mobile, Ollama must be accessible over your network (not localhost). " + + "Set the Ollama URL to your computer's LAN IP (e.g. http://192.168.1.x:11434)." + ); + } + throw new Error(`Chat request failed: ${err.message}`); + } + throw new Error("Chat request failed: unknown error."); + } + } + + throw new Error("Tool calling loop exceeded maximum iterations."); +} + +/** + * Desktop streaming: uses native fetch() for real token-by-token streaming. + */ +async function sendChatMessageStreamingDesktop( + opts: StreamingChatOptions, +): Promise<string> { const { ollamaUrl, model, messages, tools, app, onChunk, onToolCall, onCreateBubble, abortSignal } = opts; const maxIterations = 10; let iterations = 0; const workingMessages = messages.map((m) => ({ ...m })); - // Inject a system prompt when tools are available to guide the model if (tools !== undefined && tools.length > 0) { const systemPrompt: ChatMessage = { role: "system", @@ -283,7 +425,6 @@ export async function sendChatMessageStreaming( while (iterations < maxIterations) { iterations++; - // Signal the UI to create a new bubble for this round onCreateBubble(); const body: Record<string, unknown> = { @@ -332,18 +473,15 @@ export async function sendChatMessageStreaming( } } catch (err: unknown) { if (err instanceof DOMException && err.name === "AbortError") { - // User cancelled — return whatever we accumulated return content; } throw err; } - // If no tool calls, we're done if (toolCalls.length === 0) { return content; } - // Tool calling: append assistant message and execute tools const assistantMsg: ChatMessage = { role: "assistant", content, @@ -380,9 +518,6 @@ export async function sendChatMessageStreaming( content: result, }); } - - // Reset content for next streaming round - // (tool call content was intermediate, next round streams the final answer) } throw new Error("Tool calling loop exceeded maximum iterations."); diff --git a/src/settings-modal.ts b/src/settings-modal.ts index 2daca89..4fb089f 100644 --- a/src/settings-modal.ts +++ b/src/settings-modal.ts @@ -14,7 +14,7 @@ export class SettingsModal extends Modal { contentEl.empty(); contentEl.addClass("ai-organizer-settings-modal"); - this.setTitle("AI Organizer Settings"); + this.setTitle("AI Settings"); // Ollama URL setting new Setting(contentEl) @@ -10,12 +10,14 @@ flex-direction: column; overflow: hidden; min-height: 0; + position: relative; } .ai-organizer-messages { flex: 1; overflow-y: auto; padding: 8px; + padding-top: 52px; display: flex; flex-direction: column; gap: 6px; @@ -60,6 +62,8 @@ color: var(--text-error); } +/* ===== Input Row ===== */ + .ai-organizer-input-row { display: flex; flex-direction: row; @@ -84,52 +88,127 @@ outline: none; } -.ai-organizer-input-buttons { +/* ===== FAB / Speed Dial ===== */ + +.ai-organizer-fab { + position: absolute; + top: 8px; + right: 12px; + z-index: 10; display: flex; flex-direction: column; - gap: 4px; + align-items: flex-end; + gap: 8px; + pointer-events: none; +} + +.ai-organizer-fab > * { + pointer-events: auto; } -.ai-organizer-settings-btn { +/* Main FAB trigger */ +.ai-organizer-fab-trigger { + width: 40px; + height: 40px; + border-radius: 50%; + border: none; + background-color: var(--interactive-accent); + color: var(--text-on-accent); + cursor: pointer; display: flex; align-items: center; justify-content: center; - padding: 4px; - background: transparent; - border: 1px solid var(--background-modifier-border); - border-radius: 4px; - color: var(--text-muted); - cursor: pointer; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1), + background-color 0.2s; } -.ai-organizer-settings-btn:hover { - color: var(--text-normal); - background-color: var(--background-modifier-hover); +.ai-organizer-fab-trigger:hover { + background-color: var(--interactive-accent-hover); } +.ai-organizer-fab-trigger svg { + width: 18px; + height: 18px; +} -.ai-organizer-tools-btn { +/* Rotate trigger when FAB is open */ +.ai-organizer-fab:focus-within > .ai-organizer-fab-trigger { + transform: rotate(90deg); +} + +/* FAB action items (hidden by default) */ +.ai-organizer-fab-action { display: flex; align-items: center; - justify-content: center; - padding: 4px; - background: transparent; - border: 1px solid var(--background-modifier-border); + gap: 8px; + visibility: hidden; + opacity: 0; + transform: scale(0.8); + transition: opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1), + visibility 0.2s cubic-bezier(0.4, 0, 0.2, 1), + transform 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} + +/* Staggered delays */ +.ai-organizer-fab-action:nth-child(2) { + transition-delay: 30ms; +} + +.ai-organizer-fab-action:nth-child(3) { + transition-delay: 60ms; +} + +.ai-organizer-fab-action:nth-child(4) { + transition-delay: 90ms; +} + +/* Reveal actions on focus-within */ +.ai-organizer-fab:focus-within > .ai-organizer-fab-action { + visibility: visible; + opacity: 1; + transform: scale(1); +} + +/* FAB action label */ +.ai-organizer-fab-label { + padding: 3px 8px; border-radius: 4px; + background-color: var(--background-secondary); + color: var(--text-normal); + font-size: 0.8em; + white-space: nowrap; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15); +} + +/* FAB action button (circle) */ +.ai-organizer-fab-btn { + width: 40px; + height: 40px; + border-radius: 50%; + border: 1px solid var(--background-modifier-border); + background-color: var(--background-secondary); color: var(--text-muted); cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12); + transition: background-color 0.2s, color 0.2s, border-color 0.2s; } -.ai-organizer-tools-btn:hover { +.ai-organizer-fab-btn:hover { color: var(--text-normal); background-color: var(--background-modifier-hover); } -.ai-organizer-tools-btn.ai-organizer-tools-active { - color: var(--interactive-accent); - border-color: var(--interactive-accent); +.ai-organizer-fab-btn svg { + width: 18px; + height: 18px; } +/* ===== Tool Call Bubbles ===== */ + .ai-organizer-tool-call { align-self: flex-start; max-width: 85%; @@ -175,21 +254,87 @@ font-size: 0.9em; } -.ai-organizer-tool-call-details { +/* ===== DaisyUI-inspired Collapse ===== */ + +.ai-organizer-collapse { + display: grid; + position: relative; + overflow: hidden; + border-radius: 6px; + width: 100%; + grid-template-rows: max-content 0fr; + grid-template-columns: minmax(0, 1fr); + transition: grid-template-rows 0.2s ease-out; margin-top: 4px; } -.ai-organizer-tool-call-details > summary { +.ai-organizer-collapse-toggle { + position: absolute; + opacity: 0; + width: 0; + height: 0; + pointer-events: none; +} + +.ai-organizer-collapse-title { + grid-column-start: 1; + grid-row-start: 1; + position: relative; + width: 100%; + padding: 4px 28px 4px 0; cursor: pointer; color: var(--text-muted); font-size: 0.9em; user-select: none; + transition: color 0.15s; } -.ai-organizer-tool-call-details > summary:hover { +.ai-organizer-collapse-title:hover { color: var(--text-normal); } +/* Collapse arrow indicator */ +.ai-organizer-collapse-arrow > .ai-organizer-collapse-title::after { + content: ""; + position: absolute; + top: 50%; + right: 8px; + width: 0.45em; + height: 0.45em; + border-right: 2px solid currentColor; + border-bottom: 2px solid currentColor; + transform: translateY(-75%) rotate(45deg); + transform-origin: 75% 75%; + transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1); + pointer-events: none; +} + +/* Arrow rotates when open */ +.ai-organizer-collapse-toggle:checked ~ .ai-organizer-collapse-title::after { + transform: translateY(-25%) rotate(225deg); +} + +/* Expand grid when checked */ +.ai-organizer-collapse:has(.ai-organizer-collapse-toggle:checked) { + grid-template-rows: max-content 1fr; +} + +.ai-organizer-collapse-content { + grid-column-start: 1; + grid-row-start: 2; + min-height: 0; + overflow: hidden; + transition: min-height 0.2s ease-out; +} + +.ai-organizer-collapse-toggle:checked ~ .ai-organizer-collapse-content { + min-height: fit-content; +} + +.ai-organizer-collapse-content-inner { + padding: 4px 0 6px 0; +} + .ai-organizer-tool-call-args, .ai-organizer-tool-call-result { margin: 2px 0; @@ -207,13 +352,14 @@ color: var(--text-muted); } +/* ===== Misc ===== */ + .ai-organizer-tool-modal-desc { color: var(--text-muted); font-size: 0.9em; margin-bottom: 8px; } - .ai-organizer-stop-btn { background-color: var(--text-error) !important; color: var(--text-on-accent) !important; |
