Skip to content

Feat/ollama integration#19

Open
PeteHaughie wants to merge 10 commits into
mskayyali:mainfrom
PeteHaughie:feat/ollama-integration
Open

Feat/ollama integration#19
PeteHaughie wants to merge 10 commits into
mskayyali:mainfrom
PeteHaughie:feat/ollama-integration

Conversation

@PeteHaughie
Copy link
Copy Markdown

This pull request introduces robust handling for content types and dynamic model fetching, especially for the Ollama AI provider, and improves API routing for model and completion requests. The main themes are: (1) safer and more resilient UI rendering for unknown or missing content types, (2) dynamic model discovery and selection for Ollama, and (3) new API endpoints to interact with Ollama models and chat completions with input validation and enhanced error handling.

Content type handling improvements:

  • All UI components that render icons or styles based on CONTENT_TYPE_CONFIG now safely fall back to a default icon (FileText) and default styles if a content type is not recognized, preventing runtime errors and ensuring graceful degradation. [1] [2] [3] [4] [5] [6] [7] [8] [9] [10]

Ollama model fetching and selection:

  • The project sidebar now dynamically fetches available Ollama models from the backend, maps them for selection, and updates the current model if the chosen one is unavailable. This ensures users always have a valid, up-to-date model list when using Ollama. [1] [2]

API endpoints for Ollama:

  • Adds /api/ollama/models and /api/ollama endpoints that validate URLs, restrict remote access in production, fetch models, and forward chat completion requests to Ollama with proper error handling and model existence checks. [1] [2]

UI model label display:

  • The UI now displays the correct model label for both API-key and Ollama providers, showing the Ollama model name directly when appropriate. [1] [2] [3]

Codebase consistency and imports:

  • Ensures all files import the necessary icons and utility functions for fallbacks and dynamic model handling, maintaining code consistency. [1] [2] [3] [4] [5]

Copilot AI review requested due to automatic review settings April 9, 2026 12:45
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds Ollama (local) as an AI provider option, improves resilience when rendering unknown content types in the UI, and introduces Next.js API routes to proxy model listing and chat completions to an Ollama/OpenAI-compatible backend.

Changes:

  • Add Ollama provider preset + allow running without an API key; prefer customBaseUrl when set.
  • Introduce /api/ollama and /api/ollama/models proxy endpoints and client-side dynamic model discovery for Ollama.
  • Add safer fallbacks in multiple UI surfaces when encountering unknown/missing content-type configs.

Reviewed changes

Copilot reviewed 17 out of 17 changed files in this pull request and generated 14 comments.

Show a summary per file
File Description
next.config.mjs Extends CSP connect-src in dev to allow local Ollama.
lib/ai-utils.ts Adds URL normalization + endpoint selection helpers for AI requests.
lib/ai-settings.ts Adds ollama provider, keyless config for Ollama, and conditional auth header behavior.
lib/ai-ghost.ts Tries multiple completion endpoints and supports routing through the local proxy.
lib/ai-enrich.ts Same endpoint-try/proxy routing logic for enrichment requests.
components/tiling-minimap.tsx Adds fallback content-type config handling in minimap rendering.
components/tile-index.tsx Ensures fallback icon for column icons when missing.
components/tile-card.tsx Uses safer fallback config/icon/accent handling for unknown content types.
components/status-bar.tsx Adds fallback config for unknown types when rendering type counts.
components/project-sidebar.tsx Dynamically fetches Ollama models and keeps selected model valid.
components/kanban-minimap.tsx Adds fallback icon rendering when a column icon is missing.
components/kanban-area.tsx Adds fallback icon rendering in kanban column headers.
components/graph-detail-panel.tsx Adds fallback config/icon handling for unknown content types.
components/about-panel.tsx Adds fallback config/icon handling for content type highlights.
app/page.tsx Shows model label for Ollama; updates “AI inactive” banner logic.
app/api/ollama/route.ts New proxy endpoint for chat completions forwarding to Ollama/baseUrl.
app/api/ollama/models/route.ts New proxy endpoint to fetch and normalize available Ollama models.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread app/page.tsx
import type { ContentType } from "@/lib/content-types"
import { INITIAL_PROJECTS } from "@/lib/initial-data"
import { useAISettings } from "@/lib/ai-settings"
import { useAISettings, getPreset } from "@/lib/ai-settings"
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getPreset is imported but never used in this file. This will trip unused-import linting/TS checks in many setups; please remove the unused import (or actually use it if intended).

Suggested change
import { useAISettings, getPreset } from "@/lib/ai-settings"
import { useAISettings } from "@/lib/ai-settings"

Copilot uses AI. Check for mistakes.
Comment thread lib/ai-utils.ts
Comment on lines +17 to +18
return provider === "ollama" || isLocalBase(normalizedBase)
? ["/api/ollama"]
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

buildTryEndpoints() returns both /v1/chat/completions and /chat/completions for non-local providers, but for base URLs that already end with /v1 (e.g. the OpenAI/OpenRouter presets) joinEndpoint() makes both paths resolve to the same final URL. That can cause redundant retry requests in the 404/405 fallback logic. Consider tailoring the returned paths based on whether normalizedBase already ends with /v1 (or dedupe the computed URLs).

Suggested change
return provider === "ollama" || isLocalBase(normalizedBase)
? ["/api/ollama"]
if (provider === "ollama" || isLocalBase(normalizedBase)) {
return ["/api/ollama"]
}
return normalizedBase.endsWith("/v1")
? ["/chat/completions"]

Copilot uses AI. Check for mistakes.
Comment thread lib/ai-utils.ts
}

export function buildTryEndpoints(provider: string, normalizedBase: string): string[] {
return provider === "ollama" || isLocalBase(normalizedBase)
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

buildTryEndpoints() routes any localhost base URL through /api/ollama (via isLocalBase()), even when provider is not ollama. Since /api/ollama includes Ollama-specific behavior (e.g. probing /v1/models and potentially rewriting body.model), this can change behavior for users pointing OpenAI/Z.ai providers at a local OpenAI-compatible server. If the proxy is intended to be Ollama-only, gate it strictly on provider === "ollama"; if it’s intended as a generic local proxy, consider renaming the route and removing Ollama-specific model rewriting.

Suggested change
return provider === "ollama" || isLocalBase(normalizedBase)
return provider === "ollama"

Copilot uses AI. Check for mistakes.
Comment thread app/api/ollama/route.ts
Comment on lines +18 to +29
async function forwardToOllama(body: any, forwardAuth?: string, providedBase?: string) {
const baseCandidate = (providedBase || DEFAULT_OLLAMA).replace(new RegExp('/+$'), "")
if (!baseCandidate) {
return { ok: false, status: 400, json: { error: "No base URL provided" } }
}

// Disallow non-local remote hosts in production — return 403 rather than throwing.
if (!isLocalHost(baseCandidate) && process.env.NODE_ENV === "production") {
return { ok: false, status: 403, json: { error: "Remote baseUrl not allowed in production" } }
}

const base = baseCandidate.replace(/\/v1$/i, "")
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

providedBase/baseCandidate is never validated as an absolute http(s) URL. In dev, inputs like file://... or malformed strings will slip through isLocalHost(), then cause confusing 502s when fetch() throws. Consider validating baseCandidate with new URL() (and restricting protocol to http:/https:) and returning a 400 with a clear error for invalid values.

Suggested change
async function forwardToOllama(body: any, forwardAuth?: string, providedBase?: string) {
const baseCandidate = (providedBase || DEFAULT_OLLAMA).replace(new RegExp('/+$'), "")
if (!baseCandidate) {
return { ok: false, status: 400, json: { error: "No base URL provided" } }
}
// Disallow non-local remote hosts in production — return 403 rather than throwing.
if (!isLocalHost(baseCandidate) && process.env.NODE_ENV === "production") {
return { ok: false, status: 403, json: { error: "Remote baseUrl not allowed in production" } }
}
const base = baseCandidate.replace(/\/v1$/i, "")
function validateHttpBaseUrl(raw: string) {
try {
const url = new URL(raw)
if (url.protocol !== "http:" && url.protocol !== "https:") {
return { ok: false as const, error: "baseUrl must use http or https" }
}
return { ok: true as const, url: url.href.replace(/\/+$/, "") }
} catch {
return { ok: false as const, error: "baseUrl must be a valid absolute URL" }
}
}
async function forwardToOllama(body: any, forwardAuth?: string, providedBase?: string) {
const baseCandidate = (providedBase || DEFAULT_OLLAMA).replace(new RegExp('/+$'), "")
if (!baseCandidate) {
return { ok: false, status: 400, json: { error: "No base URL provided" } }
}
const validatedBase = validateHttpBaseUrl(baseCandidate)
if (!validatedBase.ok) {
return { ok: false, status: 400, json: { error: validatedBase.error } }
}
// Disallow non-local remote hosts in production — return 403 rather than throwing.
if (!isLocalHost(validatedBase.url) && process.env.NODE_ENV === "production") {
return { ok: false, status: 403, json: { error: "Remote baseUrl not allowed in production" } }
}
const base = validatedBase.url.replace(/\/v1$/i, "")

Copilot uses AI. Check for mistakes.
Comment thread app/api/ollama/route.ts
Comment on lines +82 to +83
const { baseUrl, ...sanitizedBody } = (body && typeof body === "object") ? body as Record<string, any> : {}
const providedBase = baseUrl ? String(baseUrl) : undefined
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In POST(), baseUrl is coerced via String(baseUrl) even when the incoming JSON value isn’t a string (e.g. { baseUrl: {} } becomes "[object Object]"). Prefer accepting only a string type (trim it) and rejecting other types with 400 to avoid surprising proxy behavior.

Suggested change
const { baseUrl, ...sanitizedBody } = (body && typeof body === "object") ? body as Record<string, any> : {}
const providedBase = baseUrl ? String(baseUrl) : undefined
const parsedBody = (body && typeof body === "object") ? body as Record<string, any> : {}
const { baseUrl, ...sanitizedBody } = parsedBody
let providedBase: string | undefined
if (Object.prototype.hasOwnProperty.call(parsedBody, "baseUrl")) {
if (typeof baseUrl !== "string") {
return NextResponse.json({ error: "baseUrl must be a string" }, { status: 400 })
}
const trimmedBaseUrl = baseUrl.trim()
providedBase = trimmedBaseUrl || undefined
}

Copilot uses AI. Check for mistakes.
<div className="grid grid-cols-3 gap-[3px]">
{page.map(block => {
const config = CONTENT_TYPE_CONFIG[block.contentType]
const config = CONTENT_TYPE_CONFIG[block.contentType] ?? CONTENT_TYPE_CONFIG.general
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above: CONTENT_TYPE_CONFIG[block.contentType] ?? ... can still return inherited prototype properties for certain strings, preventing the intended fallback. Use an own-property check before indexing.

Suggested change
const config = CONTENT_TYPE_CONFIG[block.contentType] ?? CONTENT_TYPE_CONFIG.general
const config = Object.prototype.hasOwnProperty.call(CONTENT_TYPE_CONFIG, block.contentType)
? CONTENT_TYPE_CONFIG[block.contentType]
: CONTENT_TYPE_CONFIG.general

Copilot uses AI. Check for mistakes.
const config = CONTENT_TYPE_CONFIG[block.contentType]
const Icon = config.icon
const accent = config.accentVar
const config = CONTENT_TYPE_CONFIG[block.contentType] ?? CONTENT_TYPE_CONFIG.general
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CONTENT_TYPE_CONFIG[block.contentType] ?? ... can still return inherited prototype properties for certain strings (e.g. "toString"), which defeats the intended fallback-to-general behavior. Use an own-property check before indexing (e.g. Object.hasOwn(CONTENT_TYPE_CONFIG, block.contentType) ? ... : ...).

Suggested change
const config = CONTENT_TYPE_CONFIG[block.contentType] ?? CONTENT_TYPE_CONFIG.general
const config = Object.hasOwn(CONTENT_TYPE_CONFIG, block.contentType)
? CONTENT_TYPE_CONFIG[block.contentType]
: CONTENT_TYPE_CONFIG.general

Copilot uses AI. Check for mistakes.
{connectedBlocks.map(b => {
const bConfig = CONTENT_TYPE_CONFIG[b.contentType]
const BIcon = bConfig.icon
const bConfig = CONTENT_TYPE_CONFIG[b.contentType] ?? CONTENT_TYPE_CONFIG.general
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue here: CONTENT_TYPE_CONFIG[b.contentType] ?? ... won’t fall back for keys that exist on the prototype chain. Prefer an own-property check (or use the Object.hasOwn pattern already used elsewhere) before indexing.

Suggested change
const bConfig = CONTENT_TYPE_CONFIG[b.contentType] ?? CONTENT_TYPE_CONFIG.general
const bConfig = Object.hasOwn(CONTENT_TYPE_CONFIG, b.contentType)
? CONTENT_TYPE_CONFIG[b.contentType as ContentType]
: CONTENT_TYPE_CONFIG.general

Copilot uses AI. Check for mistakes.
Comment thread components/status-bar.tsx
CONTENT_TYPE_CONFIG[
type as keyof typeof CONTENT_TYPE_CONFIG
]
type in CONTENT_TYPE_CONFIG
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using type in CONTENT_TYPE_CONFIG will be true for prototype properties like "toString", so CONTENT_TYPE_CONFIG[type] can resolve to an inherited function instead of undefined, defeating the fallback config. For robust handling of unknown content types, prefer an own-property check (e.g. Object.hasOwn(CONTENT_TYPE_CONFIG, type)).

Suggested change
type in CONTENT_TYPE_CONFIG
Object.hasOwn(CONTENT_TYPE_CONFIG, type)

Copilot uses AI. Check for mistakes.
Comment thread components/tile-index.tsx
@@ -45,7 +45,7 @@ export function TileIndex({ blocks, onHighlight, highlightedId, onClose, isOpen,
})
return Array.from(cols).map(type => {
const config = CONTENT_TYPE_CONFIG[type as ContentType] || CONTENT_TYPE_CONFIG.general
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CONTENT_TYPE_CONFIG[type as ContentType] || CONTENT_TYPE_CONFIG.general will not fall back for keys that resolve via the prototype chain (e.g. "toString"), because the lookup returns a truthy inherited value. If type can be any string, use an own-property guard (e.g. Object.hasOwn(CONTENT_TYPE_CONFIG, type)) before indexing to ensure the fallback actually applies.

Suggested change
const config = CONTENT_TYPE_CONFIG[type as ContentType] || CONTENT_TYPE_CONFIG.general
const config = Object.hasOwn(CONTENT_TYPE_CONFIG, type)
? CONTENT_TYPE_CONFIG[type as ContentType]
: CONTENT_TYPE_CONFIG.general

Copilot uses AI. Check for mistakes.
Copilot AI and others added 2 commits April 9, 2026 14:04
…r type, URL validation in ollama route

Agent-Logs-Url: https://github.com/PeteHaughie/nodepad/sessions/2f4a61ba-bb40-4f7c-8989-ebfe2c9a5153

Co-authored-by: PeteHaughie <1645931+PeteHaughie@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants