Merge pull request #110 from Mriris/main

fix: Ollama on Windows
This commit is contained in:
Xinlu Lai 2025-10-10 01:26:37 +08:00 committed by GitHub
commit 893112e43c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 140 additions and 21 deletions

View File

@ -419,7 +419,10 @@ export function ModelSelector({
function getModelDetails(model: ModelInfo): string {
const details = []
if (model.max_tokens) {
// Show context_length if available (Ollama models), otherwise max_tokens
if (model.context_length) {
details.push(`${formatNumber(model.context_length)} tokens`)
} else if (model.max_tokens) {
details.push(`${formatNumber(model.max_tokens)} tokens`)
}
@ -1040,13 +1043,15 @@ export function ModelSelector({
}
// Transform Ollama models to our format
// Note: max_tokens here is for OUTPUT tokens, not context length
const ollamaModels = models.map((model: any) => ({
model:
model.id ??
model.name ??
model.modelName ??
(typeof model === 'string' ? model : ''),
provider: 'ollama',
max_tokens: 4096, // Default value
max_tokens: DEFAULT_MAX_TOKENS, // Default output tokens (8K is reasonable)
supports_vision: false,
supports_function_calling: true,
supports_reasoning_effort: false,
@ -1055,16 +1060,102 @@ export function ModelSelector({
// Filter out models with empty names
const validModels = ollamaModels.filter(model => model.model)
setAvailableModels(validModels)
// Helper: normalize Ollama server root for /api/show (strip trailing /v1)
const normalizeOllamaRoot = (url: string): string => {
try {
const u = new URL(url)
let pathname = u.pathname.replace(/\/+$|^$/, '')
if (pathname.endsWith('/v1')) {
pathname = pathname.slice(0, -3)
}
u.pathname = pathname
return u.toString().replace(/\/+$/, '')
} catch {
return url.replace(/\/v1\/?$/, '')
}
}
// Helper: extract num_ctx/context_length from /api/show response
const extractContextTokens = (data: any): number | null => {
if (!data || typeof data !== 'object') return null
// First check model_info for architecture-specific context_length fields
// Example: qwen2.context_length, llama.context_length, etc.
if (data.model_info && typeof data.model_info === 'object') {
const modelInfo = data.model_info
for (const key of Object.keys(modelInfo)) {
if (key.endsWith('.context_length') || key.endsWith('_context_length')) {
const val = modelInfo[key]
if (typeof val === 'number' && isFinite(val) && val > 0) {
return val
}
}
}
}
// Fallback to other common fields
const candidates = [
(data as any)?.parameters?.num_ctx,
(data as any)?.model_info?.num_ctx,
(data as any)?.config?.num_ctx,
(data as any)?.details?.context_length,
(data as any)?.context_length,
(data as any)?.num_ctx,
(data as any)?.max_tokens,
(data as any)?.max_new_tokens
].filter((v: any) => typeof v === 'number' && isFinite(v) && v > 0)
if (candidates.length > 0) {
return Math.max(...candidates)
}
// parameters may be a string like "num_ctx=4096 ..."
if (typeof (data as any)?.parameters === 'string') {
const m = (data as any).parameters.match(/num_ctx\s*[:=]\s*(\d+)/i)
if (m) {
const n = parseInt(m[1], 10)
if (Number.isFinite(n) && n > 0) return n
}
}
return null
}
// Enrich each model via /api/show to get accurate context length
// Store context length separately from max_tokens (output limit)
const ollamaRoot = normalizeOllamaRoot(ollamaBaseUrl)
const enrichedModels = await Promise.all(
validModels.map(async (m: any) => {
try {
const showResp = await fetch(`${ollamaRoot}/api/show`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: m.model })
})
if (showResp.ok) {
const showData = await showResp.json()
const ctx = extractContextTokens(showData)
if (typeof ctx === 'number' && isFinite(ctx) && ctx > 0) {
// Store context_length separately, don't override max_tokens
return { ...m, context_length: ctx }
}
}
// Fallback to default if missing
return m
} catch {
return m
}
})
)
setAvailableModels(enrichedModels)
// Only navigate if we have models
if (validModels.length > 0) {
if (enrichedModels.length > 0) {
navigateTo('model')
} else {
setModelLoadError('No models found in your Ollama installation')
}
return validModels
return enrichedModels
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error)
@ -1403,7 +1494,15 @@ export function ModelSelector({
setReasoningEffort(null)
}
// Set context length if available (from Ollama /api/show)
if (modelInfo?.context_length) {
setContextLength(modelInfo.context_length)
} else {
setContextLength(DEFAULT_CONTEXT_LENGTH)
}
// Set max tokens based on model info or default
// Note: max_tokens is for OUTPUT, not context window
if (modelInfo?.max_tokens) {
const modelMaxTokens = modelInfo.max_tokens
// Check if the model's max tokens matches any of our presets

View File

@ -2,7 +2,7 @@ import * as fs from 'fs'
import { homedir } from 'os'
import { existsSync } from 'fs'
import shellquote from 'shell-quote'
import { spawn, execSync, type ChildProcess } from 'child_process'
import { spawn, execSync, execFileSync, type ChildProcess } from 'child_process'
import { isAbsolute, resolve, join } from 'path'
import { logError } from './log'
import * as os from 'os'
@ -130,12 +130,12 @@ function detectShell(): DetectedShell {
// 1) Respect SHELL if it points to a bash.exe that exists
if (process.env.SHELL && /bash\.exe$/i.test(process.env.SHELL) && existsSync(process.env.SHELL)) {
return { bin: process.env.SHELL, args: ['-l'], type: 'msys' }
return { bin: process.env.SHELL, args: [], type: 'msys' }
}
// 1.1) Explicit override
if (process.env.KODE_BASH && existsSync(process.env.KODE_BASH)) {
return { bin: process.env.KODE_BASH, args: ['-l'], type: 'msys' }
return { bin: process.env.KODE_BASH, args: [], type: 'msys' }
}
// 2) Common Git Bash/MSYS2 locations
@ -165,7 +165,7 @@ function detectShell(): DetectedShell {
for (const c of candidates) {
if (existsSync(c)) {
return { bin: c, args: ['-l'], type: 'msys' }
return { bin: c, args: [], type: 'msys' }
}
}
@ -175,7 +175,7 @@ function detectShell(): DetectedShell {
for (const p of pathEntries) {
const candidate = join(p, 'bash.exe')
if (existsSync(candidate)) {
return { bin: candidate, args: ['-l'], type: 'msys' }
return { bin: candidate, args: [], type: 'msys' }
}
}
@ -269,8 +269,15 @@ export class PersistentShell {
this.stderrFileBashPath = toBashPath(this.stderrFile, this.shellType)
this.cwdFileBashPath = toBashPath(this.cwdFile, this.shellType)
// Source ~/.bashrc when available (works for bash on POSIX/MSYS/WSL)
this.sendToShell('[ -f ~/.bashrc ] && source ~/.bashrc || true')
// Source ~/.bashrc when available (avoid login shells on MSYS to prevent cwd resets)
if (this.shellType === 'msys') {
// Use non-login shell; explicitly source but keep working directory
this.sendToShell('[ -f ~/.bashrc ] && source ~/.bashrc || true')
// Ensure CWD file reflects current Windows path immediately on MSYS
this.sendToShell(`pwd -W > ${quoteForBash(this.cwdFileBashPath)}`)
} else {
this.sendToShell('[ -f ~/.bashrc ] && source ~/.bashrc || true')
}
}
private static instance: PersistentShell | null = null
@ -385,30 +392,39 @@ export class PersistentShell {
* - This sequence eliminates race conditions between exit code capture and CWD updates
* - The pwd() method reads the CWD file directly for current directory info
*/
const quotedCommand = shellquote.quote([command])
const quotedCommand = quoteForBash(command)
// Check the syntax of the command
try {
if (this.shellType === 'wsl') {
execSync(`wsl.exe -e bash -n -c ${quotedCommand}`, {
// On Windows WSL, avoid shell string quoting issues by using argv form
execFileSync('wsl.exe', ['-e', 'bash', '-n', '-c', command], {
stdio: 'ignore',
timeout: 1000,
})
} else if (this.shellType === 'msys') {
// On Windows Git Bash/MSYS, use execFileSync to bypass cmd.exe parsing
execFileSync(this.binShell, ['-n', '-c', command], {
stdio: 'ignore',
timeout: 1000,
})
} else {
// POSIX platforms: keep existing behavior
execSync(`${this.binShell} -n -c ${quotedCommand}`, {
stdio: 'ignore',
timeout: 1000,
})
}
} catch (stderr) {
// If there's a syntax error, return an error and log it
const errorStr =
typeof stderr === 'string' ? stderr : String(stderr || '')
} catch (error) {
// If there's a syntax error, return an error with the actual exit code
const execError = error as any
const actualExitCode = execError?.status ?? execError?.code ?? 2 // Default to 2 (syntax error) if no code available
const errorStr = execError?.stderr?.toString() || execError?.message || String(error || '')
return Promise.resolve({
stdout: '',
stderr: errorStr,
code: 128,
code: actualExitCode,
interrupted: false,
})
}
@ -432,8 +448,12 @@ export class PersistentShell {
// 2. Capture exit code immediately after command execution to avoid losing it
commandParts.push(`EXEC_EXIT_CODE=$?`)
// 3. Update CWD file
commandParts.push(`pwd > ${quoteForBash(this.cwdFileBashPath)}`)
// 3. Update CWD file (use Windows path on MSYS to keep Node path checks correct)
if (this.shellType === 'msys') {
commandParts.push(`pwd -W > ${quoteForBash(this.cwdFileBashPath)}`)
} else {
commandParts.push(`pwd > ${quoteForBash(this.cwdFileBashPath)}`)
}
// 4. Write the preserved exit code to status file to avoid race with pwd
commandParts.push(`echo $EXEC_EXIT_CODE > ${quoteForBash(this.statusFileBashPath)}`)