diff --git a/cmd/testdata/stores.yaml b/cmd/testdata/stores.yaml
index cfa520514..698aab828 100644
--- a/cmd/testdata/stores.yaml
+++ b/cmd/testdata/stores.yaml
@@ -1,35 +1,88 @@
stores:
- - name: git
- kind:
- name: atest-store-git
- enabled: true
- url: xxx
- readonly: false
- disabled: false
- - name: ai
- kind:
- name: atest-ext-ai
- enabled: true
- url: ""
- readonly: false
- disabled: false
- properties:
+ - name: git
+ kind:
+ name: atest-store-git
+ dependencies: []
+ url: "unix:///tmp/atest-store-git.sock"
+ params: []
+ link: ""
+ enabled: true
+ categories: []
+ description: ""
+ url: xxx
+ username: ""
+ password: ""
+ readonly: false
+ disabled: false
+ properties: {}
+ - name: ai
+ kind:
+ name: atest-ext-ai
+ dependencies: [] # 无依赖
+ url: "unix:///tmp/atest-ext-ai.sock"
+ params:
- key: "provider"
- description: "AI provider (local, openai, claude)"
- defaultValue: "local"
- - key: "model"
- description: "AI model name"
- defaultValue: "codellama"
+ description: "AI provider (ollama, openai, deepseek)"
+ defaultValue: "ollama"
- key: "endpoint"
- description: "AI service endpoint"
+ description: "AI service endpoint URL"
defaultValue: "http://localhost:11434"
-plugins:
- - name: atest-store-git
- url: unix:///tmp/atest-store-git.sock
- enabled: true
- - name: atest-ext-ai
- url: unix:///tmp/atest-ext-ai.sock
+ - key: "api_key"
+ description: "API key for OpenAI/Deepseek providers"
+ defaultValue: ""
+ - key: "model"
+ description: "AI model name (auto-discovered for ollama)"
+ defaultValue: ""
+ - key: "max_tokens"
+ description: "Maximum tokens for AI generation"
+ defaultValue: "4096"
+ - key: "timeout"
+ description: "Request timeout duration"
+ defaultValue: "30s"
+ link: "https://github.com/LinuxSuRen/atest-ext-ai"
enabled: true
- description: "AI Extension Plugin for intelligent SQL generation and execution"
- version: "latest"
- registry: "ghcr.io/linuxsuren/atest-ext-ai"
+ categories: ["ai", "sql-generation"]
+ description: "AI Extension Plugin for natural language to SQL conversion"
+ url: "unix:///tmp/atest-ext-ai.sock"
+ username: ""
+ password: ""
+ readonly: false
+ disabled: false
+ properties:
+ provider: "ollama"
+ endpoint: "http://localhost:11434"
+ api_key: ""
+ model: ""
+ max_tokens: "4096"
+ timeout: "30s"
+
+plugins:
+ - name: atest-store-git
+ dependencies: []
+ url: "unix:///tmp/atest-store-git.sock"
+ params: []
+ link: ""
+ enabled: true
+ categories: []
+ - name: atest-ext-ai
+ dependencies: []
+ url: "unix:///tmp/atest-ext-ai.sock"
+ params:
+ - key: "provider"
+ description: "AI provider (ollama, openai, deepseek)"
+ defaultValue: "ollama"
+ - key: "endpoint"
+ description: "AI service endpoint"
+ defaultValue: "http://localhost:11434"
+ - key: "api_key"
+ description: "API key for external AI services"
+ defaultValue: ""
+ - key: "model"
+ description: "AI model name (auto-discovered for ollama)"
+ defaultValue: ""
+ link: "https://github.com/LinuxSuRen/atest-ext-ai"
+ enabled: true
+ categories: ["ai", "sql-generation"]
+ description: "AI Extension Plugin for natural language to SQL conversion"
+ version: "v0.1.0"
+ registry: "ghcr.io/linuxsuren/atest-ext-ai"
diff --git a/console/atest-ui/package-lock.json b/console/atest-ui/package-lock.json
index cf0ac5ceb..d34b2bcc7 100644
--- a/console/atest-ui/package-lock.json
+++ b/console/atest-ui/package-lock.json
@@ -7855,22 +7855,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/happy-dom": {
- "version": "15.10.2",
- "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-15.10.2.tgz",
- "integrity": "sha512-NbA5XrSovenJIIcfixCREX3ZnV7yHP4phhbfuxxf4CPn+LZpz/jIM9EqJ2DrPwgVDSMoAKH3pZwQvkbsSiCrUw==",
- "dev": true,
- "optional": true,
- "peer": true,
- "dependencies": {
- "entities": "^4.5.0",
- "webidl-conversions": "^7.0.0",
- "whatwg-mimetype": "^3.0.0"
- },
- "engines": {
- "node": ">=18.0.0"
- }
- },
"node_modules/has": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/has/-/has-1.0.3.tgz",
@@ -14304,14 +14288,6 @@
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.6.tgz",
"integrity": "sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A=="
},
- "node_modules/undici-types": {
- "version": "7.8.0",
- "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz",
- "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==",
- "dev": true,
- "optional": true,
- "peer": true
- },
"node_modules/unist-util-is": {
"version": "6.0.0",
"resolved": "https://registry.npmmirror.com/unist-util-is/-/unist-util-is-6.0.0.tgz",
@@ -14965,17 +14941,6 @@
"node": ">=18"
}
},
- "node_modules/vite-node/node_modules/@types/node": {
- "version": "24.0.13",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.13.tgz",
- "integrity": "sha512-Qm9OYVOFHFYg3wJoTSrz80hoec5Lia/dPp84do3X7dZvLikQvM1YpmvTBEdIr/e+U8HTkFjLHLnl78K/qjf+jQ==",
- "dev": true,
- "optional": true,
- "peer": true,
- "dependencies": {
- "undici-types": "~7.8.0"
- }
- },
"node_modules/vite-node/node_modules/esbuild": {
"version": "0.25.6",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.6.tgz",
@@ -16028,17 +15993,6 @@
"node": ">=18"
}
},
- "node_modules/whatwg-mimetype": {
- "version": "3.0.0",
- "resolved": "https://registry.npmmirror.com/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
- "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
- "dev": true,
- "optional": true,
- "peer": true,
- "engines": {
- "node": ">=12"
- }
- },
"node_modules/whatwg-url": {
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
@@ -21912,19 +21866,6 @@
"integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
"dev": true
},
- "happy-dom": {
- "version": "15.10.2",
- "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-15.10.2.tgz",
- "integrity": "sha512-NbA5XrSovenJIIcfixCREX3ZnV7yHP4phhbfuxxf4CPn+LZpz/jIM9EqJ2DrPwgVDSMoAKH3pZwQvkbsSiCrUw==",
- "dev": true,
- "optional": true,
- "peer": true,
- "requires": {
- "entities": "^4.5.0",
- "webidl-conversions": "^7.0.0",
- "whatwg-mimetype": "^3.0.0"
- }
- },
"has": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/has/-/has-1.0.3.tgz",
@@ -26559,14 +26500,6 @@
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.6.tgz",
"integrity": "sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A=="
},
- "undici-types": {
- "version": "7.8.0",
- "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz",
- "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==",
- "dev": true,
- "optional": true,
- "peer": true
- },
"unist-util-is": {
"version": "6.0.0",
"resolved": "https://registry.npmmirror.com/unist-util-is/-/unist-util-is-6.0.0.tgz",
@@ -26897,17 +26830,6 @@
"dev": true,
"optional": true
},
- "@types/node": {
- "version": "24.0.13",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.13.tgz",
- "integrity": "sha512-Qm9OYVOFHFYg3wJoTSrz80hoec5Lia/dPp84do3X7dZvLikQvM1YpmvTBEdIr/e+U8HTkFjLHLnl78K/qjf+jQ==",
- "dev": true,
- "optional": true,
- "peer": true,
- "requires": {
- "undici-types": "~7.8.0"
- }
- },
"esbuild": {
"version": "0.25.6",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.6.tgz",
@@ -27477,14 +27399,6 @@
"iconv-lite": "0.6.3"
}
},
- "whatwg-mimetype": {
- "version": "3.0.0",
- "resolved": "https://registry.npmmirror.com/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
- "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
- "dev": true,
- "optional": true,
- "peer": true
- },
"whatwg-url": {
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
diff --git a/console/atest-ui/src/App.vue b/console/atest-ui/src/App.vue
index 33eddafa9..02bf68d4f 100644
--- a/console/atest-ui/src/App.vue
+++ b/console/atest-ui/src/App.vue
@@ -41,8 +41,8 @@ const appVersion = ref('')
const appVersionLink = ref('https://github.com/LinuxSuRen/api-testing')
API.GetVersion((d) => {
appVersion.value = d.version
- const version = d.version.match('^v\\d*.\\d*.\\d*')
- const dirtyVersion = d.version.match('^v\\d*.\\d*.\\d*-\\d*-g')
+ const version = d.version.match(String.raw`^v\d*.\d*.\d*`)
+ const dirtyVersion = d.version.match(String.raw`^v\d*.\d*.\d*-\d*-g`)
if (!version && !dirtyVersion) {
return
@@ -55,16 +55,18 @@ API.GetVersion((d) => {
}
})
+const hasLocalStorage = typeof globalThis !== 'undefined' && 'localStorage' in globalThis
+const storage = hasLocalStorage ? globalThis.localStorage : undefined
const isCollapse = ref(true)
watch(isCollapse, (v: boolean) => {
- window.localStorage.setItem('button.style', v ? 'simple' : '')
+ storage?.setItem('button.style', v ? 'simple' : '')
})
-const lastActiveMenu = window.localStorage.getItem('activeMenu')
+const lastActiveMenu = storage?.getItem('activeMenu') ?? 'welcome'
const activeMenu = ref(lastActiveMenu === '' ? 'welcome' : lastActiveMenu)
const panelName = ref(activeMenu)
const handleSelect = (key: string) => {
panelName.value = key
- window.localStorage.setItem('activeMenu', key)
+ storage?.setItem('activeMenu', key)
}
const locale = ref(Cache.GetPreference().language)
@@ -178,7 +180,7 @@ API.GetMenus((menus) => {
-
+
diff --git a/console/atest-ui/src/views/Extension.vue b/console/atest-ui/src/views/Extension.vue
index e8287a572..e5238dfbc 100644
--- a/console/atest-ui/src/views/Extension.vue
+++ b/console/atest-ui/src/views/Extension.vue
@@ -1,48 +1,76 @@
diff --git a/console/atest-ui/vite.config.ts b/console/atest-ui/vite.config.ts
index 49be23793..f1115cfeb 100644
--- a/console/atest-ui/vite.config.ts
+++ b/console/atest-ui/vite.config.ts
@@ -20,7 +20,7 @@ export default defineConfig({
vue({
template: {
compilerOptions: {
- nodeTransforms: true ? [removeDataTestAttrs] : [],
+ nodeTransforms: process.env.NODE_ENV === 'production' ? [removeDataTestAttrs] : [],
},
},
}),
diff --git a/pkg/server/ai_interface.go b/pkg/server/ai_interface.go
index 942ed82c7..7ce017297 100644
--- a/pkg/server/ai_interface.go
+++ b/pkg/server/ai_interface.go
@@ -32,7 +32,7 @@ const (
// - "method": "ai.generate"
// - "model": model identifier (e.g., "gpt-4", "claude")
// - "prompt": the prompt or instruction
-// - "config": optional JSON configuration string (e.g., `{"temperature": 0.7, "max_tokens": 1000}`)
+// - "config": optional JSON configuration string (e.g., `{"max_tokens": 1000}`)
// For ai.capabilities:
// - "method": "ai.capabilities"
@@ -72,7 +72,7 @@ const (
// "method": "ai.generate",
// "model": "gpt-4",
// "prompt": "Hello world",
-// "config": `{"temperature": 0.7}`,
+// "config": `{"max_tokens": 1000}`,
// })
// content := result.Pairs["content"]
diff --git a/pkg/server/remote_server.go b/pkg/server/remote_server.go
index 0ebf4204b..3b7eba394 100644
--- a/pkg/server/remote_server.go
+++ b/pkg/server/remote_server.go
@@ -1225,6 +1225,9 @@ func (s *server) GetStores(ctx context.Context, in *SimpleQuery) (reply *Stores,
wg := sync.WaitGroup{}
mu := sync.Mutex{}
for _, item := range stores {
+ // Ensure socket URL is populated for extensions that rely on Unix sockets
+ handleStore(&item)
+
skip := false
for _, kind := range kinds.Data {
if in != nil && in.Kind != "" && !slices.Contains(kind.Categories, in.Kind) {
@@ -1670,6 +1673,9 @@ func (s *server) getLoaderByStoreName(storeName string) (loader testing.Writer,
var store *testing.Store
store, err = testing.NewStoreFactory(s.configDir).GetStore(storeName)
if err == nil && store != nil {
+ // Backfill socket URL when it is missing in the store definition
+ handleStore(store)
+
loader, err = s.storeWriterFactory.NewInstance(*store)
if err != nil {
err = fmt.Errorf("failed to new grpc loader from store %s, err: %v", store.Name, err)
diff --git a/pkg/server/store_ext_manager.go b/pkg/server/store_ext_manager.go
index c53c34c8d..fc3c82066 100644
--- a/pkg/server/store_ext_manager.go
+++ b/pkg/server/store_ext_manager.go
@@ -192,6 +192,17 @@ func (s *storeExtManager) StopAll() error {
}
}
}
+
+ // Clean up socket files
+ s.lock.RLock()
+ filesToRemove := make([]string, len(s.filesNeedToBeRemoved))
+ copy(filesToRemove, s.filesNeedToBeRemoved)
+ s.lock.RUnlock()
+
+ for _, file := range filesToRemove {
+ _ = os.RemoveAll(file)
+ }
+
s.stopSingal <- struct{}{}
return nil
}
diff --git a/pkg/testing/remote/grpc_store.go b/pkg/testing/remote/grpc_store.go
index 8e25cf9ee..3ac0b2980 100644
--- a/pkg/testing/remote/grpc_store.go
+++ b/pkg/testing/remote/grpc_store.go
@@ -18,9 +18,11 @@ package remote
import (
"context"
+ "encoding/json"
"errors"
"fmt"
"strconv"
+ "strings"
"time"
"github.com/linuxsuren/api-testing/pkg/logging"
@@ -316,15 +318,28 @@ func (g *gRPCLoader) PProf(name string) []byte {
}
func (g *gRPCLoader) Query(query map[string]string) (result testing.DataResult, err error) {
+ // Detect AI method calls
+ if method := query["method"]; strings.HasPrefix(method, "ai.") {
+ return g.handleAIQuery(query)
+ }
+
+ // Original standard query logic
var dataResult *server.DataQueryResult
offset, _ := strconv.ParseInt(query["offset"], 10, 64)
limit, _ := strconv.ParseInt(query["limit"], 10, 64)
+ queryType := query["type"]
+ queryKey := query["key"]
if dataResult, err = g.client.Query(g.ctx, &server.DataQuery{
+ Type: queryType,
Sql: query["sql"],
- Key: query["key"],
+ Key: queryKey,
Offset: offset,
Limit: limit,
}); err == nil {
+ if strings.EqualFold(queryType, "ai") || strings.EqualFold(queryKey, "generate") {
+ return g.convertAIResponse(dataResult), nil
+ }
+
result.Pairs = pairToMap(dataResult.Data)
for _, item := range dataResult.Items {
result.Rows = append(result.Rows, pairToMap(item.Data))
@@ -444,3 +459,67 @@ func (g *gRPCLoader) Close() {
g.conn.Close()
}
}
+
+// handleAIQuery handles AI-specific queries
+func (g *gRPCLoader) handleAIQuery(query map[string]string) (testing.DataResult, error) {
+ method := query["method"]
+
+ var dataQuery *server.DataQuery
+ switch method {
+ case "ai.generate":
+ dataQuery = &server.DataQuery{
+ Type: "ai",
+ Key: "generate",
+ Sql: g.encodeAIGenerateParams(query),
+ }
+ case "ai.capabilities":
+ dataQuery = &server.DataQuery{
+ Type: "ai",
+ Key: "capabilities",
+ Sql: "", // No additional parameters needed
+ }
+ default:
+ return testing.DataResult{}, fmt.Errorf("unsupported AI method: %s", method)
+ }
+
+ // Call existing gRPC Query
+ dataResult, err := g.client.Query(g.ctx, dataQuery)
+ if err != nil {
+ return testing.DataResult{}, err
+ }
+
+ // Convert response to testing.DataResult format
+ return g.convertAIResponse(dataResult), nil
+}
+
+// encodeAIGenerateParams filters and encodes AI generation parameters into JSON string.
+// This function intentionally excludes routing fields (like "method") and only propagates
+// the actual AI request payload that the plugin understands.
+func (g *gRPCLoader) encodeAIGenerateParams(query map[string]string) string {
+ params := map[string]string{
+ "model": query["model"],
+ "prompt": query["prompt"],
+ "config": query["config"],
+ }
+ data, _ := json.Marshal(params)
+ return string(data)
+}
+
+// convertAIResponse converts AI response to standard format
+func (g *gRPCLoader) convertAIResponse(dataResult *server.DataQueryResult) testing.DataResult {
+ result := testing.DataResult{
+ Pairs: pairToMap(dataResult.Data),
+ }
+
+ // Map AI-specific response fields
+ if content := result.Pairs["generated_sql"]; content != "" {
+ result.Pairs["content"] = content // Follow AI interface standard
+ }
+ if result.Pairs["error"] != "" {
+ result.Pairs["success"] = "false"
+ } else {
+ result.Pairs["success"] = "true"
+ }
+
+ return result
+}