Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ jobs:
name: Docker Build & Push
runs-on: ubuntu-latest
needs: [quality, test]
if: github.event_name != 'pull_request'
permissions:
contents: read
packages: write
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ temp/

# AI model files and caches
models/
!pkg/ai/models/
!pkg/ai/models/*.yaml
!pkg/ai/models/*.go
*.model
*.cache
*.pkl
Expand Down
52 changes: 37 additions & 15 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,27 +1,49 @@
# Build stage
FROM golang:1.24-alpine AS builder
# syntax=docker/dockerfile:1.7
ARG BUILDPLATFORM
ARG TARGETPLATFORM

FROM --platform=$BUILDPLATFORM node:20-alpine AS frontend-builder
WORKDIR /workspace

# Copy repository contents (needed because frontend build emits to ../pkg/plugin/assets)
COPY . .

# Cache npm modules to speed up rebuilds
RUN --mount=type=cache,target=/root/.npm npm --prefix frontend ci
RUN npm --prefix frontend run build

FROM --platform=$BUILDPLATFORM golang:1.24-alpine AS builder
ARG TARGETOS
ARG TARGETARCH

# Install build dependencies
RUN apk add --no-cache git ca-certificates tzdata nodejs npm
RUN apk add --no-cache git ca-certificates tzdata

# Enable module caching
ENV GOMODCACHE=/go/pkg/mod
ENV GOCACHE=/root/.cache/go-build

# Set working directory
WORKDIR /app
WORKDIR /workspace

# Copy all source code first (needed for go.mod replace directive)
COPY . .
# Copy go module files and download dependencies
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/go/pkg/mod \
go mod download

# Download dependencies (replace directive requires source to be present)
RUN go mod download
# Copy source code
COPY . .

# Build frontend assets required for Go embed
RUN npm ci --prefix frontend
RUN npm run build --prefix frontend
# Copy pre-built frontend assets
COPY --from=frontend-builder /workspace/pkg/plugin/assets ./pkg/plugin/assets

# Build the binary
RUN CGO_ENABLED=0 GOOS=linux go build \
-ldflags="-s -w" \
-o bin/atest-ext-ai \
./cmd/atest-ext-ai
# Build the binary for the target platform
RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/go/pkg/mod \
CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
go build -ldflags="-s -w" -o bin/atest-ext-ai ./cmd/atest-ext-ai

# Final stage
FROM alpine:latest
Expand Down
3 changes: 2 additions & 1 deletion cmd/atest-ext-ai/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,8 @@ func cleanupSocketFile(path string) error {
func createListener(cfg listenerConfig) (net.Listener, error) {
if cfg.network == "unix" {
dir := filepath.Dir(cfg.address)
if err := os.MkdirAll(dir, 0o755); err != nil { //nolint:gosec // socket directory must remain accessible to API clients
// #nosec G301 -- socket directory must remain accessible to API clients
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("failed to create socket directory %s: %w", dir, err)
}

Expand Down
24 changes: 13 additions & 11 deletions frontend/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -152,61 +152,63 @@ async function handleTest(updatedConfig?: AIConfig) {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
max-height: 100%;
height: 100vh;
max-height: 100vh;
min-height: 0;
padding: clamp(16px, 4vw, 32px);
box-sizing: border-box;
gap: clamp(12px, 2vw, 20px);
background: var(--atest-bg-base);
overflow: hidden;
}
.welcome-panel {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
overflow: auto;
}
.chat-content {
flex: 1;
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
gap: var(--atest-spacing-md);
display: flex;
flex-direction: column;
gap: 0;
overflow: hidden;
border-radius: var(--atest-radius-lg);
background: var(--atest-bg-surface);
box-shadow: var(--atest-shadow-md);
padding: clamp(12px, 3vw, 24px);
min-height: 0;
max-height: 100%;
}
@media (max-width: 1024px) {
.ai-chat-container {
padding: 24px;
height: 100vh;
max-height: 100vh;
}
}
@media (max-width: 768px) {
.ai-chat-container {
padding: 20px;
gap: 12px;
height: 100vh;
max-height: 100vh;
}
.chat-content {
border-radius: var(--atest-radius-md);
padding: 16px;
}
}
@media (max-width: 480px) {
.ai-chat-container {
padding: 16px;
}
.chat-content {
padding: 12px;
height: 100vh;
max-height: 100vh;
}
}
</style>
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/components/AIChatHeader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,11 @@ const statusText = computed(() => t(`ai.status.${props.status}`))

<style scoped>
.ai-chat-header {
flex-shrink: 0;
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--atest-spacing-md) clamp(20px, 4vw, 28px);
padding: var(--atest-spacing-md) clamp(20px, 4vw, 40px);
background: var(--atest-bg-surface);
border-bottom: 1px solid var(--atest-border-color);
}
Expand Down
5 changes: 2 additions & 3 deletions frontend/src/components/AIChatInput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -176,11 +176,10 @@ function handleSubmit() {

<style scoped>
.chat-input {
padding: 20px 40px 24px;
flex-shrink: 0;
padding: clamp(16px, 3vw, 20px) clamp(20px, 4vw, 40px) clamp(20px, 3vw, 24px);
background: var(--atest-bg-surface);
border-top: 1px solid var(--atest-border-color);
box-shadow: var(--atest-shadow-sm);
border-radius: var(--atest-radius-md);
display: flex;
flex-direction: column;
gap: var(--atest-spacing-sm);
Expand Down
8 changes: 4 additions & 4 deletions frontend/src/components/AIChatMessages.vue
Original file line number Diff line number Diff line change
Expand Up @@ -123,14 +123,14 @@ async function copySQL(sql: string) {

<style scoped>
.chat-messages {
height: 100%;
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 24px 40px;
overflow-x: hidden;
padding: clamp(12px, 3vw, 24px) clamp(20px, 4vw, 40px);
background: var(--atest-bg-surface);
color: var(--atest-text-primary);
border: 1px solid var(--atest-border-color);
border-radius: var(--atest-radius-md);
border-bottom: 1px solid var(--atest-border-color);
}

/* Empty state */
Expand Down
9 changes: 2 additions & 7 deletions frontend/src/components/AISettingsPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -144,11 +144,7 @@
</el-option>
</template>
<template v-else>
<el-option value="gpt-5" label="GPT-5 ⭐ Recommended" />
<el-option value="gpt-5-mini" label="GPT-5 Mini" />
<el-option value="gpt-5-nano" label="GPT-5 Nano" />
<el-option value="gpt-5-pro" label="GPT-5 Pro" />
<el-option value="gpt-4.1" label="GPT-4.1" />
<el-option disabled value="no-models" :label="t('ai.welcome.noModels')" />
</template>
</el-select>
<el-button
Expand Down Expand Up @@ -218,8 +214,7 @@
</el-option>
</template>
<template v-else>
<el-option value="deepseek-reasoner" label="DeepSeek Reasoner ⭐" />
<el-option value="deepseek-chat" label="DeepSeek Chat" />
<el-option disabled value="no-models" :label="t('ai.welcome.noModels')" />
</template>
</el-select>
<el-button
Expand Down
49 changes: 46 additions & 3 deletions frontend/src/composables/useAIChat.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ref, computed, watch } from 'vue'
import type { AppContext, AIConfig, Message, Model, DatabaseDialect } from '@/types'
import { loadConfig, loadConfigForProvider, saveConfig, getMockModels, generateId, type Provider } from '@/utils/config'
import { loadConfig, loadConfigForProvider, saveConfig, generateId, type Provider } from '@/utils/config'
import { aiService } from '@/services/aiService'

/**
Expand All @@ -27,6 +27,33 @@ export function useAIChat(_context: AppContext) {
deepseek: []
})

const catalogCache = ref<Record<string, Model[]>>({})

async function initializeModelCatalog() {
try {
const catalog = await aiService.fetchModelCatalog()
const normalizedCatalog: Record<string, Model[]> = {}

Object.entries(catalog).forEach(([provider, entry]) => {
normalizedCatalog[provider] = entry.models || []
})

catalogCache.value = normalizedCatalog

for (const [provider, models] of Object.entries(normalizedCatalog)) {
if (!modelsByProvider.value[provider]) {
modelsByProvider.value[provider] = models
} else if ((modelsByProvider.value[provider] || []).length === 0 && models.length > 0) {
modelsByProvider.value[provider] = models
}
}
} catch (error) {
console.error('Failed to load model catalog', error)
}
}

void initializeModelCatalog()

// Computed property to get models for current provider
const availableModels = computed(() => {
const key = resolveProviderKey(config.value.provider)
Expand Down Expand Up @@ -98,8 +125,24 @@ export function useAIChat(_context: AppContext) {
}
} catch (error) {
console.error('Failed to fetch models:', error)
// Use mock models as fallback for this provider
modelsByProvider.value[storeKey] = getMockModels(storeKey)
const cachedFallback = catalogCache.value[storeKey]
if (cachedFallback && cachedFallback.length) {
modelsByProvider.value[storeKey] = cachedFallback
return
}

try {
const catalog = await aiService.fetchModelCatalog(storeKey)
const entry = catalog[storeKey]
const fallbackModels = entry?.models ?? []
modelsByProvider.value[storeKey] = fallbackModels
if (fallbackModels.length) {
catalogCache.value[storeKey] = fallbackModels
}
} catch (catalogError) {
console.error('Failed to fetch catalog fallback:', catalogError)
modelsByProvider.value[storeKey] = []
}
}
}

Expand Down
25 changes: 25 additions & 0 deletions frontend/src/services/aiService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,22 @@ export const aiService = {
return result.models || []
},

/**
* Fetch model catalog metadata (either for a specific provider or all providers)
*/
async fetchModelCatalog(provider?: string): Promise<Record<string, ModelCatalogEntry>> {
const payload = provider ? { provider } : {}
const result = await callAPI<{ catalog: Record<string, ModelCatalogEntry> }>('models_catalog', payload)
const catalog = result.catalog || {}

// Normalize provider keys to lowercase for consistent lookups
const normalized: Record<string, ModelCatalogEntry> = {}
for (const [key, entry] of Object.entries(catalog)) {
normalized[key.toLowerCase()] = entry
}
return normalized
},

/**
* Test connection to AI provider
*/
Expand Down Expand Up @@ -223,6 +239,15 @@ export const aiService = {
}
}

export interface ModelCatalogEntry {
display_name: string
category: string
endpoint: string
requires_api_key: boolean
models: Model[]
tags?: string[]
}

function formatTimeout(timeout: number | undefined): string {
const value = Number(timeout)
if (!Number.isFinite(value) || value <= 0) {
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@ export interface AIConfig {
export interface Model {
id: string
name: string
size: string
size?: string
description?: string
maxTokens?: number
}

/**
Expand Down
26 changes: 1 addition & 25 deletions frontend/src/utils/config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { AIConfig, Model, DatabaseDialect } from '@/types'
import type { AIConfig, DatabaseDialect } from '@/types'

export type Provider = 'ollama' | 'openai' | 'deepseek' | 'local'

Expand Down Expand Up @@ -109,30 +109,6 @@ export function getDefaultConfig(provider: string): Partial<AIConfig> {
}
}

/**
* Get mock models when API fails
*/
export function getMockModels(provider: string): Model[] {
const mocks: Record<string, Model[]> = {
ollama: [
{ id: 'llama3.2:3b', name: 'Llama 3.2 3B', size: '2GB' },
{ id: 'gemma2:9b', name: 'Gemma 2 9B', size: '5GB' }
],
openai: [
{ id: 'gpt-5', name: 'GPT-5 ⭐', size: 'Cloud' },
{ id: 'gpt-5-mini', name: 'GPT-5 Mini', size: 'Cloud' },
{ id: 'gpt-5-nano', name: 'GPT-5 Nano', size: 'Cloud' },
{ id: 'gpt-5-pro', name: 'GPT-5 Pro', size: 'Cloud' },
{ id: 'gpt-4.1', name: 'GPT-4.1', size: 'Cloud' }
],
deepseek: [
{ id: 'deepseek-chat', name: 'DeepSeek Chat', size: 'Cloud' },
{ id: 'deepseek-reasoner', name: 'DeepSeek Reasoner', size: 'Cloud' }
]
}
return mocks[provider] || []
}

/**
* Generate unique ID
*/
Expand Down
Loading
Loading