Refactor Go server model provider reading and access (#13831)
### What problem does this PR solve?
1. Refactor model provider json file format
2. Use memory data structure to replace database
3. Add CLI command to access
```
RAGFlow(user)> list pool models from 'xai';
+-------------------------------------------------------------------------------------+------------+-------------+-----------------------+
| features | max_tokens | model_types | name |
+-------------------------------------------------------------------------------------+------------+-------------+-----------------------+
| map[] | 256000 | [llm] | grok-4 |
| map[] | 131072 | [llm] | grok-3 |
| map[] | 131072 | [llm] | grok-3-fast |
| map[] | 131072 | [llm] | grok-3-mini |
| map[] | 131072 | [llm] | grok-3-mini-mini-fast |
| map[multimodal:map[enabled:true input_modalities:[image] output_modalities:[text]]] | 32768 | [vlm] | grok-2-vision |
+-------------------------------------------------------------------------------------+------------+-------------+-----------------------+
RAGFlow(user)> show pool model 'grok-2-vision' from 'xai';
+-------------------------------------------------------------------------------------+------------+-------------+---------------+
| features | max_tokens | model_types | name |
+-------------------------------------------------------------------------------------+------------+-------------+---------------+
| map[multimodal:map[enabled:true input_modalities:[image] output_modalities:[text]]] | 32768 | [vlm] | grok-2-vision |
+-------------------------------------------------------------------------------------+------------+-------------+---------------+
RAGFlow(user)> list pool providers;
+--------+------------------------------------------------------------+---------------------------+
| name | tags | url |
+--------+------------------------------------------------------------+---------------------------+
| OpenAI | LLM,TEXT EMBEDDING,TTS,TEXT RE-RANK,SPEECH2TEXT,MODERATION | https://api.openai.com/v1 |
| xAI | LLM | https://api.x.ai/v1 |
+--------+------------------------------------------------------------+---------------------------+
RAGFlow(user)> show pool provider 'openai';
+---------------------------+--------+------------------------------------------------------------+--------------+
| base_url | name | tags | total_models |
+---------------------------+--------+------------------------------------------------------------+--------------+
| https://api.openai.com/v1 | OpenAI | LLM,TEXT EMBEDDING,TTS,TEXT RE-RANK,SPEECH2TEXT,MODERATION | 27 |
+---------------------------+--------+------------------------------------------------------------+--------------+
```
### Type of change
- [x] New Feature (non-breaking change which adds functionality)
- [x] Refactoring
---------
Signed-off-by: Jin Hai <haijin.chn@gmail.com>
2026-03-30 12:00:49 +08:00
|
|
|
//
|
|
|
|
|
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
|
|
|
|
|
//
|
|
|
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
|
|
|
// you may not use this file except in compliance with the License.
|
|
|
|
|
// You may obtain a copy of the License at
|
|
|
|
|
//
|
|
|
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
|
//
|
|
|
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
|
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
|
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
|
|
|
// See the License for the specific language governing permissions and
|
|
|
|
|
// limitations under the License.
|
|
|
|
|
//
|
|
|
|
|
|
2026-03-24 20:08:36 +08:00
|
|
|
package cli
|
|
|
|
|
|
|
|
|
|
import (
|
2026-04-03 18:11:23 +08:00
|
|
|
"bufio"
|
Go: CLI chat with text, image, video (#14573)
### What problem does this PR solve?
```
RAGFlow(user)> chat with 'glm-4.6v-flash@test@zhipu-ai' message 'What are the pics talk about?' image 'https://cdn.bigmodel.cn/static/logo/register.png' 'https://cdn.bigmodel.cn/static/logo/api-key.png'
Answer: The first picture shows a login/register modal with options for phone number login, account login, and WeChat QR code login, along with a prompt for new users to get a 20 million tokens experience package. The second picture displays the API keys management page of a platform, including a warning about API key security and a table listing existing API keys with details like creation time and usage history.
Time: 31.600545
RAGFlow(user)> chat with 'glm-4.6v-flash@test@zhipu-ai' message 'What are the video talk about?' video 'https://cdn.bigmodel.cn/agent-demos/lark/113123.mov'
Answer: Based on the sequence of frames provided, the video is a demonstration of a web search and navigation process.
1. The video starts with a blank Google search page.
2. The user types "智谱" (which is the Chinese name for the company Zhipu AI) into the search box.
3. The search is initiated and the page shows "About 0 results".
4. The search results load, showing information about Zhipu AI, including its website.
5. The user clicks on the main website link (www.zhipuai.cn).
6. The video ends by showing the homepage of Zhipu AI's website, titled "Z.ai GLM Large Model Open Platform".
In summary, the video is about searching for the company "智谱" (Zhipu AI) on Google and then navigating to its official website.
Time: 76.582520
```
### Type of change
- [x] New Feature (non-breaking change which adds functionality)
Signed-off-by: Jin Hai <haijin.chn@gmail.com>
2026-05-05 18:14:39 +08:00
|
|
|
"encoding/base64"
|
2026-03-24 20:08:36 +08:00
|
|
|
"encoding/json"
|
2026-05-07 14:17:57 +08:00
|
|
|
"errors"
|
2026-03-24 20:08:36 +08:00
|
|
|
"fmt"
|
2026-05-07 14:17:57 +08:00
|
|
|
"io"
|
|
|
|
|
"net"
|
Go: CLI chat with text, image, video (#14573)
### What problem does this PR solve?
```
RAGFlow(user)> chat with 'glm-4.6v-flash@test@zhipu-ai' message 'What are the pics talk about?' image 'https://cdn.bigmodel.cn/static/logo/register.png' 'https://cdn.bigmodel.cn/static/logo/api-key.png'
Answer: The first picture shows a login/register modal with options for phone number login, account login, and WeChat QR code login, along with a prompt for new users to get a 20 million tokens experience package. The second picture displays the API keys management page of a platform, including a warning about API key security and a table listing existing API keys with details like creation time and usage history.
Time: 31.600545
RAGFlow(user)> chat with 'glm-4.6v-flash@test@zhipu-ai' message 'What are the video talk about?' video 'https://cdn.bigmodel.cn/agent-demos/lark/113123.mov'
Answer: Based on the sequence of frames provided, the video is a demonstration of a web search and navigation process.
1. The video starts with a blank Google search page.
2. The user types "智谱" (which is the Chinese name for the company Zhipu AI) into the search box.
3. The search is initiated and the page shows "About 0 results".
4. The search results load, showing information about Zhipu AI, including its website.
5. The user clicks on the main website link (www.zhipuai.cn).
6. The video ends by showing the homepage of Zhipu AI's website, titled "Z.ai GLM Large Model Open Platform".
In summary, the video is about searching for the company "智谱" (Zhipu AI) on Google and then navigating to its official website.
Time: 76.582520
```
### Type of change
- [x] New Feature (non-breaking change which adds functionality)
Signed-off-by: Jin Hai <haijin.chn@gmail.com>
2026-05-05 18:14:39 +08:00
|
|
|
netUrl "net/url"
|
2026-04-03 18:11:23 +08:00
|
|
|
"os"
|
Go: implement TTS for MiniMax provider and CLI testing for TTS (#14911)
### What problem does this PR solve?
This PR implement TTS for MiniMax provider and CLI testing for TTS
**The following functionalities are now supported:**
**MiniMax:**
- [x] Chat / Stream Chat
- [x] Embedding
- [x] Rerank
- [x] Model listing
- [x] Provider connection checking
- [x] Text To Speech
- [ ] OCRFile
- [ ] ~~Audio To Text~~
- [ ] ~~Balance~~
**Verified examples from the CLI:**
```plaintext
RAGFlow(user)> tts with 'speech-2.8-hd@test@minimax' text 'He who desires but acts not, breeds pestilence.' play format 'wav' save './internal' param '{"voice_setting": {"voice_id": "English_radiant_girl", "speed": 1, "vol": 1, "pitch": 0}, "audio_setting": {"sample_rate": 32000, "bitrate": 128000, "format": "wav", "channel": 1}, "output_format": "hex"}'
Saved to directory: /home/infiniflow/Documents/development/ragflow/internal/speech-2.8-hd_output.wav
SUCCESS
RAGFlow(user)> stream tts with 'speech-2.8-hd@test@minimax' text 'He who desires but acts not, breeds pestilence.' play format 'wav' save './internal' param '{"voice_setting": {"voice_id": "English_radiant_girl", "speed": 1, "vol": 1, "pitch": 0}, "audio_setting": {"sample_rate": 32000, "bitrate": 128000, "format": "wav", "channel": 1}, "output_format": "hex"}'
Saved to directory: /home/infiniflow/Documents/development/ragflow/internal/speech-2.8-hd_output.wav
SUCCESS
```
Set `Play` to play audio in CLI
Set `Save` `PATH_TO_SAVE` to save file
Set `format` to save file in wav or mp3
Set `Param` align with official request body
### Type of change
- [x] New Feature (non-breaking change which adds functionality)
2026-05-14 13:19:31 +08:00
|
|
|
"os/exec"
|
2026-05-12 17:17:44 +08:00
|
|
|
"path/filepath"
|
2026-06-15 10:10:14 +08:00
|
|
|
"ragflow/internal/common"
|
2026-06-12 17:58:36 +08:00
|
|
|
"ragflow/internal/ingestion"
|
2026-06-12 20:28:15 +08:00
|
|
|
"ragflow/internal/ingestion/parser"
|
|
|
|
|
"ragflow/internal/utility"
|
2026-06-18 18:07:27 +08:00
|
|
|
"regexp"
|
2026-03-24 20:08:36 +08:00
|
|
|
"strings"
|
2026-04-24 20:59:30 +08:00
|
|
|
"time"
|
2026-03-24 20:08:36 +08:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Show server version to show RAGFlow server version
|
|
|
|
|
// Returns benchmark result map if iterations > 1, otherwise prints status
|
2026-06-25 15:49:31 +08:00
|
|
|
func (c *CLI) APIShowVersionCommand(cmd *Command) (ResponseIf, error) {
|
2026-03-24 20:08:36 +08:00
|
|
|
// Get iterations from command params (for benchmark)
|
|
|
|
|
iterations := 1
|
|
|
|
|
if val, ok := cmd.Params["iterations"].(int); ok && val > 1 {
|
|
|
|
|
iterations = val
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 15:22:50 +08:00
|
|
|
httpClient := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer]
|
|
|
|
|
|
2026-03-24 20:08:36 +08:00
|
|
|
if iterations > 1 {
|
|
|
|
|
// Benchmark mode: multiple iterations
|
2026-06-09 15:22:50 +08:00
|
|
|
return httpClient.RequestWithIterations("GET", "/system/version", "web", nil, nil, iterations)
|
2026-03-24 20:08:36 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Single mode
|
2026-06-09 15:22:50 +08:00
|
|
|
resp, err := httpClient.Request("GET", "/system/version", "web", nil, nil)
|
2026-03-24 20:08:36 +08:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to show version: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != 200 {
|
|
|
|
|
return nil, fmt.Errorf("failed to show version: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var result KeyValueResponse
|
|
|
|
|
if err = json.Unmarshal(resp.Body, &result); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("show version failed: invalid JSON (%w)", err)
|
|
|
|
|
}
|
|
|
|
|
result.Key = "version"
|
|
|
|
|
result.Duration = resp.Duration
|
|
|
|
|
|
|
|
|
|
return &result, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 15:22:50 +08:00
|
|
|
func (c *CLI) ListConfigs(cmd *Command) (ResponseIf, error) {
|
|
|
|
|
if c.Config.CLIMode != APIMode {
|
|
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
2026-04-08 19:32:53 +08:00
|
|
|
}
|
|
|
|
|
// Get iterations from command params (for benchmark)
|
|
|
|
|
iterations := 1
|
|
|
|
|
if val, ok := cmd.Params["iterations"].(int); ok && val > 1 {
|
|
|
|
|
iterations = val
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 15:22:50 +08:00
|
|
|
httpClient := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer]
|
|
|
|
|
|
2026-04-08 19:32:53 +08:00
|
|
|
if iterations > 1 {
|
|
|
|
|
// Benchmark mode: multiple iterations
|
2026-06-09 15:22:50 +08:00
|
|
|
return httpClient.RequestWithIterations("GET", "/system/configs", "web", nil, nil, iterations)
|
2026-04-08 19:32:53 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Single mode
|
2026-06-09 15:22:50 +08:00
|
|
|
resp, err := httpClient.Request("GET", "/system/configs", "web", nil, nil)
|
2026-04-08 19:32:53 +08:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to list configs: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != 200 {
|
|
|
|
|
return nil, fmt.Errorf("failed to list configs: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var response CommonDataResponse
|
|
|
|
|
if err = json.Unmarshal(resp.Body, &response); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("list configs failed: invalid JSON (%w)", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var result CommonResponse
|
|
|
|
|
result.Code = 0
|
|
|
|
|
result.Data, err = GetConfigs(&response.Data)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to list configs: %w", err)
|
|
|
|
|
}
|
|
|
|
|
result.Duration = resp.Duration
|
|
|
|
|
return &result, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func GetConfigs(config *map[string]interface{}) ([]map[string]interface{}, error) {
|
|
|
|
|
if config == nil {
|
|
|
|
|
return nil, fmt.Errorf("config is nil")
|
|
|
|
|
}
|
|
|
|
|
result := []map[string]interface{}{}
|
|
|
|
|
{
|
|
|
|
|
redisHost := GetHost(config, "Redis", "Host", "Port")
|
|
|
|
|
result = append(result, map[string]interface{}{
|
|
|
|
|
"key": "redis_host",
|
|
|
|
|
"value": redisHost})
|
|
|
|
|
}
|
|
|
|
|
{
|
|
|
|
|
if docEngine, ok := (*config)["DocEngine"].(map[string]interface{}); ok {
|
|
|
|
|
engineType, _ := docEngine["Type"].(string)
|
|
|
|
|
result = append(result, map[string]interface{}{
|
|
|
|
|
"key": "doc_engine",
|
|
|
|
|
"value": engineType})
|
|
|
|
|
if engineType == "elasticsearch" {
|
|
|
|
|
esCfg, _ := docEngine["ES"].(map[string]interface{})
|
|
|
|
|
esHost, _ := esCfg["Hosts"].(string)
|
|
|
|
|
result = append(result, map[string]interface{}{
|
|
|
|
|
"key": "elasticsearch_host",
|
|
|
|
|
"value": esHost})
|
|
|
|
|
} else if engineType == "Infinity" {
|
|
|
|
|
infinityCfg, _ := docEngine["Infinity"].(map[string]interface{})
|
|
|
|
|
infinityHost, _ := infinityCfg["URI"]
|
|
|
|
|
result = append(result, map[string]interface{}{
|
|
|
|
|
"key": "infinity_host",
|
|
|
|
|
"value": infinityHost})
|
|
|
|
|
} else {
|
|
|
|
|
return nil, fmt.Errorf("unknown doc engine: %s", engineType)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
{
|
|
|
|
|
if logConfig, ok := (*config)["Log"].(map[string]interface{}); ok {
|
|
|
|
|
level, _ := logConfig["Level"].(string)
|
|
|
|
|
result = append(result, map[string]interface{}{
|
|
|
|
|
"key": "log_level",
|
|
|
|
|
"value": level})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
{
|
|
|
|
|
if databaseConfig, ok := (*config)["Database"].(map[string]interface{}); ok {
|
|
|
|
|
driver, _ := databaseConfig["Driver"].(string)
|
|
|
|
|
result = append(result, map[string]interface{}{
|
|
|
|
|
"key": "database",
|
|
|
|
|
"value": driver})
|
|
|
|
|
driverAddr, _ := databaseConfig["Host"].(string)
|
|
|
|
|
driverPort, _ := databaseConfig["Port"].(float64)
|
|
|
|
|
driverHost := fmt.Sprintf("%s:%0.f", driverAddr, driverPort)
|
|
|
|
|
result = append(result, map[string]interface{}{
|
|
|
|
|
"key": "database_host",
|
|
|
|
|
"value": driverHost})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
{
|
|
|
|
|
if language, ok := (*config)["Language"].(map[string]interface{}); ok {
|
|
|
|
|
result = append(result, map[string]interface{}{
|
|
|
|
|
"key": "language",
|
|
|
|
|
"value": language})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
{
|
|
|
|
|
if adminConfig, ok := (*config)["Admin"].(map[string]interface{}); ok {
|
|
|
|
|
adminAddr, _ := adminConfig["Host"].(string)
|
|
|
|
|
adminPort, _ := adminConfig["Port"].(float64)
|
|
|
|
|
adminHost := fmt.Sprintf("%s:%0.f", adminAddr, adminPort)
|
|
|
|
|
result = append(result, map[string]interface{}{
|
|
|
|
|
"key": "admin",
|
|
|
|
|
"value": adminHost})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
{
|
|
|
|
|
if storageEngineConfig, ok := (*config)["StorageEngine"].(map[string]interface{}); ok {
|
|
|
|
|
engineType, _ := storageEngineConfig["Type"].(string)
|
|
|
|
|
result = append(result, map[string]interface{}{
|
|
|
|
|
"key": "storage_engine",
|
|
|
|
|
"value": engineType})
|
|
|
|
|
if engineType == "minio" {
|
|
|
|
|
minioCfg, _ := storageEngineConfig["Minio"].(map[string]interface{})
|
|
|
|
|
miniHost, _ := minioCfg["Host"].(string)
|
|
|
|
|
result = append(result, map[string]interface{}{
|
|
|
|
|
"key": "minio_host",
|
|
|
|
|
"value": miniHost})
|
|
|
|
|
} else {
|
|
|
|
|
return nil, fmt.Errorf("unknown storage engine: %s", engineType)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return result, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func GetHost(config *map[string]interface{}, serverType, address, port string) string {
|
|
|
|
|
if config == nil {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
result := ""
|
|
|
|
|
|
|
|
|
|
if redis, ok := (*config)[serverType].(map[string]interface{}); ok {
|
|
|
|
|
serverAddr, hostOk := redis[address].(string)
|
|
|
|
|
serverPort, portOk := redis[port].(float64)
|
|
|
|
|
|
|
|
|
|
if hostOk && portOk {
|
|
|
|
|
result = fmt.Sprintf("%s:%.0f", serverAddr, serverPort)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-25 20:36:50 +08:00
|
|
|
func (c *CLI) APISetLogLevelCommand(cmd *Command) (ResponseIf, error) {
|
2026-06-09 15:22:50 +08:00
|
|
|
if c.Config.CLIMode != APIMode {
|
|
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
2026-04-08 19:32:53 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-25 20:36:50 +08:00
|
|
|
logLevel, ok := cmd.Params["level"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("no log level")
|
|
|
|
|
}
|
2026-04-08 19:32:53 +08:00
|
|
|
|
2026-06-25 20:36:50 +08:00
|
|
|
payload := map[string]interface{}{
|
|
|
|
|
"level": logLevel,
|
|
|
|
|
}
|
2026-04-08 19:32:53 +08:00
|
|
|
|
2026-06-25 20:36:50 +08:00
|
|
|
httpClient := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer]
|
|
|
|
|
resp, err := httpClient.Request("PUT", "/system/config/log", "admin", nil, payload)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to change log level: %w", err)
|
|
|
|
|
}
|
2026-04-08 19:32:53 +08:00
|
|
|
|
2026-06-25 20:36:50 +08:00
|
|
|
if resp.StatusCode != 200 {
|
2026-06-29 11:13:14 +08:00
|
|
|
return nil, fmt.Errorf("failed to change log level: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
2026-04-08 19:32:53 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-29 11:13:14 +08:00
|
|
|
return HandleSimpleResponse(resp, "change log level")
|
2026-04-08 19:32:53 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-09 15:22:50 +08:00
|
|
|
func (c *CLI) RegisterUser(cmd *Command) (ResponseIf, error) {
|
|
|
|
|
if c.Config.CLIMode != APIMode {
|
|
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
2026-03-24 20:08:36 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check for benchmark iterations
|
|
|
|
|
var ok bool
|
|
|
|
|
_, ok = cmd.Params["iterations"].(int)
|
|
|
|
|
if ok {
|
|
|
|
|
return nil, fmt.Errorf("failed to register user in benchmark statement")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var email string
|
|
|
|
|
email, ok = cmd.Params["user_name"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("no email")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var password string
|
|
|
|
|
password, ok = cmd.Params["password"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("no password")
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-23 17:43:26 +08:00
|
|
|
publicKey, err := c.GetPublicKeyPEM()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to get public key: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-08 15:53:06 +08:00
|
|
|
// Encrypt password using RSA
|
2026-06-23 17:43:26 +08:00
|
|
|
encryptedPassword, err := EncryptPassword(password, publicKey)
|
2026-05-08 15:53:06 +08:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to encrypt password: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-24 20:08:36 +08:00
|
|
|
var nickname string
|
|
|
|
|
nickname, ok = cmd.Params["nickname"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("no nickname")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
payload := map[string]interface{}{
|
|
|
|
|
"email": email,
|
2026-05-08 15:53:06 +08:00
|
|
|
"password": encryptedPassword,
|
2026-03-24 20:08:36 +08:00
|
|
|
"nickname": nickname,
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 15:22:50 +08:00
|
|
|
httpClient := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer]
|
|
|
|
|
resp, err := httpClient.Request("POST", "/users", "web", nil, payload)
|
2026-03-24 20:08:36 +08:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to register user: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != 200 {
|
|
|
|
|
return nil, fmt.Errorf("failed to register user: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var result RegisterResponse
|
|
|
|
|
if err = json.Unmarshal(resp.Body, &result); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("register user failed: invalid JSON (%w)", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if result.Code != 0 {
|
|
|
|
|
return nil, fmt.Errorf("%s", result.Message)
|
|
|
|
|
}
|
|
|
|
|
result.Duration = resp.Duration
|
|
|
|
|
|
|
|
|
|
return &result, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-24 16:50:40 +08:00
|
|
|
// APIListDatasetsCommand lists datasets for current user (user mode)
|
2026-03-24 20:08:36 +08:00
|
|
|
// Returns (result_map, error) - result_map is non-nil for benchmark mode
|
2026-06-24 16:50:40 +08:00
|
|
|
func (c *CLI) APIListDatasetsCommand(cmd *Command) (ResponseIf, error) {
|
2026-06-09 15:22:50 +08:00
|
|
|
if c.Config.CLIMode != APIMode {
|
2026-03-24 20:08:36 +08:00
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 15:22:50 +08:00
|
|
|
httpClient := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer]
|
|
|
|
|
|
2026-06-24 18:48:09 +08:00
|
|
|
// Determine auth kind based on whether API key is being used
|
|
|
|
|
if httpClient.LoginToken == nil && !c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].useAPIKey {
|
2026-04-08 19:32:53 +08:00
|
|
|
return nil, fmt.Errorf("no authorization")
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-26 21:07:06 +08:00
|
|
|
authKind := "web"
|
2026-06-24 18:48:09 +08:00
|
|
|
if httpClient.useAPIKey {
|
2026-03-26 21:07:06 +08:00
|
|
|
authKind = "api"
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 15:22:50 +08:00
|
|
|
if httpClient.LoginToken != nil {
|
2026-04-08 19:32:53 +08:00
|
|
|
authKind = "web"
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-24 20:08:36 +08:00
|
|
|
// Normal mode
|
2026-06-09 15:22:50 +08:00
|
|
|
resp, err := httpClient.Request("GET", "/datasets", authKind, nil, nil)
|
2026-03-24 20:08:36 +08:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to list datasets: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != 200 {
|
|
|
|
|
return nil, fmt.Errorf("failed to list datasets: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var result CommonResponse
|
|
|
|
|
if err = json.Unmarshal(resp.Body, &result); err != nil {
|
2026-05-15 14:00:45 +08:00
|
|
|
return nil, fmt.Errorf("list datasets failed: invalid JSON (%w)", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if result.Code != 0 {
|
|
|
|
|
return nil, fmt.Errorf("%s", result.Message)
|
|
|
|
|
}
|
|
|
|
|
result.Duration = resp.Duration
|
|
|
|
|
|
|
|
|
|
return &result, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-24 16:50:40 +08:00
|
|
|
func (c *CLI) APIListDatasetDocumentsCommand(cmd *Command) (ResponseIf, error) {
|
|
|
|
|
if c.Config.CLIMode != APIMode {
|
|
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
httpClient := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer]
|
2026-06-24 18:48:09 +08:00
|
|
|
// Determine auth kind based on whether API key is being used
|
|
|
|
|
if httpClient.LoginToken == nil && !httpClient.useAPIKey {
|
2026-06-24 16:50:40 +08:00
|
|
|
return nil, fmt.Errorf("no authorization")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
datasetID, ok := cmd.Params["dataset_id"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("no dataset id")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
page := 1
|
|
|
|
|
pageSize := 10
|
|
|
|
|
keywords := ""
|
|
|
|
|
returnEmptyMetadata := "true"
|
|
|
|
|
url := fmt.Sprintf("/datasets/%s/documents?page=%d&page_size=%d&keywords=%s&return_empty_metadata=%s", datasetID, page, pageSize, keywords, returnEmptyMetadata)
|
|
|
|
|
|
|
|
|
|
// Normal mode
|
|
|
|
|
resp, err := httpClient.Request("GET", url, "web", nil, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to list documents: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != 200 {
|
|
|
|
|
return nil, fmt.Errorf("failed to list documents: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var result ListDocumentsResponse
|
|
|
|
|
if err = json.Unmarshal(resp.Body, &result); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("list documents failed: invalid JSON (%w)", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if result.Code != 0 {
|
|
|
|
|
return nil, fmt.Errorf("%s", result.Message)
|
|
|
|
|
}
|
|
|
|
|
result.Duration = resp.Duration
|
|
|
|
|
|
|
|
|
|
return &result, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-25 13:41:58 +08:00
|
|
|
func (c *CLI) APIListDatasetFilesCommand(cmd *Command) (ResponseIf, error) {
|
|
|
|
|
if c.Config.CLIMode != APIMode {
|
|
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
httpClient := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer]
|
|
|
|
|
// Determine auth kind based on whether API key is being used
|
|
|
|
|
if httpClient.LoginToken == nil && !httpClient.useAPIKey {
|
|
|
|
|
return nil, fmt.Errorf("no authorization")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
datasetName, ok := cmd.Params["dataset_name"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("no dataset name")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
datasetID, err := c.getDatasetIDByName(datasetName)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to get dataset id: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
url := fmt.Sprintf("/datasets/%s/documents", datasetID)
|
|
|
|
|
|
|
|
|
|
// Normal mode
|
|
|
|
|
resp, err := httpClient.Request("GET", url, "web", nil, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to list documents: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != 200 {
|
|
|
|
|
return nil, fmt.Errorf("failed to list documents: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var result ListDocumentsResponse
|
|
|
|
|
if err = json.Unmarshal(resp.Body, &result); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("list documents failed: invalid JSON (%w)", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if result.Code != 0 {
|
|
|
|
|
return nil, fmt.Errorf("%s", result.Message)
|
|
|
|
|
}
|
|
|
|
|
result.Duration = resp.Duration
|
|
|
|
|
|
|
|
|
|
return &result, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-24 16:50:40 +08:00
|
|
|
// APIListAgentsCommand lists agents
|
|
|
|
|
func (c *CLI) APIListAgentsCommand(cmd *Command) (ResponseIf, error) {
|
|
|
|
|
if c.Config.CLIMode != APIMode {
|
|
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
httpClient := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer]
|
|
|
|
|
|
2026-06-24 18:48:09 +08:00
|
|
|
// Determine auth kind based on whether API key is being used
|
|
|
|
|
if httpClient.LoginToken == nil && !c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].useAPIKey {
|
2026-06-24 16:50:40 +08:00
|
|
|
return nil, fmt.Errorf("no authorization")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
authKind := "web"
|
2026-06-24 18:48:09 +08:00
|
|
|
if httpClient.useAPIKey {
|
2026-06-24 16:50:40 +08:00
|
|
|
authKind = "api"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if httpClient.LoginToken != nil {
|
|
|
|
|
authKind = "web"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Normal mode
|
|
|
|
|
resp, err := httpClient.Request("GET", "/agents", authKind, nil, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to list agents: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != 200 {
|
|
|
|
|
return nil, fmt.Errorf("failed to list agents: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var result ListAgentsResponse
|
|
|
|
|
if err = json.Unmarshal(resp.Body, &result); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("list agents failed: invalid JSON (%w)", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if result.Code != 0 {
|
|
|
|
|
return nil, fmt.Errorf("%s", result.Message)
|
|
|
|
|
}
|
|
|
|
|
result.Duration = resp.Duration
|
|
|
|
|
|
|
|
|
|
return &result, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// APIListChatsCommand lists chats
|
|
|
|
|
func (c *CLI) APIListChatsCommand(cmd *Command) (ResponseIf, error) {
|
|
|
|
|
if c.Config.CLIMode != APIMode {
|
|
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
httpClient := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer]
|
|
|
|
|
|
2026-06-24 18:48:09 +08:00
|
|
|
// Determine auth kind based on whether API key is being used
|
|
|
|
|
if httpClient.LoginToken == nil && !c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].useAPIKey {
|
2026-06-24 16:50:40 +08:00
|
|
|
return nil, fmt.Errorf("no authorization")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
authKind := "web"
|
2026-06-24 18:48:09 +08:00
|
|
|
if httpClient.useAPIKey {
|
2026-06-24 16:50:40 +08:00
|
|
|
authKind = "api"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if httpClient.LoginToken != nil {
|
|
|
|
|
authKind = "web"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Normal mode
|
|
|
|
|
resp, err := httpClient.Request("GET", "/chats", authKind, nil, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to list chats: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != 200 {
|
|
|
|
|
return nil, fmt.Errorf("failed to list chats: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var result ListChatsResponse
|
|
|
|
|
if err = json.Unmarshal(resp.Body, &result); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("list chats failed: invalid JSON (%w)", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if result.Code != 0 {
|
|
|
|
|
return nil, fmt.Errorf("%s", result.Message)
|
|
|
|
|
}
|
|
|
|
|
result.Duration = resp.Duration
|
|
|
|
|
|
|
|
|
|
return &result, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// APIListSearchesCommand lists searches
|
|
|
|
|
func (c *CLI) APIListSearchesCommand(cmd *Command) (ResponseIf, error) {
|
|
|
|
|
if c.Config.CLIMode != APIMode {
|
|
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
httpClient := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer]
|
|
|
|
|
|
2026-06-24 18:48:09 +08:00
|
|
|
// Determine auth kind based on whether API key is being used
|
|
|
|
|
if httpClient.LoginToken == nil && !c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].useAPIKey {
|
2026-06-24 16:50:40 +08:00
|
|
|
return nil, fmt.Errorf("no authorization")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
authKind := "web"
|
2026-06-24 18:48:09 +08:00
|
|
|
if httpClient.useAPIKey {
|
2026-06-24 16:50:40 +08:00
|
|
|
authKind = "api"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if httpClient.LoginToken != nil {
|
|
|
|
|
authKind = "web"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Normal mode
|
|
|
|
|
resp, err := httpClient.Request("GET", "/searches", authKind, nil, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to list searches: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != 200 {
|
|
|
|
|
return nil, fmt.Errorf("failed to list searches: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var result ListSearchesResponse
|
|
|
|
|
if err = json.Unmarshal(resp.Body, &result); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("list searches failed: invalid JSON (%w)", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if result.Code != 0 {
|
|
|
|
|
return nil, fmt.Errorf("%s", result.Message)
|
|
|
|
|
}
|
|
|
|
|
result.Duration = resp.Duration
|
|
|
|
|
|
|
|
|
|
return &result, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-29 11:13:14 +08:00
|
|
|
// APIListMemoriesCommand lists memories
|
|
|
|
|
func (c *CLI) APIListMemoriesCommand(cmd *Command) (ResponseIf, error) {
|
|
|
|
|
if c.Config.CLIMode != APIMode {
|
|
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
httpClient := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer]
|
|
|
|
|
|
|
|
|
|
if httpClient.LoginToken == nil && !c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].useAPIKey {
|
|
|
|
|
return nil, fmt.Errorf("no authorization")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
authKind := "web"
|
|
|
|
|
if httpClient.useAPIKey {
|
|
|
|
|
authKind = "api"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if httpClient.LoginToken != nil {
|
|
|
|
|
authKind = "web"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Normal mode
|
|
|
|
|
resp, err := httpClient.Request("GET", "/memories", authKind, nil, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to list memories: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != 200 {
|
|
|
|
|
return nil, fmt.Errorf("failed to list memories: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var result ListMemoriesResponse
|
|
|
|
|
if err = json.Unmarshal(resp.Body, &result); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("list memories failed: invalid JSON (%w)", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if result.Code != 0 {
|
|
|
|
|
return nil, fmt.Errorf("%s", result.Message)
|
|
|
|
|
}
|
|
|
|
|
result.Duration = resp.Duration
|
|
|
|
|
|
|
|
|
|
return &result, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-15 14:00:45 +08:00
|
|
|
// ListDatasetDocumentUserCommand lists dataset documents
|
2026-06-09 15:22:50 +08:00
|
|
|
func (c *CLI) ListDatasetDocumentUserCommand(cmd *Command) (ResponseIf, error) {
|
|
|
|
|
if c.Config.CLIMode != APIMode {
|
2026-05-15 14:00:45 +08:00
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check for benchmark iterations
|
|
|
|
|
iterations := 1
|
|
|
|
|
if val, ok := cmd.Params["iterations"].(int); ok && val > 1 {
|
|
|
|
|
iterations = val
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 15:22:50 +08:00
|
|
|
httpClient := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer]
|
2026-06-24 18:48:09 +08:00
|
|
|
// Determine auth kind based on whether API key is being used
|
|
|
|
|
if httpClient.LoginToken == nil && !httpClient.useAPIKey {
|
2026-05-15 14:00:45 +08:00
|
|
|
return nil, fmt.Errorf("no authorization")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
datasetID, ok := cmd.Params["dataset_id"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("no dataset id")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
page := 1
|
|
|
|
|
pageSize := 10
|
|
|
|
|
keywords := ""
|
|
|
|
|
returnEmptyMetadata := "true"
|
|
|
|
|
url := fmt.Sprintf("/datasets/%s/documents?page=%d&page_size=%d&keywords=%s&return_empty_metadata=%s", datasetID, page, pageSize, keywords, returnEmptyMetadata)
|
|
|
|
|
|
|
|
|
|
if iterations > 1 {
|
|
|
|
|
// Benchmark mode - return raw result for benchmark stats
|
2026-06-09 15:22:50 +08:00
|
|
|
return httpClient.RequestWithIterations("GET", url, "web", nil, nil, iterations)
|
2026-05-15 14:00:45 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Normal mode
|
2026-06-09 15:22:50 +08:00
|
|
|
resp, err := httpClient.Request("GET", url, "web", nil, nil)
|
2026-05-15 14:00:45 +08:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to list documents: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != 200 {
|
|
|
|
|
return nil, fmt.Errorf("failed to list documents: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var result ListDocumentsResponse
|
|
|
|
|
if err = json.Unmarshal(resp.Body, &result); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("list documents failed: invalid JSON (%w)", err)
|
2026-03-24 20:08:36 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if result.Code != 0 {
|
|
|
|
|
return nil, fmt.Errorf("%s", result.Message)
|
|
|
|
|
}
|
|
|
|
|
result.Duration = resp.Duration
|
|
|
|
|
|
|
|
|
|
return &result, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// getDatasetID gets dataset ID by name
|
2026-06-09 15:22:50 +08:00
|
|
|
func (c *CLI) getDatasetID(datasetName string) (string, error) {
|
|
|
|
|
|
|
|
|
|
httpClient := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer]
|
|
|
|
|
resp, err := httpClient.Request("GET", "/datasets", "web", nil, nil)
|
2026-03-24 20:08:36 +08:00
|
|
|
if err != nil {
|
|
|
|
|
return "", fmt.Errorf("failed to list datasets: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != 200 {
|
2026-04-07 09:44:51 +08:00
|
|
|
return "", fmt.Errorf("failed to list datasets: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
2026-03-24 20:08:36 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resJSON, err := resp.JSON()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", fmt.Errorf("invalid JSON response: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
code, ok := resJSON["code"].(float64)
|
|
|
|
|
if !ok || code != 0 {
|
|
|
|
|
msg, _ := resJSON["message"].(string)
|
|
|
|
|
return "", fmt.Errorf("failed to list datasets: %s", msg)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-07 09:44:51 +08:00
|
|
|
data, ok := resJSON["data"].([]interface{})
|
2026-03-24 20:08:36 +08:00
|
|
|
if !ok {
|
|
|
|
|
return "", fmt.Errorf("invalid response format")
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-07 09:44:51 +08:00
|
|
|
for _, kb := range data {
|
2026-03-24 20:08:36 +08:00
|
|
|
if kbMap, ok := kb.(map[string]interface{}); ok {
|
|
|
|
|
if name, _ := kbMap["name"].(string); name == datasetName {
|
|
|
|
|
if id, _ := kbMap["id"].(string); id != "" {
|
|
|
|
|
return id, nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return "", fmt.Errorf("dataset '%s' not found", datasetName)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-08 11:49:37 +08:00
|
|
|
// GetMetadata gets metadata for one or more datasets
|
2026-06-09 15:22:50 +08:00
|
|
|
func (c *CLI) GetMetadata(cmd *Command) (ResponseIf, error) {
|
|
|
|
|
if c.Config.CLIMode != APIMode {
|
2026-05-20 20:32:06 +08:00
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
datasetNames, ok := cmd.Params["dataset_names"].([]string)
|
|
|
|
|
if !ok || len(datasetNames) == 0 {
|
|
|
|
|
return nil, fmt.Errorf("dataset_names not provided")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Convert dataset names to IDs
|
|
|
|
|
datasetIDs := make([]string, 0, len(datasetNames))
|
|
|
|
|
for _, name := range datasetNames {
|
|
|
|
|
id, err := c.getDatasetID(name)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
datasetIDs = append(datasetIDs, id)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Build comma-separated dataset_ids for query param
|
|
|
|
|
datasetIDsStr := strings.Join(datasetIDs, ",")
|
|
|
|
|
|
2026-06-09 15:22:50 +08:00
|
|
|
httpClient := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer]
|
|
|
|
|
resp, err := httpClient.Request("GET", "/datasets/metadata/flattened?dataset_ids="+datasetIDsStr, "web", nil, nil)
|
2026-05-20 20:32:06 +08:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to list metadata: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != 200 {
|
|
|
|
|
return nil, fmt.Errorf("failed to list metadata: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var result MetadataResponse
|
|
|
|
|
if err = json.Unmarshal(resp.Body, &result); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("list metadata failed: invalid JSON (%w)", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if result.Code != 0 {
|
|
|
|
|
return nil, fmt.Errorf("%s", result.Message)
|
|
|
|
|
}
|
|
|
|
|
result.Duration = resp.Duration
|
|
|
|
|
|
|
|
|
|
return &result, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-24 20:08:36 +08:00
|
|
|
// formatEmptyArray converts empty arrays to "[]" string
|
|
|
|
|
func formatEmptyArray(v interface{}) string {
|
|
|
|
|
if v == nil {
|
|
|
|
|
return "[]"
|
|
|
|
|
}
|
|
|
|
|
switch val := v.(type) {
|
|
|
|
|
case []interface{}:
|
|
|
|
|
if len(val) == 0 {
|
|
|
|
|
return "[]"
|
|
|
|
|
}
|
|
|
|
|
case []string:
|
|
|
|
|
if len(val) == 0 {
|
|
|
|
|
return "[]"
|
|
|
|
|
}
|
|
|
|
|
case []int:
|
|
|
|
|
if len(val) == 0 {
|
|
|
|
|
return "[]"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return fmt.Sprintf("%v", v)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// SearchOnDatasets searches for chunks in specified datasets
|
|
|
|
|
// Returns (result_map, error) - result_map is non-nil for benchmark mode
|
2026-06-09 15:22:50 +08:00
|
|
|
func (c *CLI) SearchOnDatasets(cmd *Command) (ResponseIf, error) {
|
|
|
|
|
if c.Config.CLIMode != APIMode {
|
2026-03-24 20:08:36 +08:00
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
question, ok := cmd.Params["question"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("question not provided")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
datasets, ok := cmd.Params["datasets"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("datasets not provided")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Parse dataset names (comma-separated) and convert to IDs
|
|
|
|
|
datasetNames := strings.Split(datasets, ",")
|
|
|
|
|
datasetIDs := make([]string, 0, len(datasetNames))
|
|
|
|
|
for _, name := range datasetNames {
|
|
|
|
|
name = strings.TrimSpace(name)
|
|
|
|
|
id, err := c.getDatasetID(name)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
datasetIDs = append(datasetIDs, id)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check for benchmark iterations
|
|
|
|
|
iterations := 1
|
|
|
|
|
if val, ok := cmd.Params["iterations"].(int); ok && val > 1 {
|
|
|
|
|
iterations = val
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
payload := map[string]interface{}{
|
2026-05-08 20:25:14 +08:00
|
|
|
"dataset_ids": datasetIDs,
|
2026-03-24 20:08:36 +08:00
|
|
|
"question": question,
|
|
|
|
|
"similarity_threshold": 0.2,
|
|
|
|
|
"vector_similarity_weight": 0.3,
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-08 11:49:37 +08:00
|
|
|
// Add optional parameters from command
|
|
|
|
|
if val, ok := cmd.Params["top_k"]; ok {
|
|
|
|
|
payload["top_k"] = val
|
|
|
|
|
}
|
|
|
|
|
if val, ok := cmd.Params["similarity_threshold"]; ok {
|
|
|
|
|
payload["similarity_threshold"] = val
|
|
|
|
|
}
|
|
|
|
|
if val, ok := cmd.Params["vector_similarity_weight"]; ok {
|
|
|
|
|
payload["vector_similarity_weight"] = val
|
|
|
|
|
}
|
|
|
|
|
if val, ok := cmd.Params["keyword"]; ok {
|
|
|
|
|
payload["keyword"] = val
|
|
|
|
|
}
|
|
|
|
|
if val, ok := cmd.Params["use_kg"]; ok {
|
|
|
|
|
payload["use_kg"] = val
|
|
|
|
|
}
|
|
|
|
|
if val, ok := cmd.Params["rerank_id"]; ok {
|
|
|
|
|
payload["rerank_id"] = val
|
|
|
|
|
}
|
|
|
|
|
if val, ok := cmd.Params["tenant_rerank_id"]; ok {
|
|
|
|
|
payload["tenant_rerank_id"] = val
|
|
|
|
|
}
|
|
|
|
|
if val, ok := cmd.Params["page_size"]; ok {
|
|
|
|
|
payload["page_size"] = val
|
|
|
|
|
}
|
|
|
|
|
if val, ok := cmd.Params["page"]; ok {
|
|
|
|
|
payload["page"] = val
|
|
|
|
|
}
|
|
|
|
|
if val, ok := cmd.Params["search_id"]; ok {
|
|
|
|
|
if s, ok := val.(string); ok {
|
|
|
|
|
payload["search_id"] = s
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if val, ok := cmd.Params["cross_languages"]; ok {
|
|
|
|
|
if list, ok := val.([]string); ok {
|
|
|
|
|
payload["cross_languages"] = list
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if val, ok := cmd.Params["doc_ids"]; ok {
|
|
|
|
|
if list, ok := val.([]string); ok {
|
|
|
|
|
payload["doc_ids"] = list
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if val, ok := cmd.Params["meta_data_filter"]; ok {
|
|
|
|
|
// Accept either a raw JSON string from the CLI or a pre-decoded
|
|
|
|
|
// map[string]interface{} (future-proofing for callers that
|
|
|
|
|
// construct the command programmatically). The string form is
|
|
|
|
|
// the public CLI surface; the map form is for unit tests.
|
|
|
|
|
switch v := val.(type) {
|
|
|
|
|
case string:
|
|
|
|
|
var decoded map[string]interface{}
|
|
|
|
|
if err := json.Unmarshal([]byte(v), &decoded); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("invalid meta_data_filter JSON: %w", err)
|
|
|
|
|
}
|
|
|
|
|
payload["meta_data_filter"] = decoded
|
|
|
|
|
case map[string]interface{}:
|
|
|
|
|
payload["meta_data_filter"] = v
|
|
|
|
|
default:
|
|
|
|
|
return nil, fmt.Errorf("meta_data_filter must be JSON string or object")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 15:22:50 +08:00
|
|
|
httpClient := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer]
|
2026-03-24 20:08:36 +08:00
|
|
|
if iterations > 1 {
|
|
|
|
|
// Benchmark mode - return raw result for benchmark stats
|
2026-06-09 15:22:50 +08:00
|
|
|
return httpClient.RequestWithIterations("POST", "/datasets/search", "web", nil, payload, iterations)
|
2026-03-24 20:08:36 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Normal mode
|
2026-06-09 15:22:50 +08:00
|
|
|
resp, err := httpClient.Request("POST", "/datasets/search", "web", nil, payload)
|
2026-03-24 20:08:36 +08:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to search on datasets: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != 200 {
|
|
|
|
|
return nil, fmt.Errorf("failed to search on datasets: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resJSON, err := resp.JSON()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("invalid JSON response: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
code, ok := resJSON["code"].(float64)
|
|
|
|
|
if !ok || code != 0 {
|
|
|
|
|
msg, _ := resJSON["message"].(string)
|
|
|
|
|
return nil, fmt.Errorf("failed to search on datasets: %s", msg)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
data, ok := resJSON["data"].(map[string]interface{})
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("invalid response format")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
chunks, ok := data["chunks"].([]interface{})
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("invalid response format: chunks not found")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Convert to slice of maps for printing
|
|
|
|
|
tableData := make([]map[string]interface{}, 0, len(chunks))
|
|
|
|
|
for _, chunk := range chunks {
|
|
|
|
|
if chunkMap, ok := chunk.(map[string]interface{}); ok {
|
|
|
|
|
row := map[string]interface{}{
|
|
|
|
|
"id": chunkMap["chunk_id"],
|
|
|
|
|
"content": chunkMap["content_with_weight"],
|
|
|
|
|
"document_id": chunkMap["doc_id"],
|
|
|
|
|
"dataset_id": chunkMap["kb_id"],
|
|
|
|
|
"docnm_kwd": chunkMap["docnm_kwd"],
|
|
|
|
|
"image_id": chunkMap["image_id"],
|
|
|
|
|
"similarity": chunkMap["similarity"],
|
|
|
|
|
"term_similarity": chunkMap["term_similarity"],
|
|
|
|
|
"vector_similarity": chunkMap["vector_similarity"],
|
|
|
|
|
}
|
|
|
|
|
// Add optional fields that may be empty arrays
|
|
|
|
|
if v, ok := chunkMap["doc_type_kwd"]; ok {
|
|
|
|
|
row["doc_type_kwd"] = formatEmptyArray(v)
|
|
|
|
|
}
|
|
|
|
|
if v, ok := chunkMap["important_kwd"]; ok {
|
|
|
|
|
row["important_kwd"] = formatEmptyArray(v)
|
|
|
|
|
}
|
|
|
|
|
if v, ok := chunkMap["mom_id"]; ok {
|
|
|
|
|
row["mom_id"] = formatEmptyArray(v)
|
|
|
|
|
}
|
|
|
|
|
if v, ok := chunkMap["positions"]; ok {
|
|
|
|
|
row["positions"] = formatEmptyArray(v)
|
|
|
|
|
}
|
|
|
|
|
if v, ok := chunkMap["content_ltks"]; ok {
|
|
|
|
|
row["content_ltks"] = v
|
|
|
|
|
}
|
|
|
|
|
tableData = append(tableData, row)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
PrintTableSimple(tableData)
|
|
|
|
|
return nil, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-24 16:50:40 +08:00
|
|
|
// APICreateAPIKeyCommand creates a new API key
|
|
|
|
|
func (c *CLI) APICreateAPIKeyCommand(cmd *Command) (ResponseIf, error) {
|
2026-06-09 15:22:50 +08:00
|
|
|
if c.Config.CLIMode != APIMode {
|
2026-03-24 20:08:36 +08:00
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 15:22:50 +08:00
|
|
|
httpClient := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer]
|
2026-06-26 19:16:14 +08:00
|
|
|
|
|
|
|
|
// Determine auth kind based on whether API key is being used
|
|
|
|
|
if httpClient.LoginToken == nil && !c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].useAPIKey {
|
|
|
|
|
return nil, fmt.Errorf("no authorization")
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-24 16:50:40 +08:00
|
|
|
resp, err := httpClient.Request("POST", "/system/keys", "web", nil, nil)
|
2026-03-24 20:08:36 +08:00
|
|
|
if err != nil {
|
2026-06-24 16:50:40 +08:00
|
|
|
return nil, fmt.Errorf("failed to create key: %w", err)
|
2026-03-24 20:08:36 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != 200 {
|
2026-06-24 16:50:40 +08:00
|
|
|
return nil, fmt.Errorf("failed to create key: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
2026-03-24 20:08:36 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var createResult CommonDataResponse
|
|
|
|
|
if err = json.Unmarshal(resp.Body, &createResult); err != nil {
|
2026-06-24 16:50:40 +08:00
|
|
|
return nil, fmt.Errorf("create key failed: invalid JSON (%w)", err)
|
2026-03-24 20:08:36 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if createResult.Code != 0 {
|
|
|
|
|
return nil, fmt.Errorf("%s", createResult.Message)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var result SimpleResponse
|
|
|
|
|
result.Code = 0
|
2026-06-24 16:50:40 +08:00
|
|
|
result.Message = "API Key created successfully"
|
2026-03-24 20:08:36 +08:00
|
|
|
result.Duration = resp.Duration
|
|
|
|
|
return &result, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-26 19:16:14 +08:00
|
|
|
func (c *CLI) APICreateDatasetCommand(cmd *Command) (ResponseIf, error) {
|
|
|
|
|
if c.Config.CLIMode != APIMode {
|
|
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
httpClient := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer]
|
|
|
|
|
|
|
|
|
|
// Determine auth kind based on whether API key is being used
|
|
|
|
|
if httpClient.LoginToken == nil && !c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].useAPIKey {
|
|
|
|
|
return nil, fmt.Errorf("no authorization")
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-29 11:13:14 +08:00
|
|
|
datasetName, ok := cmd.Params["dataset_name"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("dataset_name parameter is required")
|
2026-06-26 19:16:14 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-29 11:13:14 +08:00
|
|
|
payload := map[string]interface{}{
|
|
|
|
|
"name": datasetName,
|
2026-06-26 19:16:14 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-29 11:13:14 +08:00
|
|
|
resp, err := httpClient.Request("POST", "/datasets", "web", nil, payload)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to create dataset: %w", err)
|
2026-06-26 19:16:14 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-29 11:13:14 +08:00
|
|
|
if resp.StatusCode != 200 {
|
|
|
|
|
return nil, fmt.Errorf("failed to create dataset: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
2026-06-26 19:16:14 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-29 11:13:14 +08:00
|
|
|
return HandleSimpleResponse(resp, "create dataset")
|
2026-06-26 19:16:14 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *CLI) APICreateAgentCommand(cmd *Command) (ResponseIf, error) {
|
|
|
|
|
if c.Config.CLIMode != APIMode {
|
|
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
httpClient := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer]
|
|
|
|
|
|
|
|
|
|
// Determine auth kind based on whether API key is being used
|
|
|
|
|
if httpClient.LoginToken == nil && !c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].useAPIKey {
|
|
|
|
|
return nil, fmt.Errorf("no authorization")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resp, err := httpClient.Request("POST", "/agents", "web", nil, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to create agent: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != 200 {
|
|
|
|
|
return nil, fmt.Errorf("failed to create agent: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var createResult CommonDataResponse
|
|
|
|
|
if err = json.Unmarshal(resp.Body, &createResult); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("create agent failed: invalid JSON (%w)", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if createResult.Code != 0 {
|
|
|
|
|
return nil, fmt.Errorf("%s", createResult.Message)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var result SimpleResponse
|
|
|
|
|
result.Code = 0
|
|
|
|
|
result.Message = "Agent created successfully"
|
|
|
|
|
result.Duration = resp.Duration
|
|
|
|
|
return &result, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *CLI) APICreateChatCommand(cmd *Command) (ResponseIf, error) {
|
|
|
|
|
if c.Config.CLIMode != APIMode {
|
|
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
httpClient := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer]
|
|
|
|
|
|
|
|
|
|
// Determine auth kind based on whether API key is being used
|
|
|
|
|
if httpClient.LoginToken == nil && !c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].useAPIKey {
|
|
|
|
|
return nil, fmt.Errorf("no authorization")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resp, err := httpClient.Request("POST", "/chats", "web", nil, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to create chat: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != 200 {
|
|
|
|
|
return nil, fmt.Errorf("failed to create chat: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var createResult CommonDataResponse
|
|
|
|
|
if err = json.Unmarshal(resp.Body, &createResult); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("create chat failed: invalid JSON (%w)", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if createResult.Code != 0 {
|
|
|
|
|
return nil, fmt.Errorf("%s", createResult.Message)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var result SimpleResponse
|
|
|
|
|
result.Code = 0
|
|
|
|
|
result.Message = "Chat created successfully"
|
|
|
|
|
result.Duration = resp.Duration
|
|
|
|
|
return &result, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *CLI) APICreateSearchCommand(cmd *Command) (ResponseIf, error) {
|
|
|
|
|
if c.Config.CLIMode != APIMode {
|
|
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
httpClient := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer]
|
|
|
|
|
|
|
|
|
|
// Determine auth kind based on whether API key is being used
|
|
|
|
|
if httpClient.LoginToken == nil && !c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].useAPIKey {
|
|
|
|
|
return nil, fmt.Errorf("no authorization")
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-29 11:13:14 +08:00
|
|
|
searchName, ok := cmd.Params["search_name"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("search_name parameter is required")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
payload := map[string]interface{}{
|
|
|
|
|
"name": searchName,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resp, err := httpClient.Request("POST", "/searches", "web", nil, payload)
|
2026-06-26 19:16:14 +08:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to create search: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != 200 {
|
|
|
|
|
return nil, fmt.Errorf("failed to create search: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var createResult CommonDataResponse
|
|
|
|
|
if err = json.Unmarshal(resp.Body, &createResult); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("create search failed: invalid JSON (%w)", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if createResult.Code != 0 {
|
|
|
|
|
return nil, fmt.Errorf("%s", createResult.Message)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var result SimpleResponse
|
|
|
|
|
result.Code = 0
|
|
|
|
|
result.Message = "Search created successfully"
|
|
|
|
|
result.Duration = resp.Duration
|
|
|
|
|
return &result, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *CLI) APICreateMemoryCommand(cmd *Command) (ResponseIf, error) {
|
|
|
|
|
if c.Config.CLIMode != APIMode {
|
|
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
httpClient := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer]
|
|
|
|
|
|
|
|
|
|
// Determine auth kind based on whether API key is being used
|
|
|
|
|
if httpClient.LoginToken == nil && !c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].useAPIKey {
|
|
|
|
|
return nil, fmt.Errorf("no authorization")
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-29 11:13:14 +08:00
|
|
|
memoryName, ok := cmd.Params["memory_name"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("memory_name parameter is required")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
payload := map[string]interface{}{
|
|
|
|
|
"name": memoryName,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resp, err := httpClient.Request("POST", "/memories", "web", nil, payload)
|
2026-06-26 19:16:14 +08:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to create memory: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != 200 {
|
|
|
|
|
return nil, fmt.Errorf("failed to create memory: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var createResult CommonDataResponse
|
|
|
|
|
if err = json.Unmarshal(resp.Body, &createResult); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("create memory failed: invalid JSON (%w)", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if createResult.Code != 0 {
|
|
|
|
|
return nil, fmt.Errorf("%s", createResult.Message)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var result SimpleResponse
|
|
|
|
|
result.Code = 0
|
|
|
|
|
result.Message = "Memory created successfully"
|
|
|
|
|
result.Duration = resp.Duration
|
|
|
|
|
return &result, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-24 16:50:40 +08:00
|
|
|
// APIListAPIKeysCommand lists all API keys for the current user
|
|
|
|
|
func (c *CLI) APIListAPIKeysCommand(cmd *Command) (ResponseIf, error) {
|
2026-06-09 15:22:50 +08:00
|
|
|
if c.Config.CLIMode != APIMode {
|
2026-03-24 20:08:36 +08:00
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 15:22:50 +08:00
|
|
|
httpClient := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer]
|
2026-06-24 16:50:40 +08:00
|
|
|
resp, err := httpClient.Request("GET", "/system/keys", "web", nil, nil)
|
2026-03-24 20:08:36 +08:00
|
|
|
if err != nil {
|
2026-06-24 16:50:40 +08:00
|
|
|
return nil, fmt.Errorf("failed to list keys: %w", err)
|
2026-03-24 20:08:36 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != 200 {
|
2026-06-24 16:50:40 +08:00
|
|
|
return nil, fmt.Errorf("failed to list keys: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
2026-03-24 20:08:36 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var result CommonResponse
|
|
|
|
|
if err = json.Unmarshal(resp.Body, &result); err != nil {
|
2026-06-24 16:50:40 +08:00
|
|
|
return nil, fmt.Errorf("list keys failed: invalid JSON (%w)", err)
|
2026-03-24 20:08:36 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if result.Code != 0 {
|
|
|
|
|
return nil, fmt.Errorf("%s", result.Message)
|
|
|
|
|
}
|
|
|
|
|
result.Duration = resp.Duration
|
|
|
|
|
return &result, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-24 16:50:40 +08:00
|
|
|
// APIDeleteAPIKeyCommand deletes an API key
|
|
|
|
|
func (c *CLI) APIDeleteAPIKeyCommand(cmd *Command) (ResponseIf, error) {
|
2026-06-09 15:22:50 +08:00
|
|
|
if c.Config.CLIMode != APIMode {
|
2026-03-24 20:08:36 +08:00
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-24 16:50:40 +08:00
|
|
|
apiKey, ok := cmd.Params["api_key"].(string)
|
2026-03-24 20:08:36 +08:00
|
|
|
if !ok {
|
2026-06-24 16:50:40 +08:00
|
|
|
return nil, fmt.Errorf("key not provided")
|
2026-03-24 20:08:36 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-24 16:50:40 +08:00
|
|
|
resp, err := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].Request("DELETE", fmt.Sprintf("/system/keys/%s", apiKey), "web", nil, nil)
|
2026-03-24 20:08:36 +08:00
|
|
|
if err != nil {
|
2026-06-24 16:50:40 +08:00
|
|
|
return nil, fmt.Errorf("failed to delete key: %w", err)
|
2026-03-24 20:08:36 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != 200 {
|
2026-06-24 16:50:40 +08:00
|
|
|
return nil, fmt.Errorf("failed to delete key: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
2026-03-24 20:08:36 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-29 11:13:14 +08:00
|
|
|
return HandleSimpleResponse(resp, "delete key")
|
2026-03-24 20:08:36 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-25 20:36:50 +08:00
|
|
|
// APISetAPIKeyCommand sets the API key after validating it
|
|
|
|
|
func (c *CLI) APISetAPIKeyCommand(cmd *Command) (ResponseIf, error) {
|
2026-06-09 15:22:50 +08:00
|
|
|
if c.Config.CLIMode != APIMode {
|
2026-03-24 20:08:36 +08:00
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-24 18:48:09 +08:00
|
|
|
apiKey, ok := cmd.Params["api_key"].(string)
|
2026-03-24 20:08:36 +08:00
|
|
|
if !ok {
|
2026-06-24 18:48:09 +08:00
|
|
|
return nil, fmt.Errorf("key not provided")
|
2026-03-24 20:08:36 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Save current token to restore if validation fails
|
2026-06-24 18:48:09 +08:00
|
|
|
savedToken := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].APIKey
|
|
|
|
|
savedUseAPIToken := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].useAPIKey
|
2026-03-24 20:08:36 +08:00
|
|
|
|
2026-06-24 18:48:09 +08:00
|
|
|
// Set the new key temporarily for validation
|
|
|
|
|
c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].APIKey = &apiKey
|
|
|
|
|
c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].useAPIKey = true
|
2026-03-24 20:08:36 +08:00
|
|
|
|
|
|
|
|
// Validate token by calling list tokens API
|
2026-06-24 18:48:09 +08:00
|
|
|
resp, err := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].Request("GET", "/system/tokens", "api", nil, nil)
|
2026-03-24 20:08:36 +08:00
|
|
|
if err != nil {
|
|
|
|
|
// Restore original token on error
|
2026-06-24 18:48:09 +08:00
|
|
|
c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].APIKey = savedToken
|
|
|
|
|
c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].useAPIKey = savedUseAPIToken
|
2026-03-24 20:08:36 +08:00
|
|
|
return nil, fmt.Errorf("failed to validate token: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != 200 {
|
|
|
|
|
// Restore original token on error
|
2026-06-24 18:48:09 +08:00
|
|
|
c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].APIKey = savedToken
|
|
|
|
|
c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].useAPIKey = savedUseAPIToken
|
2026-03-24 20:08:36 +08:00
|
|
|
return nil, fmt.Errorf("token validation failed: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var result CommonResponse
|
|
|
|
|
if err = json.Unmarshal(resp.Body, &result); err != nil {
|
|
|
|
|
// Restore original token on error
|
2026-06-24 18:48:09 +08:00
|
|
|
c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].APIKey = savedToken
|
|
|
|
|
c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].useAPIKey = savedUseAPIToken
|
2026-03-24 20:08:36 +08:00
|
|
|
return nil, fmt.Errorf("token validation failed: invalid JSON (%w)", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if result.Code != 0 {
|
|
|
|
|
// Restore original token on error
|
2026-06-24 18:48:09 +08:00
|
|
|
c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].APIKey = savedToken
|
|
|
|
|
c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].useAPIKey = savedUseAPIToken
|
2026-03-24 20:08:36 +08:00
|
|
|
return nil, fmt.Errorf("token validation failed: %s", result.Message)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Token is valid, keep it set
|
|
|
|
|
var successResult SimpleResponse
|
|
|
|
|
successResult.Code = 0
|
2026-06-24 18:48:09 +08:00
|
|
|
successResult.Message = "API key set successfully"
|
2026-03-24 20:08:36 +08:00
|
|
|
successResult.Duration = resp.Duration
|
|
|
|
|
return &successResult, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-25 20:36:50 +08:00
|
|
|
// APISetVariableCommand sets variable value
|
|
|
|
|
func (c *CLI) APISetVariableCommand(cmd *Command) (ResponseIf, error) {
|
|
|
|
|
|
|
|
|
|
if c.Config.CLIMode != APIMode {
|
|
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].APIKey == nil && c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].LoginToken == nil {
|
|
|
|
|
return nil, fmt.Errorf("API key not set. Please login first")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
varName, ok := cmd.Params["var_name"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("var_name not provided")
|
|
|
|
|
}
|
|
|
|
|
varValue, ok := cmd.Params["var_value"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("var_value not provided")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
payload := map[string]interface{}{
|
|
|
|
|
"var_name": varName,
|
|
|
|
|
"var_value": varValue,
|
|
|
|
|
}
|
|
|
|
|
resp, err := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].Request("PUT", "/system/variables", "web", nil, payload)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to set variable: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != 200 {
|
|
|
|
|
return nil, fmt.Errorf("failed to set variable: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var result MessageResponse
|
|
|
|
|
if err = json.Unmarshal(resp.Body, &result); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("set variable failed: invalid JSON (%w)", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if result.Code != 0 {
|
|
|
|
|
return nil, fmt.Errorf("%s", result.Message)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
result.Duration = resp.Duration
|
2026-06-26 13:51:56 +08:00
|
|
|
return &result, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// APIShowVariableCommand displays variable value
|
|
|
|
|
func (c *CLI) APIShowVariableCommand(cmd *Command) (ResponseIf, error) {
|
|
|
|
|
|
|
|
|
|
if c.Config.CLIMode != APIMode {
|
|
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
httpClient := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer]
|
|
|
|
|
|
|
|
|
|
if httpClient.APIKey == nil && httpClient.LoginToken == nil {
|
|
|
|
|
return nil, fmt.Errorf("API key not set. Please login first")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
varName, ok := cmd.Params["var_name"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("var_name not provided")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
EncodedVarName := common.EncodeToBase64(varName)
|
|
|
|
|
|
|
|
|
|
endPoint := fmt.Sprintf("/system/variables/%s", EncodedVarName)
|
|
|
|
|
|
|
|
|
|
resp, err := httpClient.Request("GET", endPoint, "web", nil, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to get variable: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != 200 {
|
|
|
|
|
return nil, fmt.Errorf("failed to get variable: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var result CommonResponse
|
|
|
|
|
if err = json.Unmarshal(resp.Body, &result); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("show variable failed: invalid JSON (%w)", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if result.Code != 0 {
|
|
|
|
|
return nil, fmt.Errorf("%s", result.Message)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
normalizeVariableRows(result.Data)
|
|
|
|
|
result.Duration = resp.Duration
|
2026-06-25 20:36:50 +08:00
|
|
|
return &result, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-25 15:49:31 +08:00
|
|
|
// APIShowAPIKeyCommand displays the current API key
|
|
|
|
|
func (c *CLI) APIShowAPIKeyCommand(cmd *Command) (ResponseIf, error) {
|
2026-06-09 15:22:50 +08:00
|
|
|
if c.Config.CLIMode != APIMode {
|
2026-03-24 20:08:36 +08:00
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-24 18:48:09 +08:00
|
|
|
if c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].APIKey == nil {
|
|
|
|
|
return nil, fmt.Errorf("no API key is currently set")
|
2026-03-24 20:08:36 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-25 15:49:31 +08:00
|
|
|
var result CommonDataResponse
|
2026-03-24 20:08:36 +08:00
|
|
|
result.Code = 0
|
|
|
|
|
result.Message = ""
|
2026-06-25 15:49:31 +08:00
|
|
|
result.Data = map[string]interface{}{
|
|
|
|
|
"token": *c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].APIKey,
|
2026-03-24 20:08:36 +08:00
|
|
|
}
|
|
|
|
|
result.Duration = 0
|
|
|
|
|
return &result, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-25 15:49:31 +08:00
|
|
|
// APIUnsetAPIKeyCommand removes the current API key
|
|
|
|
|
func (c *CLI) APIUnsetAPIKeyCommand(cmd *Command) (ResponseIf, error) {
|
2026-06-09 15:22:50 +08:00
|
|
|
if c.Config.CLIMode != APIMode {
|
2026-03-24 20:08:36 +08:00
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-24 18:48:09 +08:00
|
|
|
if c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].APIKey == nil {
|
|
|
|
|
return nil, fmt.Errorf("no API key is currently set")
|
2026-03-24 20:08:36 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-24 18:48:09 +08:00
|
|
|
c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].APIKey = nil
|
|
|
|
|
c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].useAPIKey = false
|
2026-03-24 20:08:36 +08:00
|
|
|
|
|
|
|
|
var result SimpleResponse
|
|
|
|
|
result.Code = 0
|
2026-06-24 18:48:09 +08:00
|
|
|
result.Message = "API key unset successfully"
|
2026-03-24 20:08:36 +08:00
|
|
|
result.Duration = 0
|
|
|
|
|
return &result, nil
|
|
|
|
|
}
|
2026-03-26 11:54:10 +08:00
|
|
|
|
2026-05-25 19:15:07 +08:00
|
|
|
// CreateChunkStore creates a chunk store in doc engine
|
2026-06-09 15:22:50 +08:00
|
|
|
func (c *CLI) CreateChunkStore(cmd *Command) (ResponseIf, error) {
|
|
|
|
|
if c.Config.CLIMode != APIMode {
|
2026-03-26 11:54:10 +08:00
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
datasetName, ok := cmd.Params["dataset_name"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("dataset_name not provided")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
vectorSize, ok := cmd.Params["vector_size"].(int)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("vector_size not provided")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get dataset ID by name
|
|
|
|
|
datasetID, err := c.getDatasetID(datasetName)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
payload := map[string]interface{}{
|
|
|
|
|
"kb_id": datasetID,
|
|
|
|
|
"vector_size": vectorSize,
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 15:22:50 +08:00
|
|
|
resp, err := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].Request("POST", "/tenant/chunk_store", "web", nil, payload)
|
2026-03-26 11:54:10 +08:00
|
|
|
if err != nil {
|
2026-05-25 19:15:07 +08:00
|
|
|
return nil, fmt.Errorf("failed to create chunk store: %w", err)
|
2026-03-26 11:54:10 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != 200 {
|
2026-05-25 19:15:07 +08:00
|
|
|
return nil, fmt.Errorf("failed to create chunk store: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
2026-03-26 11:54:10 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resJSON, err := resp.JSON()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("invalid JSON response: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
code, ok := resJSON["code"].(float64)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("invalid response format: code is not a number")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var result SimpleResponse
|
|
|
|
|
result.Code = int(code)
|
|
|
|
|
if result.Code == 0 {
|
2026-05-25 19:15:07 +08:00
|
|
|
result.Message = fmt.Sprintf("Success to create chunk store for dataset: %s", datasetName)
|
2026-03-26 11:54:10 +08:00
|
|
|
} else {
|
2026-05-25 19:15:07 +08:00
|
|
|
result.Message = fmt.Sprintf("Failed to create chunk store: %v", resJSON)
|
2026-03-26 11:54:10 +08:00
|
|
|
}
|
|
|
|
|
result.Duration = 0
|
|
|
|
|
return &result, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-25 19:15:07 +08:00
|
|
|
// DropChunkStore drops a chunk store in doc engine
|
2026-06-09 15:22:50 +08:00
|
|
|
func (c *CLI) DropChunkStore(cmd *Command) (ResponseIf, error) {
|
|
|
|
|
if c.Config.CLIMode != APIMode {
|
2026-03-26 11:54:10 +08:00
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
datasetName, ok := cmd.Params["dataset_name"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("dataset_name not provided")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get dataset ID by name
|
|
|
|
|
datasetID, err := c.getDatasetID(datasetName)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
payload := map[string]interface{}{
|
|
|
|
|
"kb_id": datasetID,
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 15:22:50 +08:00
|
|
|
resp, err := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].Request("DELETE", "/tenant/chunk_store", "web", nil, payload)
|
2026-03-26 11:54:10 +08:00
|
|
|
if err != nil {
|
2026-04-09 09:52:31 +08:00
|
|
|
return nil, fmt.Errorf("failed to drop dataset: %w", err)
|
2026-03-26 11:54:10 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != 200 {
|
2026-04-09 09:52:31 +08:00
|
|
|
return nil, fmt.Errorf("failed to drop dataset: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
2026-03-26 11:54:10 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resJSON, err := resp.JSON()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("invalid JSON response: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
code, ok := resJSON["code"].(float64)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("invalid response format: code is not a number")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var result SimpleResponse
|
|
|
|
|
result.Code = int(code)
|
|
|
|
|
if result.Code == 0 {
|
2026-05-25 19:15:07 +08:00
|
|
|
result.Message = fmt.Sprintf("Success to drop chunk store for dataset: %s", datasetName)
|
2026-03-26 11:54:10 +08:00
|
|
|
} else {
|
2026-05-25 19:15:07 +08:00
|
|
|
result.Message = fmt.Sprintf("Failed to drop chunk store for dataset: %s: %v", datasetName, resJSON)
|
2026-03-26 11:54:10 +08:00
|
|
|
}
|
|
|
|
|
result.Duration = 0
|
|
|
|
|
return &result, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-25 19:15:07 +08:00
|
|
|
// CreateMetadataStore creates the document metadata store for the tenant
|
2026-06-09 15:22:50 +08:00
|
|
|
func (c *CLI) CreateMetadataStore(cmd *Command) (ResponseIf, error) {
|
|
|
|
|
if c.Config.CLIMode != APIMode {
|
2026-03-26 11:54:10 +08:00
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 15:22:50 +08:00
|
|
|
resp, err := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].Request("POST", "/tenant/metadata_store", "web", nil, nil)
|
2026-03-26 11:54:10 +08:00
|
|
|
if err != nil {
|
2026-05-25 19:15:07 +08:00
|
|
|
return nil, fmt.Errorf("failed to create metadata store: %w", err)
|
2026-03-26 11:54:10 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != 200 {
|
2026-05-25 19:15:07 +08:00
|
|
|
return nil, fmt.Errorf("failed to create metadata store: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
2026-03-26 11:54:10 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resJSON, err := resp.JSON()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("invalid JSON response: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
code, ok := resJSON["code"].(float64)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("invalid response format: code is not a number")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var result SimpleResponse
|
|
|
|
|
result.Code = int(code)
|
|
|
|
|
if result.Code == 0 {
|
2026-05-25 19:15:07 +08:00
|
|
|
result.Message = "Success to create metadata store"
|
2026-03-26 11:54:10 +08:00
|
|
|
} else {
|
2026-05-25 19:15:07 +08:00
|
|
|
result.Message = fmt.Sprintf("Failed to create metadata store: %v", resJSON)
|
2026-03-26 11:54:10 +08:00
|
|
|
}
|
|
|
|
|
result.Duration = 0
|
|
|
|
|
return &result, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-25 19:15:07 +08:00
|
|
|
// DropMetadataStore drops the document metadata store for the tenant
|
2026-06-09 15:22:50 +08:00
|
|
|
func (c *CLI) DropMetadataStore(cmd *Command) (ResponseIf, error) {
|
|
|
|
|
if c.Config.CLIMode != APIMode {
|
2026-03-26 11:54:10 +08:00
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 15:22:50 +08:00
|
|
|
resp, err := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].Request("DELETE", "/tenant/metadata_store", "web", nil, nil)
|
2026-03-26 11:54:10 +08:00
|
|
|
if err != nil {
|
2026-05-25 19:15:07 +08:00
|
|
|
return nil, fmt.Errorf("failed to drop metadata store: %w", err)
|
2026-03-26 11:54:10 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != 200 {
|
2026-05-25 19:15:07 +08:00
|
|
|
return nil, fmt.Errorf("failed to drop metadata store: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
2026-03-26 11:54:10 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resJSON, err := resp.JSON()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("invalid JSON response: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
code, ok := resJSON["code"].(float64)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("invalid response format: code is not a number")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var result SimpleResponse
|
|
|
|
|
result.Code = int(code)
|
|
|
|
|
if result.Code == 0 {
|
2026-05-25 19:15:07 +08:00
|
|
|
result.Message = "Success to drop metadata store"
|
2026-03-26 11:54:10 +08:00
|
|
|
} else {
|
2026-05-25 19:15:07 +08:00
|
|
|
result.Message = fmt.Sprintf("Failed to drop metadata store: %v", resJSON)
|
2026-03-26 11:54:10 +08:00
|
|
|
}
|
|
|
|
|
result.Duration = 0
|
|
|
|
|
return &result, nil
|
|
|
|
|
}
|
Refactor Go server model provider reading and access (#13831)
### What problem does this PR solve?
1. Refactor model provider json file format
2. Use memory data structure to replace database
3. Add CLI command to access
```
RAGFlow(user)> list pool models from 'xai';
+-------------------------------------------------------------------------------------+------------+-------------+-----------------------+
| features | max_tokens | model_types | name |
+-------------------------------------------------------------------------------------+------------+-------------+-----------------------+
| map[] | 256000 | [llm] | grok-4 |
| map[] | 131072 | [llm] | grok-3 |
| map[] | 131072 | [llm] | grok-3-fast |
| map[] | 131072 | [llm] | grok-3-mini |
| map[] | 131072 | [llm] | grok-3-mini-mini-fast |
| map[multimodal:map[enabled:true input_modalities:[image] output_modalities:[text]]] | 32768 | [vlm] | grok-2-vision |
+-------------------------------------------------------------------------------------+------------+-------------+-----------------------+
RAGFlow(user)> show pool model 'grok-2-vision' from 'xai';
+-------------------------------------------------------------------------------------+------------+-------------+---------------+
| features | max_tokens | model_types | name |
+-------------------------------------------------------------------------------------+------------+-------------+---------------+
| map[multimodal:map[enabled:true input_modalities:[image] output_modalities:[text]]] | 32768 | [vlm] | grok-2-vision |
+-------------------------------------------------------------------------------------+------------+-------------+---------------+
RAGFlow(user)> list pool providers;
+--------+------------------------------------------------------------+---------------------------+
| name | tags | url |
+--------+------------------------------------------------------------+---------------------------+
| OpenAI | LLM,TEXT EMBEDDING,TTS,TEXT RE-RANK,SPEECH2TEXT,MODERATION | https://api.openai.com/v1 |
| xAI | LLM | https://api.x.ai/v1 |
+--------+------------------------------------------------------------+---------------------------+
RAGFlow(user)> show pool provider 'openai';
+---------------------------+--------+------------------------------------------------------------+--------------+
| base_url | name | tags | total_models |
+---------------------------+--------+------------------------------------------------------------+--------------+
| https://api.openai.com/v1 | OpenAI | LLM,TEXT EMBEDDING,TTS,TEXT RE-RANK,SPEECH2TEXT,MODERATION | 27 |
+---------------------------+--------+------------------------------------------------------------+--------------+
```
### Type of change
- [x] New Feature (non-breaking change which adds functionality)
- [x] Refactoring
---------
Signed-off-by: Jin Hai <haijin.chn@gmail.com>
2026-03-30 12:00:49 +08:00
|
|
|
|
2026-04-02 20:20:35 +08:00
|
|
|
// AddProvider creates a new model provider
|
|
|
|
|
// ADD PROVIDER <name>
|
2026-06-09 15:22:50 +08:00
|
|
|
func (c *CLI) AddProvider(cmd *Command) (ResponseIf, error) {
|
|
|
|
|
if c.Config.CLIMode != APIMode {
|
2026-03-31 18:42:12 +08:00
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
providerName, ok := cmd.Params["provider_name"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("provider name not provided")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Build payload
|
|
|
|
|
payload := map[string]interface{}{
|
2026-04-02 20:20:35 +08:00
|
|
|
"provider_name": providerName,
|
2026-03-31 18:42:12 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-09 15:22:50 +08:00
|
|
|
resp, err := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].Request("PUT", "/providers", "web", nil, payload)
|
2026-03-31 18:42:12 +08:00
|
|
|
if err != nil {
|
2026-04-02 20:20:35 +08:00
|
|
|
return nil, fmt.Errorf("failed to add provider: %w", err)
|
2026-03-31 18:42:12 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != 200 {
|
2026-04-02 20:20:35 +08:00
|
|
|
return nil, fmt.Errorf("failed to add provider: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
2026-03-31 18:42:12 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-29 11:13:14 +08:00
|
|
|
return HandleSimpleResponse(resp, "add provider")
|
2026-03-31 18:42:12 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-25 10:01:21 +08:00
|
|
|
// APIListProviders lists added providers
|
|
|
|
|
func (c *CLI) APIListProviders(cmd *Command) (ResponseIf, error) {
|
2026-06-09 15:22:50 +08:00
|
|
|
if c.Config.CLIMode != APIMode {
|
2026-03-31 18:42:12 +08:00
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 15:22:50 +08:00
|
|
|
resp, err := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].Request("GET", "/providers", "web", nil, nil)
|
2026-03-31 18:42:12 +08:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to list providers: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != 200 {
|
|
|
|
|
return nil, fmt.Errorf("failed to list providers: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var result CommonResponse
|
|
|
|
|
if err = json.Unmarshal(resp.Body, &result); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("list providers failed: invalid JSON (%w)", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if result.Code != 0 {
|
|
|
|
|
return nil, fmt.Errorf("%s", result.Message)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
result.Duration = resp.Duration
|
|
|
|
|
return &result, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 20:20:35 +08:00
|
|
|
// DeleteProvider deletes a provider
|
|
|
|
|
// DELETE PROVIDER <name>
|
2026-06-09 15:22:50 +08:00
|
|
|
func (c *CLI) DeleteProvider(cmd *Command) (ResponseIf, error) {
|
|
|
|
|
if c.Config.CLIMode != APIMode {
|
2026-03-31 18:42:12 +08:00
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-26 19:16:14 +08:00
|
|
|
httpClient := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer]
|
|
|
|
|
if httpClient.APIKey == nil && httpClient.LoginToken == nil {
|
|
|
|
|
return nil, fmt.Errorf("API key not set. Please login first")
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 18:42:12 +08:00
|
|
|
providerName, ok := cmd.Params["provider_name"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("provider name not provided")
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 20:20:35 +08:00
|
|
|
url := fmt.Sprintf("/providers/%s", providerName)
|
|
|
|
|
|
2026-03-31 18:42:12 +08:00
|
|
|
// Build payload
|
|
|
|
|
payload := map[string]interface{}{
|
|
|
|
|
"llm_factory": providerName,
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-26 19:16:14 +08:00
|
|
|
resp, err := httpClient.Request("DELETE", url, "web", nil, payload)
|
2026-04-02 20:20:35 +08:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to delete provider: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != 200 {
|
|
|
|
|
return nil, fmt.Errorf("failed to delete provider: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-29 11:13:14 +08:00
|
|
|
return HandleSimpleResponse(resp, "delete provider")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// APIDropDatasetCommand DROP DATASET 'dataset_name'
|
|
|
|
|
func (c *CLI) APIDropDatasetCommand(cmd *Command) (ResponseIf, error) {
|
|
|
|
|
if c.Config.CLIMode != APIMode {
|
|
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
2026-04-02 20:20:35 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-29 11:13:14 +08:00
|
|
|
httpClient := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer]
|
|
|
|
|
|
|
|
|
|
if httpClient.LoginToken == nil && !c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].useAPIKey {
|
|
|
|
|
return nil, fmt.Errorf("no authorization")
|
2026-04-02 20:20:35 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-29 11:13:14 +08:00
|
|
|
datasetName, ok := cmd.Params["dataset_name"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("dataset_name parameter is required")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
datasetID, err := c.getDatasetIDByName(datasetName)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to get dataset ID: %w by dataset name: %s", err, datasetName)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
payload := map[string]interface{}{
|
|
|
|
|
"ids": []string{datasetID},
|
|
|
|
|
"delete_all": true,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resp, err := httpClient.Request("DELETE", "/datasets", "web", nil, payload)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to create dataset: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != 200 {
|
|
|
|
|
return nil, fmt.Errorf("failed to create dataset: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return HandleSimpleResponse(resp, "create provider instance")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// APIDropAgentCommand DROP AGENT 'agent_name'
|
|
|
|
|
func (c *CLI) APIDropAgentCommand(cmd *Command) (ResponseIf, error) {
|
|
|
|
|
if c.Config.CLIMode != APIMode {
|
|
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
httpClient := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer]
|
|
|
|
|
|
|
|
|
|
if httpClient.LoginToken == nil && !c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].useAPIKey {
|
|
|
|
|
return nil, fmt.Errorf("no authorization")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
agentName, ok := cmd.Params["agent_name"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("agent_name parameter is required")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
agentID, err := c.getAgentIDByName(agentName)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to get agent ID: %w by agent name: %s", err, agentName)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
payload := map[string]interface{}{
|
|
|
|
|
"ids": []string{agentID},
|
|
|
|
|
"delete_all": true,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resp, err := httpClient.Request("DELETE", "/agents", "web", nil, payload)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to create agent: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != 200 {
|
|
|
|
|
return nil, fmt.Errorf("failed to create agent: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return HandleSimpleResponse(resp, "delete agent")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// APIDropChatCommand DROP CHAT 'chat_name'
|
|
|
|
|
func (c *CLI) APIDropChatCommand(cmd *Command) (ResponseIf, error) {
|
|
|
|
|
if c.Config.CLIMode != APIMode {
|
|
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
httpClient := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer]
|
|
|
|
|
|
|
|
|
|
if httpClient.LoginToken == nil && !c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].useAPIKey {
|
|
|
|
|
return nil, fmt.Errorf("no authorization")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
chatName, ok := cmd.Params["chat_name"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("chat_name parameter is required")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
chatID, err := c.getChatIDByName(chatName)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to get chat ID: %w by chat name: %s", err, chatName)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
payload := map[string]interface{}{
|
|
|
|
|
"ids": []string{chatID},
|
|
|
|
|
"delete_all": true,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resp, err := httpClient.Request("DELETE", "/chats", "web", nil, payload)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to create chat: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != 200 {
|
|
|
|
|
return nil, fmt.Errorf("failed to create chat: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return HandleSimpleResponse(resp, "delete chat")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// APIDropSearchCommand DROP SEARCH 'search_name'
|
|
|
|
|
func (c *CLI) APIDropSearchCommand(cmd *Command) (ResponseIf, error) {
|
|
|
|
|
if c.Config.CLIMode != APIMode {
|
|
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
httpClient := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer]
|
|
|
|
|
|
|
|
|
|
if httpClient.LoginToken == nil && !c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].useAPIKey {
|
|
|
|
|
return nil, fmt.Errorf("no authorization")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
searchName, ok := cmd.Params["search_name"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("search_name parameter is required")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
searchID, err := c.getSearchIDByName(searchName)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to get search ID: %w by search name: %s", err, searchName)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
endPoint := fmt.Sprintf("/searches/%s", searchID)
|
|
|
|
|
|
|
|
|
|
resp, err := httpClient.Request("DELETE", endPoint, "web", nil, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to delete search: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != 200 {
|
|
|
|
|
return nil, fmt.Errorf("failed to delete search: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return HandleSimpleResponse(resp, "delete search")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// APIDropMemoryCommand DROP MEMORY 'memory_name'
|
|
|
|
|
func (c *CLI) APIDropMemoryCommand(cmd *Command) (ResponseIf, error) {
|
|
|
|
|
if c.Config.CLIMode != APIMode {
|
|
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
httpClient := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer]
|
|
|
|
|
|
|
|
|
|
if httpClient.LoginToken == nil && !c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].useAPIKey {
|
|
|
|
|
return nil, fmt.Errorf("no authorization")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
memoryName, ok := cmd.Params["memory_name"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("memory_name parameter is required")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
memoryID, err := c.getMemoryIDByName(memoryName)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to get memory ID: %w by memory name: %s", err, memoryName)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
endPoint := fmt.Sprintf("/memories/%s", memoryID)
|
|
|
|
|
|
|
|
|
|
resp, err := httpClient.Request("DELETE", endPoint, "web", nil, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to delete memory: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != 200 {
|
|
|
|
|
return nil, fmt.Errorf("failed to delete memory: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return HandleSimpleResponse(resp, "delete memory")
|
2026-04-02 20:20:35 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-26 19:16:14 +08:00
|
|
|
// APICreateProviderInstanceCommand creates a new provider instance
|
2026-06-03 13:23:20 +08:00
|
|
|
// CREATE PROVIDER <name> INSTANCE <instance_name> KEY <api_key> URL <base_url> REGION <region>
|
2026-06-26 19:16:14 +08:00
|
|
|
func (c *CLI) APICreateProviderInstanceCommand(cmd *Command) (ResponseIf, error) {
|
2026-06-09 15:22:50 +08:00
|
|
|
if c.Config.CLIMode != APIMode {
|
2026-04-02 20:20:35 +08:00
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-26 19:16:14 +08:00
|
|
|
httpClient := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer]
|
|
|
|
|
if httpClient.APIKey == nil && httpClient.LoginToken == nil {
|
|
|
|
|
return nil, fmt.Errorf("API key not set. Please login first")
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 20:20:35 +08:00
|
|
|
providerName, ok := cmd.Params["provider_name"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("provider name not provided")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
instanceName, ok := cmd.Params["instance_name"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("instance name not provided")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
apiKey, ok := cmd.Params["api_key"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("API key not provided")
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 17:05:08 +08:00
|
|
|
baseUrl, ok := cmd.Params["base_url"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
baseUrl = ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
region, ok := cmd.Params["region"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
region = ""
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 20:20:35 +08:00
|
|
|
url := fmt.Sprintf("/providers/%s/instances", providerName)
|
|
|
|
|
|
|
|
|
|
payload := map[string]interface{}{
|
|
|
|
|
"instance_name": instanceName,
|
|
|
|
|
"api_key": apiKey,
|
2026-04-29 17:05:08 +08:00
|
|
|
"base_url": baseUrl,
|
|
|
|
|
"region": region,
|
2026-04-02 20:20:35 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-26 19:16:14 +08:00
|
|
|
resp, err := httpClient.Request("POST", url, "web", nil, payload)
|
2026-04-02 20:20:35 +08:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to create provider instance: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != 200 {
|
|
|
|
|
return nil, fmt.Errorf("failed to create provider instance: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-29 11:13:14 +08:00
|
|
|
return HandleSimpleResponse(resp, "create provider instance")
|
2026-04-02 20:20:35 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-21 21:31:50 +08:00
|
|
|
// ShowInstanceBalance shows balance of a specific instance
|
|
|
|
|
// SHOW BALANCE FROM PROVIDER <provider_name> <instance_name>
|
2026-06-09 15:22:50 +08:00
|
|
|
func (c *CLI) ShowInstanceBalance(cmd *Command) (ResponseIf, error) {
|
|
|
|
|
if c.Config.CLIMode != APIMode {
|
2026-04-21 21:31:50 +08:00
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
instanceName, ok := cmd.Params["instance_name"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("instance name not provided")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
providerName, ok := cmd.Params["provider_name"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("provider name not provided")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
url := fmt.Sprintf("/providers/%s/instances/%s/balance", providerName, instanceName)
|
|
|
|
|
|
2026-06-09 15:22:50 +08:00
|
|
|
resp, err := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].Request("GET", url, "web", nil, nil)
|
2026-04-02 20:20:35 +08:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to show instance: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != 200 {
|
|
|
|
|
return nil, fmt.Errorf("failed to show instance: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var result CommonDataResponse
|
|
|
|
|
if err = json.Unmarshal(resp.Body, &result); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("show instance failed: invalid JSON (%w)", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if result.Code != 0 {
|
|
|
|
|
return nil, fmt.Errorf("%s", result.Message)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
result.Duration = resp.Duration
|
|
|
|
|
return &result, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// DropProviderInstance deletes a provider instance
|
|
|
|
|
// DROP INSTANCE <name> FROM PROVIDER <name>
|
2026-06-09 15:22:50 +08:00
|
|
|
func (c *CLI) DropProviderInstance(cmd *Command) (ResponseIf, error) {
|
|
|
|
|
if c.Config.CLIMode != APIMode {
|
2026-04-02 20:20:35 +08:00
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
instanceName, ok := cmd.Params["instance_name"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("instance name not provided")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
providerName, ok := cmd.Params["provider_name"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("provider name not provided")
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-17 09:55:25 +08:00
|
|
|
payload := map[string]interface{}{
|
|
|
|
|
"instances": []string{instanceName},
|
|
|
|
|
}
|
2026-04-02 20:20:35 +08:00
|
|
|
|
2026-04-17 09:55:25 +08:00
|
|
|
url := fmt.Sprintf("/providers/%s/instances", providerName)
|
|
|
|
|
|
2026-06-09 15:22:50 +08:00
|
|
|
resp, err := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].Request("DELETE", url, "web", nil, payload)
|
2026-03-31 18:42:12 +08:00
|
|
|
if err != nil {
|
2026-04-02 20:20:35 +08:00
|
|
|
return nil, fmt.Errorf("failed to drop instance: %w", err)
|
2026-03-31 18:42:12 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != 200 {
|
2026-04-02 20:20:35 +08:00
|
|
|
return nil, fmt.Errorf("failed to drop instance: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
2026-03-31 18:42:12 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-29 11:13:14 +08:00
|
|
|
return HandleSimpleResponse(resp, "drop instance")
|
2026-04-02 20:20:35 +08:00
|
|
|
}
|
|
|
|
|
|
feat(go-cli): support batch model add/remove and optional embedding dimension (#15631)
## Summary
This PR improves the Go CLI in two areas:
1. It adds batch model management support, allowing multiple models to
be added or removed in a single command.
2. It makes the `dimension` argument optional for the `embed text`
command.
These changes keep the existing single-model and explicit-dimension
behaviors compatible while making the CLI more convenient for common
workflows.
## What Changed
### 1. Batch model add/remove support
The CLI now supports operating on multiple model names provided in a
single quoted string.
Supported commands include:
```
add model 'x1 x2 x3' to provider 'vllm' instance 'test' with tokens 1024 chat think vision, token 2048 chat, token 1024 think vision;
drop model 'x1 x2 x3' from 'vllm' 'test';
remove model 'x1 x2 x3' from 'vllm' 'test';
```
For add model, each config segment after with is matched to the
corresponding model name by position.
Example mapping:
- x1 -> tokens 1024, chat + vision, thinking=true
- x2 -> tokens 2048, chat
- x3 -> tokens 1024, vision, thinking=true
The existing single-model syntax remains supported.
### 2. Optional embedding dimension
Previously, the Go CLI required dimension to be explicitly provided for
embed text.
Before:
embed text 'what is rag' 'who are you' with 'model@test@provider'
dimension 8192;
Now both forms are supported:
embed text 'what is rag' 'who are you' with 'model@test@provider'
dimension 8192;
embed text 'what is rag' 'who are you' with 'model@test@provider';
When omitted, the CLI leaves dimension unset and relies on
provider/backend behavior.
## Tests
Added parser tests covering:
- Multiple models with multiple config segments
- Model type deduplication
- Model/config count mismatch
- Drop/remove multiple models
- Optional embedding dimension parsing
2026-06-05 19:31:06 +08:00
|
|
|
// DROP MODEL <name1 name2 name3> FROM <provider_name> <instance_name>
|
|
|
|
|
// Remove MODEL <name1 name2 name3> FROM <provider_name> <instance_name>
|
2026-06-09 15:22:50 +08:00
|
|
|
func (c *CLI) DropInstanceModel(cmd *Command) (ResponseIf, error) {
|
|
|
|
|
if c.Config.CLIMode != APIMode {
|
2026-04-29 19:18:49 +08:00
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
instanceName, ok := cmd.Params["instance_name"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("instance name not provided")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
providerName, ok := cmd.Params["provider_name"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("provider name not provided")
|
|
|
|
|
}
|
|
|
|
|
|
feat(go-cli): support batch model add/remove and optional embedding dimension (#15631)
## Summary
This PR improves the Go CLI in two areas:
1. It adds batch model management support, allowing multiple models to
be added or removed in a single command.
2. It makes the `dimension` argument optional for the `embed text`
command.
These changes keep the existing single-model and explicit-dimension
behaviors compatible while making the CLI more convenient for common
workflows.
## What Changed
### 1. Batch model add/remove support
The CLI now supports operating on multiple model names provided in a
single quoted string.
Supported commands include:
```
add model 'x1 x2 x3' to provider 'vllm' instance 'test' with tokens 1024 chat think vision, token 2048 chat, token 1024 think vision;
drop model 'x1 x2 x3' from 'vllm' 'test';
remove model 'x1 x2 x3' from 'vllm' 'test';
```
For add model, each config segment after with is matched to the
corresponding model name by position.
Example mapping:
- x1 -> tokens 1024, chat + vision, thinking=true
- x2 -> tokens 2048, chat
- x3 -> tokens 1024, vision, thinking=true
The existing single-model syntax remains supported.
### 2. Optional embedding dimension
Previously, the Go CLI required dimension to be explicitly provided for
embed text.
Before:
embed text 'what is rag' 'who are you' with 'model@test@provider'
dimension 8192;
Now both forms are supported:
embed text 'what is rag' 'who are you' with 'model@test@provider'
dimension 8192;
embed text 'what is rag' 'who are you' with 'model@test@provider';
When omitted, the CLI leaves dimension unset and relies on
provider/backend behavior.
## Tests
Added parser tests covering:
- Multiple models with multiple config segments
- Model type deduplication
- Model/config count mismatch
- Drop/remove multiple models
- Optional embedding dimension parsing
2026-06-05 19:31:06 +08:00
|
|
|
modelNames, ok := cmd.Params["model_names"].([]string)
|
2026-04-29 19:18:49 +08:00
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("model name not provided")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
payload := map[string]interface{}{
|
feat(go-cli): support batch model add/remove and optional embedding dimension (#15631)
## Summary
This PR improves the Go CLI in two areas:
1. It adds batch model management support, allowing multiple models to
be added or removed in a single command.
2. It makes the `dimension` argument optional for the `embed text`
command.
These changes keep the existing single-model and explicit-dimension
behaviors compatible while making the CLI more convenient for common
workflows.
## What Changed
### 1. Batch model add/remove support
The CLI now supports operating on multiple model names provided in a
single quoted string.
Supported commands include:
```
add model 'x1 x2 x3' to provider 'vllm' instance 'test' with tokens 1024 chat think vision, token 2048 chat, token 1024 think vision;
drop model 'x1 x2 x3' from 'vllm' 'test';
remove model 'x1 x2 x3' from 'vllm' 'test';
```
For add model, each config segment after with is matched to the
corresponding model name by position.
Example mapping:
- x1 -> tokens 1024, chat + vision, thinking=true
- x2 -> tokens 2048, chat
- x3 -> tokens 1024, vision, thinking=true
The existing single-model syntax remains supported.
### 2. Optional embedding dimension
Previously, the Go CLI required dimension to be explicitly provided for
embed text.
Before:
embed text 'what is rag' 'who are you' with 'model@test@provider'
dimension 8192;
Now both forms are supported:
embed text 'what is rag' 'who are you' with 'model@test@provider'
dimension 8192;
embed text 'what is rag' 'who are you' with 'model@test@provider';
When omitted, the CLI leaves dimension unset and relies on
provider/backend behavior.
## Tests
Added parser tests covering:
- Multiple models with multiple config segments
- Model type deduplication
- Model/config count mismatch
- Drop/remove multiple models
- Optional embedding dimension parsing
2026-06-05 19:31:06 +08:00
|
|
|
"models": modelNames,
|
2026-04-29 19:18:49 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
url := fmt.Sprintf("/providers/%s/instances/%s/models", providerName, instanceName)
|
|
|
|
|
|
2026-06-09 15:22:50 +08:00
|
|
|
resp, err := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].Request("DELETE", url, "web", nil, payload)
|
2026-04-29 19:18:49 +08:00
|
|
|
if err != nil {
|
2026-06-29 11:13:14 +08:00
|
|
|
return nil, fmt.Errorf("failed to drop model: %w", err)
|
2026-04-29 19:18:49 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != 200 {
|
2026-06-29 11:13:14 +08:00
|
|
|
return nil, fmt.Errorf("failed to drop model: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
2026-04-29 19:18:49 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-29 11:13:14 +08:00
|
|
|
return HandleSimpleResponse(resp, "drop model")
|
2026-04-29 19:18:49 +08:00
|
|
|
}
|
|
|
|
|
|
Go: CLI chat with text, image, video (#14573)
### What problem does this PR solve?
```
RAGFlow(user)> chat with 'glm-4.6v-flash@test@zhipu-ai' message 'What are the pics talk about?' image 'https://cdn.bigmodel.cn/static/logo/register.png' 'https://cdn.bigmodel.cn/static/logo/api-key.png'
Answer: The first picture shows a login/register modal with options for phone number login, account login, and WeChat QR code login, along with a prompt for new users to get a 20 million tokens experience package. The second picture displays the API keys management page of a platform, including a warning about API key security and a table listing existing API keys with details like creation time and usage history.
Time: 31.600545
RAGFlow(user)> chat with 'glm-4.6v-flash@test@zhipu-ai' message 'What are the video talk about?' video 'https://cdn.bigmodel.cn/agent-demos/lark/113123.mov'
Answer: Based on the sequence of frames provided, the video is a demonstration of a web search and navigation process.
1. The video starts with a blank Google search page.
2. The user types "智谱" (which is the Chinese name for the company Zhipu AI) into the search box.
3. The search is initiated and the page shows "About 0 results".
4. The search results load, showing information about Zhipu AI, including its website.
5. The user clicks on the main website link (www.zhipuai.cn).
6. The video ends by showing the homepage of Zhipu AI's website, titled "Z.ai GLM Large Model Open Platform".
In summary, the video is about searching for the company "智谱" (Zhipu AI) on Google and then navigating to its official website.
Time: 76.582520
```
### Type of change
- [x] New Feature (non-breaking change which adds functionality)
Signed-off-by: Jin Hai <haijin.chn@gmail.com>
2026-05-05 18:14:39 +08:00
|
|
|
func isValidURL(str string) bool {
|
|
|
|
|
u, err := netUrl.Parse(str)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
return u.Scheme != "" && u.Host != ""
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 15:22:50 +08:00
|
|
|
func (c *CLI) ChatToModel(cmd *Command) (ResponseIf, error) {
|
|
|
|
|
if c.Config.CLIMode != APIMode {
|
2026-04-02 20:20:35 +08:00
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var providerName, instanceName, modelName string
|
2026-06-15 10:10:14 +08:00
|
|
|
var err error
|
2026-04-02 20:20:35 +08:00
|
|
|
|
2026-06-15 10:10:14 +08:00
|
|
|
compositeModelName, ok := cmd.Params["composite_model_name"].(string)
|
|
|
|
|
if ok {
|
|
|
|
|
modelName, instanceName, providerName, err = common.ExtractCompositeName(compositeModelName)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
2026-04-02 20:20:35 +08:00
|
|
|
}
|
2026-06-15 10:10:14 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
modelID, ok := cmd.Params["model_id"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
modelID = ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if modelID == "" && compositeModelName == "" {
|
|
|
|
|
if c.CurrentModel == nil {
|
|
|
|
|
return nil, fmt.Errorf("model name or ID not provided and no current model set. Use 'use model' command first")
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 20:20:35 +08:00
|
|
|
// Use current model if set
|
2026-06-15 10:10:14 +08:00
|
|
|
if c.CurrentModel.ModelID != "" {
|
|
|
|
|
modelID = c.CurrentModel.ModelID
|
|
|
|
|
} else {
|
|
|
|
|
providerName = c.CurrentModel.Provider
|
|
|
|
|
instanceName = c.CurrentModel.Instance
|
|
|
|
|
modelName = c.CurrentModel.Model
|
|
|
|
|
}
|
2026-04-02 20:20:35 +08:00
|
|
|
}
|
|
|
|
|
|
Go: CLI chat with text, image, video (#14573)
### What problem does this PR solve?
```
RAGFlow(user)> chat with 'glm-4.6v-flash@test@zhipu-ai' message 'What are the pics talk about?' image 'https://cdn.bigmodel.cn/static/logo/register.png' 'https://cdn.bigmodel.cn/static/logo/api-key.png'
Answer: The first picture shows a login/register modal with options for phone number login, account login, and WeChat QR code login, along with a prompt for new users to get a 20 million tokens experience package. The second picture displays the API keys management page of a platform, including a warning about API key security and a table listing existing API keys with details like creation time and usage history.
Time: 31.600545
RAGFlow(user)> chat with 'glm-4.6v-flash@test@zhipu-ai' message 'What are the video talk about?' video 'https://cdn.bigmodel.cn/agent-demos/lark/113123.mov'
Answer: Based on the sequence of frames provided, the video is a demonstration of a web search and navigation process.
1. The video starts with a blank Google search page.
2. The user types "智谱" (which is the Chinese name for the company Zhipu AI) into the search box.
3. The search is initiated and the page shows "About 0 results".
4. The search results load, showing information about Zhipu AI, including its website.
5. The user clicks on the main website link (www.zhipuai.cn).
6. The video ends by showing the homepage of Zhipu AI's website, titled "Z.ai GLM Large Model Open Platform".
In summary, the video is about searching for the company "智谱" (Zhipu AI) on Google and then navigating to its official website.
Time: 76.582520
```
### Type of change
- [x] New Feature (non-breaking change which adds functionality)
Signed-off-by: Jin Hai <haijin.chn@gmail.com>
2026-05-05 18:14:39 +08:00
|
|
|
formattedMessages := []map[string]interface{}{}
|
|
|
|
|
|
|
|
|
|
messages, ok := cmd.Params["messages"].([]string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("messages not provided")
|
|
|
|
|
}
|
|
|
|
|
contents := []map[string]interface{}{}
|
|
|
|
|
if len(messages) > 0 {
|
|
|
|
|
for _, message := range messages {
|
|
|
|
|
contents = append(contents, map[string]interface{}{
|
|
|
|
|
"type": "text",
|
|
|
|
|
"text": message,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
images, ok := cmd.Params["images"].([]string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("images not provided")
|
|
|
|
|
}
|
|
|
|
|
if len(images) > 0 {
|
|
|
|
|
for _, image := range images {
|
|
|
|
|
if isValidURL(image) {
|
|
|
|
|
contents = append(contents, map[string]interface{}{
|
|
|
|
|
"type": "image_url",
|
|
|
|
|
"image_url": map[string]string{
|
|
|
|
|
"url": image,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
} else {
|
|
|
|
|
// image is a path, read the file and turn it into base64
|
|
|
|
|
imageContent, err := os.ReadFile(image)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to read image: %w", err)
|
|
|
|
|
}
|
|
|
|
|
contents = append(contents, map[string]interface{}{
|
|
|
|
|
"type": "image_file",
|
|
|
|
|
"image_file": map[string]interface{}{
|
|
|
|
|
"content": base64.StdEncoding.EncodeToString(imageContent),
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
videos, ok := cmd.Params["videos"].([]string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("images not provided")
|
|
|
|
|
}
|
|
|
|
|
if len(videos) > 0 {
|
|
|
|
|
for _, video := range videos {
|
|
|
|
|
if isValidURL(video) {
|
|
|
|
|
contents = append(contents, map[string]interface{}{
|
|
|
|
|
"type": "video_url",
|
|
|
|
|
"video_url": map[string]interface{}{
|
|
|
|
|
"url": video,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
} else {
|
|
|
|
|
return nil, fmt.Errorf("invalid video URL: %s", video)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 17:17:44 +08:00
|
|
|
audios, ok := cmd.Params["audios"].([]string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("images not provided")
|
|
|
|
|
}
|
|
|
|
|
if len(audios) > 0 {
|
|
|
|
|
if len(audios) != 1 {
|
|
|
|
|
return nil, fmt.Errorf("only one audio file is supported")
|
|
|
|
|
}
|
|
|
|
|
audioFile := audios[0]
|
|
|
|
|
audioContent, err := os.ReadFile(audioFile)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to read audio: %w", err)
|
|
|
|
|
}
|
|
|
|
|
// file type: wav or mp3
|
|
|
|
|
format := filepath.Ext(audioFile) // file type: wav or mp3
|
|
|
|
|
format = strings.TrimPrefix(format, ".")
|
|
|
|
|
contents = append(contents, map[string]interface{}{
|
|
|
|
|
"type": "input_audio",
|
|
|
|
|
"input_audio": map[string]interface{}{
|
|
|
|
|
"data": base64.StdEncoding.EncodeToString(audioContent),
|
|
|
|
|
"format": format,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
Go: CLI chat with text, image, video (#14573)
### What problem does this PR solve?
```
RAGFlow(user)> chat with 'glm-4.6v-flash@test@zhipu-ai' message 'What are the pics talk about?' image 'https://cdn.bigmodel.cn/static/logo/register.png' 'https://cdn.bigmodel.cn/static/logo/api-key.png'
Answer: The first picture shows a login/register modal with options for phone number login, account login, and WeChat QR code login, along with a prompt for new users to get a 20 million tokens experience package. The second picture displays the API keys management page of a platform, including a warning about API key security and a table listing existing API keys with details like creation time and usage history.
Time: 31.600545
RAGFlow(user)> chat with 'glm-4.6v-flash@test@zhipu-ai' message 'What are the video talk about?' video 'https://cdn.bigmodel.cn/agent-demos/lark/113123.mov'
Answer: Based on the sequence of frames provided, the video is a demonstration of a web search and navigation process.
1. The video starts with a blank Google search page.
2. The user types "智谱" (which is the Chinese name for the company Zhipu AI) into the search box.
3. The search is initiated and the page shows "About 0 results".
4. The search results load, showing information about Zhipu AI, including its website.
5. The user clicks on the main website link (www.zhipuai.cn).
6. The video ends by showing the homepage of Zhipu AI's website, titled "Z.ai GLM Large Model Open Platform".
In summary, the video is about searching for the company "智谱" (Zhipu AI) on Google and then navigating to its official website.
Time: 76.582520
```
### Type of change
- [x] New Feature (non-breaking change which adds functionality)
Signed-off-by: Jin Hai <haijin.chn@gmail.com>
2026-05-05 18:14:39 +08:00
|
|
|
|
|
|
|
|
files, ok := cmd.Params["files"].([]string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("images not provided")
|
|
|
|
|
}
|
|
|
|
|
if len(files) > 0 {
|
|
|
|
|
for _, file := range files {
|
|
|
|
|
if isValidURL(file) {
|
|
|
|
|
contents = append(contents, map[string]interface{}{
|
|
|
|
|
"type": "file_url",
|
|
|
|
|
"file_url": map[string]interface{}{
|
|
|
|
|
"url": file,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
} else {
|
|
|
|
|
return nil, fmt.Errorf("invalid file URL: %s", file)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
formattedText := map[string]interface{}{
|
|
|
|
|
"role": "user",
|
|
|
|
|
"content": contents,
|
|
|
|
|
}
|
|
|
|
|
formattedMessages = append(formattedMessages, formattedText)
|
|
|
|
|
|
2026-04-21 16:52:32 +08:00
|
|
|
thinking := cmd.Params["thinking"].(bool)
|
|
|
|
|
stream := cmd.Params["stream"].(bool)
|
2026-04-24 20:59:30 +08:00
|
|
|
effort := cmd.Params["effort"].(string)
|
|
|
|
|
verbosity := cmd.Params["verbosity"].(string)
|
2026-04-02 20:20:35 +08:00
|
|
|
|
2026-04-30 15:25:01 +08:00
|
|
|
url := "/chat/completions"
|
|
|
|
|
|
2026-04-02 20:20:35 +08:00
|
|
|
payload := map[string]interface{}{
|
2026-06-15 10:10:14 +08:00
|
|
|
"messages": formattedMessages,
|
|
|
|
|
"stream": stream,
|
|
|
|
|
"thinking": thinking,
|
|
|
|
|
}
|
|
|
|
|
if modelID == "" {
|
|
|
|
|
payload["provider_name"] = providerName
|
|
|
|
|
payload["instance_name"] = instanceName
|
|
|
|
|
payload["model_name"] = modelName
|
|
|
|
|
} else {
|
|
|
|
|
payload["model_id"] = modelID
|
2026-04-24 20:59:30 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if thinking {
|
|
|
|
|
payload["effort"] = effort
|
|
|
|
|
payload["verbosity"] = verbosity
|
2026-04-02 20:20:35 +08:00
|
|
|
}
|
2026-03-31 18:42:12 +08:00
|
|
|
|
2026-04-21 16:52:32 +08:00
|
|
|
if stream {
|
|
|
|
|
// Call stream http api
|
2026-04-24 20:59:30 +08:00
|
|
|
startTime := time.Now()
|
2026-06-09 15:22:50 +08:00
|
|
|
reader, err := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].RequestStream("POST", url, "web", nil, payload)
|
2026-04-21 16:52:32 +08:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to chat model: %w", err)
|
|
|
|
|
}
|
|
|
|
|
defer reader.Close()
|
|
|
|
|
|
|
|
|
|
// Parse SSE and output to console
|
|
|
|
|
scanner := bufio.NewScanner(reader)
|
|
|
|
|
var fullMessage strings.Builder
|
|
|
|
|
|
|
|
|
|
reasoningPrint := true
|
|
|
|
|
messagePrint := true
|
|
|
|
|
for scanner.Scan() {
|
|
|
|
|
line := scanner.Text()
|
|
|
|
|
if strings.HasPrefix(line, "data:") {
|
|
|
|
|
data := strings.TrimPrefix(line, "data:")
|
|
|
|
|
data = strings.TrimSpace(data)
|
|
|
|
|
|
|
|
|
|
if strings.HasPrefix(data, "[REASONING]") {
|
|
|
|
|
data = strings.TrimPrefix(data, "[REASONING]")
|
|
|
|
|
if reasoningPrint {
|
|
|
|
|
fmt.Print("Thinking: ")
|
|
|
|
|
reasoningPrint = false
|
2026-04-24 20:59:30 +08:00
|
|
|
thinking = true
|
2026-04-21 16:52:32 +08:00
|
|
|
} else {
|
|
|
|
|
fmt.Print(data)
|
2026-04-03 18:11:23 +08:00
|
|
|
}
|
|
|
|
|
os.Stdout.Sync()
|
|
|
|
|
}
|
2026-04-21 16:52:32 +08:00
|
|
|
if strings.HasPrefix(data, "[MESSAGE]") {
|
|
|
|
|
data = strings.TrimPrefix(data, "[MESSAGE]")
|
|
|
|
|
if messagePrint {
|
|
|
|
|
if thinking {
|
|
|
|
|
fmt.Println()
|
|
|
|
|
}
|
|
|
|
|
fmt.Print("Answer: ")
|
|
|
|
|
messagePrint = false
|
|
|
|
|
} else {
|
|
|
|
|
fmt.Print(data)
|
|
|
|
|
os.Stdout.Sync()
|
|
|
|
|
fullMessage.WriteString(data)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else if strings.HasPrefix(line, "event:error") {
|
|
|
|
|
// error event
|
|
|
|
|
if scanner.Scan() {
|
|
|
|
|
errData := strings.TrimPrefix(scanner.Text(), "data:")
|
|
|
|
|
errData = strings.TrimSpace(errData)
|
|
|
|
|
return nil, fmt.Errorf("chat error: %s", errData)
|
|
|
|
|
}
|
|
|
|
|
// If there's an error, return a generic error
|
|
|
|
|
return nil, fmt.Errorf("chat error: received error event from server")
|
2026-04-03 18:11:23 +08:00
|
|
|
}
|
|
|
|
|
}
|
2026-04-24 20:59:30 +08:00
|
|
|
duration := time.Since(startTime).Seconds()
|
2026-04-21 16:52:32 +08:00
|
|
|
if err := scanner.Err(); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("error reading stream: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fmt.Println()
|
|
|
|
|
|
|
|
|
|
result := &StreamMessageResponse{
|
|
|
|
|
Code: 0,
|
|
|
|
|
Message: fullMessage.String(),
|
|
|
|
|
Duration: duration,
|
|
|
|
|
}
|
|
|
|
|
return result, nil
|
2026-04-02 20:20:35 +08:00
|
|
|
}
|
2026-04-03 18:11:23 +08:00
|
|
|
|
2026-06-09 15:22:50 +08:00
|
|
|
resp, err := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].Request("POST", url, "web", nil, payload)
|
2026-04-21 16:52:32 +08:00
|
|
|
if err != nil {
|
2026-05-08 15:54:27 +08:00
|
|
|
return nil, formatRequestError("Chat request", err)
|
2026-04-02 20:20:35 +08:00
|
|
|
}
|
2026-04-03 18:11:23 +08:00
|
|
|
|
2026-04-21 16:52:32 +08:00
|
|
|
if resp.StatusCode != 200 {
|
2026-06-15 10:10:14 +08:00
|
|
|
return nil, fmt.Errorf("failed to chat model: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
2026-04-21 16:52:32 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var result NonStreamResponse
|
|
|
|
|
if err = json.Unmarshal(resp.Body, &result); err != nil {
|
2026-06-15 10:10:14 +08:00
|
|
|
return nil, fmt.Errorf("failed to chat model: invalid JSON (%w)", err)
|
2026-04-21 16:52:32 +08:00
|
|
|
}
|
2026-04-03 18:11:23 +08:00
|
|
|
|
2026-04-21 16:52:32 +08:00
|
|
|
if result.Code != 0 {
|
|
|
|
|
return nil, fmt.Errorf("%s", result.Message)
|
2026-04-02 20:20:35 +08:00
|
|
|
}
|
2026-04-21 16:52:32 +08:00
|
|
|
result.Duration = resp.Duration
|
|
|
|
|
return &result, nil
|
2026-03-31 18:42:12 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-09 15:22:50 +08:00
|
|
|
func (c *CLI) EmbedUserText(cmd *Command) (ResponseIf, error) {
|
2026-06-24 18:48:09 +08:00
|
|
|
if c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].APIKey == nil && c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].LoginToken == nil {
|
|
|
|
|
return nil, fmt.Errorf("API key not set. Please login first")
|
2026-05-09 17:41:54 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-09 15:22:50 +08:00
|
|
|
if c.Config.CLIMode != APIMode {
|
2026-05-09 17:41:54 +08:00
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var providerName, instanceName, modelName string
|
2026-06-15 10:10:14 +08:00
|
|
|
var err error
|
2026-05-09 17:41:54 +08:00
|
|
|
|
|
|
|
|
// Check if composite_model_name is provided in command
|
2026-06-15 10:10:14 +08:00
|
|
|
compositeModelName, ok := cmd.Params["composite_model_name"].(string)
|
|
|
|
|
if ok {
|
|
|
|
|
modelName, instanceName, providerName, err = common.ExtractCompositeName(compositeModelName)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
modelID, ok := cmd.Params["model_id"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
modelID = ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if modelID == "" && compositeModelName == "" {
|
|
|
|
|
if c.CurrentModel == nil {
|
|
|
|
|
return nil, fmt.Errorf("model name or ID not provided and no current model set. Use 'use model' command first")
|
2026-05-09 17:41:54 +08:00
|
|
|
}
|
2026-06-15 10:10:14 +08:00
|
|
|
|
2026-05-09 17:41:54 +08:00
|
|
|
// Use current model if set
|
2026-06-15 10:10:14 +08:00
|
|
|
if c.CurrentModel.ModelID != "" {
|
|
|
|
|
modelID = c.CurrentModel.ModelID
|
|
|
|
|
} else {
|
|
|
|
|
providerName = c.CurrentModel.Provider
|
|
|
|
|
instanceName = c.CurrentModel.Instance
|
|
|
|
|
modelName = c.CurrentModel.Model
|
|
|
|
|
}
|
2026-05-09 17:41:54 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
texts, ok := cmd.Params["texts"].([]string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("texts not provided")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
dimension, ok := cmd.Params["dimension"].(int)
|
|
|
|
|
if !ok {
|
|
|
|
|
dimension = 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
payload := map[string]interface{}{
|
2026-06-15 10:10:14 +08:00
|
|
|
"texts": texts,
|
|
|
|
|
"dimension": dimension,
|
|
|
|
|
}
|
|
|
|
|
if modelID == "" {
|
|
|
|
|
payload["provider_name"] = providerName
|
|
|
|
|
payload["instance_name"] = instanceName
|
|
|
|
|
payload["model_name"] = modelName
|
|
|
|
|
} else {
|
|
|
|
|
payload["model_id"] = modelID
|
2026-05-09 17:41:54 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
url := "/embeddings"
|
|
|
|
|
|
2026-06-09 15:22:50 +08:00
|
|
|
resp, err := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].Request("POST", url, "web", nil, payload)
|
2026-05-09 17:41:54 +08:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to embed text: %w", err)
|
|
|
|
|
}
|
|
|
|
|
if resp.StatusCode != 200 {
|
|
|
|
|
return nil, fmt.Errorf("failed to embed text: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
|
|
|
|
}
|
2026-05-11 14:45:30 +08:00
|
|
|
var result EmbeddingsResponse
|
2026-05-09 17:41:54 +08:00
|
|
|
if err = json.Unmarshal(resp.Body, &result); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("embed text failed: invalid JSON (%w)", err)
|
|
|
|
|
}
|
|
|
|
|
if result.Code != 0 {
|
|
|
|
|
return nil, fmt.Errorf("%s", result.Message)
|
|
|
|
|
}
|
|
|
|
|
result.Duration = resp.Duration
|
|
|
|
|
return &result, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 15:22:50 +08:00
|
|
|
func (c *CLI) RerankUserDocument(cmd *Command) (ResponseIf, error) {
|
2026-06-24 18:48:09 +08:00
|
|
|
if c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].APIKey == nil && c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].LoginToken == nil {
|
|
|
|
|
return nil, fmt.Errorf("API key not set. Please login first")
|
2026-05-09 17:41:54 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-09 15:22:50 +08:00
|
|
|
if c.Config.CLIMode != APIMode {
|
2026-05-09 17:41:54 +08:00
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var providerName, instanceName, modelName string
|
2026-06-15 10:10:14 +08:00
|
|
|
var err error
|
2026-05-09 17:41:54 +08:00
|
|
|
|
|
|
|
|
// Check if composite_model_name is provided in command
|
2026-06-15 10:10:14 +08:00
|
|
|
compositeModelName, ok := cmd.Params["composite_model_name"].(string)
|
|
|
|
|
if ok {
|
|
|
|
|
modelName, instanceName, providerName, err = common.ExtractCompositeName(compositeModelName)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
2026-05-09 17:41:54 +08:00
|
|
|
}
|
2026-06-15 10:10:14 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
modelID, ok := cmd.Params["model_id"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
modelID = ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if modelID == "" && compositeModelName == "" {
|
|
|
|
|
if c.CurrentModel == nil {
|
|
|
|
|
return nil, fmt.Errorf("model name or ID not provided and no current model set. Use 'use model' command first")
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-09 17:41:54 +08:00
|
|
|
// Use current model if set
|
2026-06-15 10:10:14 +08:00
|
|
|
if c.CurrentModel.ModelID != "" {
|
|
|
|
|
modelID = c.CurrentModel.ModelID
|
|
|
|
|
} else {
|
|
|
|
|
providerName = c.CurrentModel.Provider
|
|
|
|
|
instanceName = c.CurrentModel.Instance
|
|
|
|
|
modelName = c.CurrentModel.Model
|
|
|
|
|
}
|
2026-05-09 17:41:54 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
query, ok := cmd.Params["query"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("query not provided")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
documents, ok := cmd.Params["documents"].([]string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("documents not provided")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
topN, ok := cmd.Params["top_n"].(int)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("top n not provided")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
payload := map[string]interface{}{
|
2026-06-15 10:10:14 +08:00
|
|
|
"query": query,
|
|
|
|
|
"documents": documents,
|
|
|
|
|
"top_n": topN,
|
|
|
|
|
}
|
|
|
|
|
if modelID == "" {
|
|
|
|
|
payload["provider_name"] = providerName
|
|
|
|
|
payload["instance_name"] = instanceName
|
|
|
|
|
payload["model_name"] = modelName
|
|
|
|
|
} else {
|
|
|
|
|
payload["model_id"] = modelID
|
2026-05-09 17:41:54 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
url := "/rerank"
|
|
|
|
|
|
2026-06-09 15:22:50 +08:00
|
|
|
resp, err := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].Request("POST", url, "web", nil, payload)
|
2026-05-09 17:41:54 +08:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to rerank document: %w", err)
|
|
|
|
|
}
|
|
|
|
|
if resp.StatusCode != 200 {
|
|
|
|
|
return nil, fmt.Errorf("failed to rerank document: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
|
|
|
|
}
|
|
|
|
|
var result CommonResponse
|
|
|
|
|
if err = json.Unmarshal(resp.Body, &result); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("rerank document failed: invalid JSON (%w)", err)
|
|
|
|
|
}
|
|
|
|
|
if result.Code != 0 {
|
|
|
|
|
return nil, fmt.Errorf("%s", result.Message)
|
|
|
|
|
}
|
|
|
|
|
result.Duration = resp.Duration
|
|
|
|
|
return &result, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 15:22:50 +08:00
|
|
|
func (c *CLI) TTSUserCommand(cmd *Command) (ResponseIf, error) {
|
2026-06-24 18:48:09 +08:00
|
|
|
if c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].APIKey == nil && c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].LoginToken == nil {
|
|
|
|
|
return nil, fmt.Errorf("API key not set. Please login first")
|
2026-05-12 17:17:44 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-09 15:22:50 +08:00
|
|
|
if c.Config.CLIMode != APIMode {
|
2026-05-12 17:17:44 +08:00
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var providerName, instanceName, modelName string
|
2026-06-15 10:10:14 +08:00
|
|
|
var err error
|
2026-05-12 17:17:44 +08:00
|
|
|
|
|
|
|
|
// Check if composite_model_name is provided in command
|
2026-06-15 10:10:14 +08:00
|
|
|
compositeModelName, ok := cmd.Params["composite_model_name"].(string)
|
|
|
|
|
if ok {
|
|
|
|
|
modelName, instanceName, providerName, err = common.ExtractCompositeName(compositeModelName)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
modelID, ok := cmd.Params["model_id"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
modelID = ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if modelID == "" && compositeModelName == "" {
|
|
|
|
|
if c.CurrentModel == nil {
|
|
|
|
|
return nil, fmt.Errorf("model name or ID not provided and no current model set. Use 'use model' command first")
|
2026-05-12 17:17:44 +08:00
|
|
|
}
|
2026-06-15 10:10:14 +08:00
|
|
|
|
2026-05-12 17:17:44 +08:00
|
|
|
// Use current model if set
|
2026-06-15 10:10:14 +08:00
|
|
|
if c.CurrentModel.ModelID != "" {
|
|
|
|
|
modelID = c.CurrentModel.ModelID
|
|
|
|
|
} else {
|
|
|
|
|
providerName = c.CurrentModel.Provider
|
|
|
|
|
instanceName = c.CurrentModel.Instance
|
|
|
|
|
modelName = c.CurrentModel.Model
|
|
|
|
|
}
|
2026-05-12 17:17:44 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
text, ok := cmd.Params["text"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("text not provided")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
//fileToSave, ok := cmd.Params["file"].(string)
|
|
|
|
|
//if !ok {
|
|
|
|
|
// return nil, fmt.Errorf("file not provided")
|
|
|
|
|
//}
|
|
|
|
|
|
|
|
|
|
payload := map[string]interface{}{
|
2026-06-15 10:10:14 +08:00
|
|
|
"text": text,
|
|
|
|
|
}
|
|
|
|
|
if modelID == "" {
|
|
|
|
|
payload["provider_name"] = providerName
|
|
|
|
|
payload["instance_name"] = instanceName
|
|
|
|
|
payload["model_name"] = modelName
|
|
|
|
|
} else {
|
|
|
|
|
payload["model_id"] = modelID
|
2026-05-12 17:17:44 +08:00
|
|
|
}
|
|
|
|
|
|
Go: implement TTS for MiniMax provider and CLI testing for TTS (#14911)
### What problem does this PR solve?
This PR implement TTS for MiniMax provider and CLI testing for TTS
**The following functionalities are now supported:**
**MiniMax:**
- [x] Chat / Stream Chat
- [x] Embedding
- [x] Rerank
- [x] Model listing
- [x] Provider connection checking
- [x] Text To Speech
- [ ] OCRFile
- [ ] ~~Audio To Text~~
- [ ] ~~Balance~~
**Verified examples from the CLI:**
```plaintext
RAGFlow(user)> tts with 'speech-2.8-hd@test@minimax' text 'He who desires but acts not, breeds pestilence.' play format 'wav' save './internal' param '{"voice_setting": {"voice_id": "English_radiant_girl", "speed": 1, "vol": 1, "pitch": 0}, "audio_setting": {"sample_rate": 32000, "bitrate": 128000, "format": "wav", "channel": 1}, "output_format": "hex"}'
Saved to directory: /home/infiniflow/Documents/development/ragflow/internal/speech-2.8-hd_output.wav
SUCCESS
RAGFlow(user)> stream tts with 'speech-2.8-hd@test@minimax' text 'He who desires but acts not, breeds pestilence.' play format 'wav' save './internal' param '{"voice_setting": {"voice_id": "English_radiant_girl", "speed": 1, "vol": 1, "pitch": 0}, "audio_setting": {"sample_rate": 32000, "bitrate": 128000, "format": "wav", "channel": 1}, "output_format": "hex"}'
Saved to directory: /home/infiniflow/Documents/development/ragflow/internal/speech-2.8-hd_output.wav
SUCCESS
```
Set `Play` to play audio in CLI
Set `Save` `PATH_TO_SAVE` to save file
Set `format` to save file in wav or mp3
Set `Param` align with official request body
### Type of change
- [x] New Feature (non-breaking change which adds functionality)
2026-05-14 13:19:31 +08:00
|
|
|
ttsConfigPayload := make(map[string]interface{})
|
|
|
|
|
|
|
|
|
|
explicitFormat, hasExplicitFormat := cmd.Params["format"].(string)
|
|
|
|
|
|
|
|
|
|
if paramStr, ok := cmd.Params["param_str"].(string); ok && paramStr != "" {
|
|
|
|
|
var dynamicParams map[string]interface{}
|
|
|
|
|
if err := json.Unmarshal([]byte(paramStr), &dynamicParams); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("param string must be valid JSON. Error: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ttsConfigPayload["params"] = dynamicParams
|
|
|
|
|
|
|
|
|
|
if !hasExplicitFormat {
|
|
|
|
|
var findFormat func(map[string]interface{}) string
|
|
|
|
|
findFormat = func(m map[string]interface{}) string {
|
|
|
|
|
if val, ok := m["format"]; ok {
|
|
|
|
|
return fmt.Sprintf("%v", val)
|
|
|
|
|
}
|
|
|
|
|
if val, ok := m["response_format"]; ok {
|
|
|
|
|
return fmt.Sprintf("%v", val)
|
|
|
|
|
}
|
|
|
|
|
for _, v := range m {
|
|
|
|
|
if subMap, ok := v.(map[string]interface{}); ok {
|
|
|
|
|
if res := findFormat(subMap); res != "" {
|
|
|
|
|
return res
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
if ext := findFormat(dynamicParams); ext != "" {
|
|
|
|
|
explicitFormat = ext
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if explicitFormat != "" {
|
|
|
|
|
ttsConfigPayload["format"] = explicitFormat
|
|
|
|
|
} else {
|
Go: implement TTS for fishaudio, openrouter and asr for fishaudio (#14926)
### What problem does this PR solve?
This PR implement TTS for FishAudio and MiniMax provider and ASR for
FishAudio
**The following functionalities are now supported:**
**FishAudio:**
- [x] Text To Speech
- [x] Stream Text To Speech
- [x] Audio To Text
**OpenRouter:**
- [x] Text To Speech
**Verified examples from the CLI:**
```plaintext
**FishAudio**
RAGFlow(user)> tts with 's1@test@fishaudio' text 'He who desires but acts not, breeds pestilence.' play format 'wav' save './internal' param '{"reference_id": "90e65eaaf50e4470b8e6d43ee6afd7d5", "temperature": 0.7, "top_p": 0.7, "prosody": {"speed": 1, "volume": 0, "normalize_loudness": true}, "chunk_length": 300, "normalize": true, "sample_rate": 44100, "mp3_bitrate": 128, "latency": "normal", "max_new_tokens": 1024, "repetition_penalty": 1.2, "min_chunk_length": 50, "condition_on_previous_chunks": true, "early_stop_threshold": 1}'
Saved to directory: /home/infiniflow/Documents/development/ragflow/internal/s1_output.wav
SUCCESS
RAGFlow(user)> stream tts with 's1@test@fishaudio' text 'He who desires but acts not, breeds pestilence.' play format 'wav' save './internal' param '{"reference_id": "90e65eaaf50e4470b8e6d43ee6afd7d5", "temperature": 0.7, "top_p": 0.7, "prosody": {"speed": 1, "volume": 0, "normalize_loudness": true}, "chunk_length": 300, "normalize": true, "sample_rate": 44100, "mp3_bitrate": 128, "latency": "normal", "max_new_tokens": 1024, "repetition_penalty": 1.2, "min_chunk_length": 50, "condition_on_previous_chunks": true, "early_stop_threshold": 1}'
Saved to directory: /home/infiniflow/Documents/development/ragflow/internal/s1_output.wav
SUCCESS
RAGFlow(user)> asr with 'transcribe-1@test@fishaudio' audio './internal/test.wav' param '{"language": "en", "ignore_timestamps": true}'
+----------------------------------------------------------------------------------------------------------------------+
| text |
+----------------------------------------------------------------------------------------------------------------------+
| The examination and testimony of the experts enabled the commission to conclude that five shots may have been fired. |
+----------------------------------------------------------------------------------------------------------------------+
```
### Type of change
- [x] Bug Fix (non-breaking change which fixes an issue)
- [x] New Feature (non-breaking change which adds functionality)
- [x] Refactoring
2026-05-14 18:58:00 +08:00
|
|
|
ttsConfigPayload["format"] = "mp3"
|
Go: implement TTS for MiniMax provider and CLI testing for TTS (#14911)
### What problem does this PR solve?
This PR implement TTS for MiniMax provider and CLI testing for TTS
**The following functionalities are now supported:**
**MiniMax:**
- [x] Chat / Stream Chat
- [x] Embedding
- [x] Rerank
- [x] Model listing
- [x] Provider connection checking
- [x] Text To Speech
- [ ] OCRFile
- [ ] ~~Audio To Text~~
- [ ] ~~Balance~~
**Verified examples from the CLI:**
```plaintext
RAGFlow(user)> tts with 'speech-2.8-hd@test@minimax' text 'He who desires but acts not, breeds pestilence.' play format 'wav' save './internal' param '{"voice_setting": {"voice_id": "English_radiant_girl", "speed": 1, "vol": 1, "pitch": 0}, "audio_setting": {"sample_rate": 32000, "bitrate": 128000, "format": "wav", "channel": 1}, "output_format": "hex"}'
Saved to directory: /home/infiniflow/Documents/development/ragflow/internal/speech-2.8-hd_output.wav
SUCCESS
RAGFlow(user)> stream tts with 'speech-2.8-hd@test@minimax' text 'He who desires but acts not, breeds pestilence.' play format 'wav' save './internal' param '{"voice_setting": {"voice_id": "English_radiant_girl", "speed": 1, "vol": 1, "pitch": 0}, "audio_setting": {"sample_rate": 32000, "bitrate": 128000, "format": "wav", "channel": 1}, "output_format": "hex"}'
Saved to directory: /home/infiniflow/Documents/development/ragflow/internal/speech-2.8-hd_output.wav
SUCCESS
```
Set `Play` to play audio in CLI
Set `Save` `PATH_TO_SAVE` to save file
Set `format` to save file in wav or mp3
Set `Param` align with official request body
### Type of change
- [x] New Feature (non-breaking change which adds functionality)
2026-05-14 13:19:31 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if len(ttsConfigPayload) > 0 {
|
|
|
|
|
payload["tts_config"] = ttsConfigPayload
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 17:17:44 +08:00
|
|
|
url := "/audio/speech"
|
|
|
|
|
|
2026-06-09 15:22:50 +08:00
|
|
|
resp, err := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].Request("POST", url, "web", nil, payload)
|
2026-05-12 17:17:44 +08:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to TTS document: %w", err)
|
|
|
|
|
}
|
|
|
|
|
if resp.StatusCode != 200 {
|
|
|
|
|
return nil, fmt.Errorf("failed to TTS document: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
|
|
|
|
}
|
Go: implement TTS for MiniMax provider and CLI testing for TTS (#14911)
### What problem does this PR solve?
This PR implement TTS for MiniMax provider and CLI testing for TTS
**The following functionalities are now supported:**
**MiniMax:**
- [x] Chat / Stream Chat
- [x] Embedding
- [x] Rerank
- [x] Model listing
- [x] Provider connection checking
- [x] Text To Speech
- [ ] OCRFile
- [ ] ~~Audio To Text~~
- [ ] ~~Balance~~
**Verified examples from the CLI:**
```plaintext
RAGFlow(user)> tts with 'speech-2.8-hd@test@minimax' text 'He who desires but acts not, breeds pestilence.' play format 'wav' save './internal' param '{"voice_setting": {"voice_id": "English_radiant_girl", "speed": 1, "vol": 1, "pitch": 0}, "audio_setting": {"sample_rate": 32000, "bitrate": 128000, "format": "wav", "channel": 1}, "output_format": "hex"}'
Saved to directory: /home/infiniflow/Documents/development/ragflow/internal/speech-2.8-hd_output.wav
SUCCESS
RAGFlow(user)> stream tts with 'speech-2.8-hd@test@minimax' text 'He who desires but acts not, breeds pestilence.' play format 'wav' save './internal' param '{"voice_setting": {"voice_id": "English_radiant_girl", "speed": 1, "vol": 1, "pitch": 0}, "audio_setting": {"sample_rate": 32000, "bitrate": 128000, "format": "wav", "channel": 1}, "output_format": "hex"}'
Saved to directory: /home/infiniflow/Documents/development/ragflow/internal/speech-2.8-hd_output.wav
SUCCESS
```
Set `Play` to play audio in CLI
Set `Save` `PATH_TO_SAVE` to save file
Set `format` to save file in wav or mp3
Set `Param` align with official request body
### Type of change
- [x] New Feature (non-breaking change which adds functionality)
2026-05-14 13:19:31 +08:00
|
|
|
|
|
|
|
|
var ttsResult struct {
|
|
|
|
|
Code int `json:"code"`
|
|
|
|
|
Message string `json:"message"`
|
|
|
|
|
Data struct {
|
|
|
|
|
Audio string `json:"audio"`
|
|
|
|
|
} `json:"data"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err = json.Unmarshal(resp.Body, &ttsResult); err != nil {
|
2026-05-12 17:17:44 +08:00
|
|
|
return nil, fmt.Errorf("TTS document failed: invalid JSON (%w)", err)
|
|
|
|
|
}
|
Go: implement TTS for MiniMax provider and CLI testing for TTS (#14911)
### What problem does this PR solve?
This PR implement TTS for MiniMax provider and CLI testing for TTS
**The following functionalities are now supported:**
**MiniMax:**
- [x] Chat / Stream Chat
- [x] Embedding
- [x] Rerank
- [x] Model listing
- [x] Provider connection checking
- [x] Text To Speech
- [ ] OCRFile
- [ ] ~~Audio To Text~~
- [ ] ~~Balance~~
**Verified examples from the CLI:**
```plaintext
RAGFlow(user)> tts with 'speech-2.8-hd@test@minimax' text 'He who desires but acts not, breeds pestilence.' play format 'wav' save './internal' param '{"voice_setting": {"voice_id": "English_radiant_girl", "speed": 1, "vol": 1, "pitch": 0}, "audio_setting": {"sample_rate": 32000, "bitrate": 128000, "format": "wav", "channel": 1}, "output_format": "hex"}'
Saved to directory: /home/infiniflow/Documents/development/ragflow/internal/speech-2.8-hd_output.wav
SUCCESS
RAGFlow(user)> stream tts with 'speech-2.8-hd@test@minimax' text 'He who desires but acts not, breeds pestilence.' play format 'wav' save './internal' param '{"voice_setting": {"voice_id": "English_radiant_girl", "speed": 1, "vol": 1, "pitch": 0}, "audio_setting": {"sample_rate": 32000, "bitrate": 128000, "format": "wav", "channel": 1}, "output_format": "hex"}'
Saved to directory: /home/infiniflow/Documents/development/ragflow/internal/speech-2.8-hd_output.wav
SUCCESS
```
Set `Play` to play audio in CLI
Set `Save` `PATH_TO_SAVE` to save file
Set `format` to save file in wav or mp3
Set `Param` align with official request body
### Type of change
- [x] New Feature (non-breaking change which adds functionality)
2026-05-14 13:19:31 +08:00
|
|
|
|
|
|
|
|
if ttsResult.Code != 0 {
|
|
|
|
|
return nil, fmt.Errorf("%s", ttsResult.Message)
|
2026-05-12 17:17:44 +08:00
|
|
|
}
|
|
|
|
|
|
Go: implement TTS for MiniMax provider and CLI testing for TTS (#14911)
### What problem does this PR solve?
This PR implement TTS for MiniMax provider and CLI testing for TTS
**The following functionalities are now supported:**
**MiniMax:**
- [x] Chat / Stream Chat
- [x] Embedding
- [x] Rerank
- [x] Model listing
- [x] Provider connection checking
- [x] Text To Speech
- [ ] OCRFile
- [ ] ~~Audio To Text~~
- [ ] ~~Balance~~
**Verified examples from the CLI:**
```plaintext
RAGFlow(user)> tts with 'speech-2.8-hd@test@minimax' text 'He who desires but acts not, breeds pestilence.' play format 'wav' save './internal' param '{"voice_setting": {"voice_id": "English_radiant_girl", "speed": 1, "vol": 1, "pitch": 0}, "audio_setting": {"sample_rate": 32000, "bitrate": 128000, "format": "wav", "channel": 1}, "output_format": "hex"}'
Saved to directory: /home/infiniflow/Documents/development/ragflow/internal/speech-2.8-hd_output.wav
SUCCESS
RAGFlow(user)> stream tts with 'speech-2.8-hd@test@minimax' text 'He who desires but acts not, breeds pestilence.' play format 'wav' save './internal' param '{"voice_setting": {"voice_id": "English_radiant_girl", "speed": 1, "vol": 1, "pitch": 0}, "audio_setting": {"sample_rate": 32000, "bitrate": 128000, "format": "wav", "channel": 1}, "output_format": "hex"}'
Saved to directory: /home/infiniflow/Documents/development/ragflow/internal/speech-2.8-hd_output.wav
SUCCESS
```
Set `Play` to play audio in CLI
Set `Save` `PATH_TO_SAVE` to save file
Set `format` to save file in wav or mp3
Set `Param` align with official request body
### Type of change
- [x] New Feature (non-breaking change which adds functionality)
2026-05-14 13:19:31 +08:00
|
|
|
// Convert Base64 back to the original audio byte stream
|
|
|
|
|
audioBytes, err := base64.StdEncoding.DecodeString(ttsResult.Data.Audio)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to decode audio base64: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
shouldPlay, _ := cmd.Params["play"].(bool)
|
|
|
|
|
shouldSave, _ := cmd.Params["save"].(bool)
|
|
|
|
|
saveDir, _ := cmd.Params["save_path"].(string)
|
|
|
|
|
|
2026-05-15 14:03:33 +08:00
|
|
|
// format file name
|
|
|
|
|
safeModelName := strings.ReplaceAll(modelName, "/", "_")
|
|
|
|
|
safeModelName = strings.ReplaceAll(safeModelName, ":", "-")
|
|
|
|
|
fileName := fmt.Sprintf("%s_output.%s", safeModelName, explicitFormat)
|
Go: implement TTS for MiniMax provider and CLI testing for TTS (#14911)
### What problem does this PR solve?
This PR implement TTS for MiniMax provider and CLI testing for TTS
**The following functionalities are now supported:**
**MiniMax:**
- [x] Chat / Stream Chat
- [x] Embedding
- [x] Rerank
- [x] Model listing
- [x] Provider connection checking
- [x] Text To Speech
- [ ] OCRFile
- [ ] ~~Audio To Text~~
- [ ] ~~Balance~~
**Verified examples from the CLI:**
```plaintext
RAGFlow(user)> tts with 'speech-2.8-hd@test@minimax' text 'He who desires but acts not, breeds pestilence.' play format 'wav' save './internal' param '{"voice_setting": {"voice_id": "English_radiant_girl", "speed": 1, "vol": 1, "pitch": 0}, "audio_setting": {"sample_rate": 32000, "bitrate": 128000, "format": "wav", "channel": 1}, "output_format": "hex"}'
Saved to directory: /home/infiniflow/Documents/development/ragflow/internal/speech-2.8-hd_output.wav
SUCCESS
RAGFlow(user)> stream tts with 'speech-2.8-hd@test@minimax' text 'He who desires but acts not, breeds pestilence.' play format 'wav' save './internal' param '{"voice_setting": {"voice_id": "English_radiant_girl", "speed": 1, "vol": 1, "pitch": 0}, "audio_setting": {"sample_rate": 32000, "bitrate": 128000, "format": "wav", "channel": 1}, "output_format": "hex"}'
Saved to directory: /home/infiniflow/Documents/development/ragflow/internal/speech-2.8-hd_output.wav
SUCCESS
```
Set `Play` to play audio in CLI
Set `Save` `PATH_TO_SAVE` to save file
Set `format` to save file in wav or mp3
Set `Param` align with official request body
### Type of change
- [x] New Feature (non-breaking change which adds functionality)
2026-05-14 13:19:31 +08:00
|
|
|
|
|
|
|
|
cwd, err := os.Getwd()
|
|
|
|
|
if err != nil {
|
|
|
|
|
cwd = "."
|
|
|
|
|
}
|
|
|
|
|
localPath := filepath.Join(cwd, fileName)
|
|
|
|
|
|
|
|
|
|
if err := os.WriteFile(localPath, audioBytes, 0644); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to write local audio file: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if shouldPlay {
|
|
|
|
|
cmdExec := exec.Command("aplay", localPath)
|
|
|
|
|
if err := cmdExec.Run(); err != nil {
|
|
|
|
|
fmt.Printf("Play error: %v (Hint: did you use 'format: wav' in your params?)\n", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var finalMessage string
|
|
|
|
|
if shouldSave {
|
|
|
|
|
if saveDir == "" {
|
|
|
|
|
saveDir = cwd
|
|
|
|
|
} else {
|
|
|
|
|
absSaveDir, err := filepath.Abs(saveDir)
|
|
|
|
|
if err == nil {
|
|
|
|
|
saveDir = absSaveDir
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := os.MkdirAll(saveDir, 0755); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to create save directory: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
finalPath := filepath.Join(saveDir, fileName)
|
|
|
|
|
if err := os.WriteFile(finalPath, audioBytes, 0644); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to save file to target directory: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if saveDir != cwd {
|
|
|
|
|
os.Remove(localPath)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
finalMessage = fmt.Sprintf("Saved to directory: %s", finalPath)
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
defer os.Remove(localPath)
|
|
|
|
|
finalMessage = "TTS Task Completed (Audio not saved)"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if finalMessage != "" && shouldSave {
|
|
|
|
|
fmt.Println(finalMessage)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var result SimpleResponse
|
|
|
|
|
result.Code = 0
|
|
|
|
|
result.Message = "SUCCESS"
|
|
|
|
|
result.Duration = resp.Duration
|
2026-05-12 17:17:44 +08:00
|
|
|
|
|
|
|
|
return &result, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 15:22:50 +08:00
|
|
|
func (c *CLI) ASRUserCommand(cmd *Command) (ResponseIf, error) {
|
2026-06-24 18:48:09 +08:00
|
|
|
if c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].APIKey == nil && c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].LoginToken == nil {
|
|
|
|
|
return nil, fmt.Errorf("API key not set. Please login first")
|
2026-05-12 17:17:44 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-09 15:22:50 +08:00
|
|
|
if c.Config.CLIMode != APIMode {
|
2026-05-12 17:17:44 +08:00
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var providerName, instanceName, modelName string
|
2026-06-15 10:10:14 +08:00
|
|
|
var err error
|
2026-05-12 17:17:44 +08:00
|
|
|
|
|
|
|
|
// Check if composite_model_name is provided in command
|
2026-06-15 10:10:14 +08:00
|
|
|
compositeModelName, ok := cmd.Params["composite_model_name"].(string)
|
|
|
|
|
if ok {
|
|
|
|
|
modelName, instanceName, providerName, err = common.ExtractCompositeName(compositeModelName)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
2026-05-12 17:17:44 +08:00
|
|
|
}
|
2026-06-15 10:10:14 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
modelID, ok := cmd.Params["model_id"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
modelID = ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if modelID == "" && compositeModelName == "" {
|
|
|
|
|
if c.CurrentModel == nil {
|
|
|
|
|
return nil, fmt.Errorf("model name or ID not provided and no current model set. Use 'use model' command first")
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 17:17:44 +08:00
|
|
|
// Use current model if set
|
2026-06-15 10:10:14 +08:00
|
|
|
if c.CurrentModel.ModelID != "" {
|
|
|
|
|
modelID = c.CurrentModel.ModelID
|
|
|
|
|
} else {
|
|
|
|
|
providerName = c.CurrentModel.Provider
|
|
|
|
|
instanceName = c.CurrentModel.Instance
|
|
|
|
|
modelName = c.CurrentModel.Model
|
|
|
|
|
}
|
2026-05-12 17:17:44 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
audioFile, ok := cmd.Params["audio_file"].(string)
|
|
|
|
|
if !ok {
|
Go: implement TTS for fishaudio, openrouter and asr for fishaudio (#14926)
### What problem does this PR solve?
This PR implement TTS for FishAudio and MiniMax provider and ASR for
FishAudio
**The following functionalities are now supported:**
**FishAudio:**
- [x] Text To Speech
- [x] Stream Text To Speech
- [x] Audio To Text
**OpenRouter:**
- [x] Text To Speech
**Verified examples from the CLI:**
```plaintext
**FishAudio**
RAGFlow(user)> tts with 's1@test@fishaudio' text 'He who desires but acts not, breeds pestilence.' play format 'wav' save './internal' param '{"reference_id": "90e65eaaf50e4470b8e6d43ee6afd7d5", "temperature": 0.7, "top_p": 0.7, "prosody": {"speed": 1, "volume": 0, "normalize_loudness": true}, "chunk_length": 300, "normalize": true, "sample_rate": 44100, "mp3_bitrate": 128, "latency": "normal", "max_new_tokens": 1024, "repetition_penalty": 1.2, "min_chunk_length": 50, "condition_on_previous_chunks": true, "early_stop_threshold": 1}'
Saved to directory: /home/infiniflow/Documents/development/ragflow/internal/s1_output.wav
SUCCESS
RAGFlow(user)> stream tts with 's1@test@fishaudio' text 'He who desires but acts not, breeds pestilence.' play format 'wav' save './internal' param '{"reference_id": "90e65eaaf50e4470b8e6d43ee6afd7d5", "temperature": 0.7, "top_p": 0.7, "prosody": {"speed": 1, "volume": 0, "normalize_loudness": true}, "chunk_length": 300, "normalize": true, "sample_rate": 44100, "mp3_bitrate": 128, "latency": "normal", "max_new_tokens": 1024, "repetition_penalty": 1.2, "min_chunk_length": 50, "condition_on_previous_chunks": true, "early_stop_threshold": 1}'
Saved to directory: /home/infiniflow/Documents/development/ragflow/internal/s1_output.wav
SUCCESS
RAGFlow(user)> asr with 'transcribe-1@test@fishaudio' audio './internal/test.wav' param '{"language": "en", "ignore_timestamps": true}'
+----------------------------------------------------------------------------------------------------------------------+
| text |
+----------------------------------------------------------------------------------------------------------------------+
| The examination and testimony of the experts enabled the commission to conclude that five shots may have been fired. |
+----------------------------------------------------------------------------------------------------------------------+
```
### Type of change
- [x] Bug Fix (non-breaking change which fixes an issue)
- [x] New Feature (non-breaking change which adds functionality)
- [x] Refactoring
2026-05-14 18:58:00 +08:00
|
|
|
return nil, fmt.Errorf("audio file not provided")
|
2026-05-12 17:17:44 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
payload := map[string]interface{}{
|
2026-06-15 10:10:14 +08:00
|
|
|
"file": audioFile,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if modelID == "" {
|
|
|
|
|
payload["provider_name"] = providerName
|
|
|
|
|
payload["instance_name"] = instanceName
|
|
|
|
|
payload["model_name"] = modelName
|
|
|
|
|
} else {
|
|
|
|
|
payload["model_id"] = modelID
|
Go: implement TTS for fishaudio, openrouter and asr for fishaudio (#14926)
### What problem does this PR solve?
This PR implement TTS for FishAudio and MiniMax provider and ASR for
FishAudio
**The following functionalities are now supported:**
**FishAudio:**
- [x] Text To Speech
- [x] Stream Text To Speech
- [x] Audio To Text
**OpenRouter:**
- [x] Text To Speech
**Verified examples from the CLI:**
```plaintext
**FishAudio**
RAGFlow(user)> tts with 's1@test@fishaudio' text 'He who desires but acts not, breeds pestilence.' play format 'wav' save './internal' param '{"reference_id": "90e65eaaf50e4470b8e6d43ee6afd7d5", "temperature": 0.7, "top_p": 0.7, "prosody": {"speed": 1, "volume": 0, "normalize_loudness": true}, "chunk_length": 300, "normalize": true, "sample_rate": 44100, "mp3_bitrate": 128, "latency": "normal", "max_new_tokens": 1024, "repetition_penalty": 1.2, "min_chunk_length": 50, "condition_on_previous_chunks": true, "early_stop_threshold": 1}'
Saved to directory: /home/infiniflow/Documents/development/ragflow/internal/s1_output.wav
SUCCESS
RAGFlow(user)> stream tts with 's1@test@fishaudio' text 'He who desires but acts not, breeds pestilence.' play format 'wav' save './internal' param '{"reference_id": "90e65eaaf50e4470b8e6d43ee6afd7d5", "temperature": 0.7, "top_p": 0.7, "prosody": {"speed": 1, "volume": 0, "normalize_loudness": true}, "chunk_length": 300, "normalize": true, "sample_rate": 44100, "mp3_bitrate": 128, "latency": "normal", "max_new_tokens": 1024, "repetition_penalty": 1.2, "min_chunk_length": 50, "condition_on_previous_chunks": true, "early_stop_threshold": 1}'
Saved to directory: /home/infiniflow/Documents/development/ragflow/internal/s1_output.wav
SUCCESS
RAGFlow(user)> asr with 'transcribe-1@test@fishaudio' audio './internal/test.wav' param '{"language": "en", "ignore_timestamps": true}'
+----------------------------------------------------------------------------------------------------------------------+
| text |
+----------------------------------------------------------------------------------------------------------------------+
| The examination and testimony of the experts enabled the commission to conclude that five shots may have been fired. |
+----------------------------------------------------------------------------------------------------------------------+
```
### Type of change
- [x] Bug Fix (non-breaking change which fixes an issue)
- [x] New Feature (non-breaking change which adds functionality)
- [x] Refactoring
2026-05-14 18:58:00 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
asrConfigPayload := make(map[string]interface{})
|
|
|
|
|
if paramStr, ok := cmd.Params["param_str"].(string); ok && paramStr != "" {
|
|
|
|
|
var dynamicParams map[string]interface{}
|
|
|
|
|
if err := json.Unmarshal([]byte(paramStr), &dynamicParams); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("param string must be valid JSON. Error: %w", err)
|
|
|
|
|
}
|
|
|
|
|
asrConfigPayload["params"] = dynamicParams
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if len(asrConfigPayload) > 0 {
|
|
|
|
|
payload["asr_config"] = asrConfigPayload
|
2026-05-12 17:17:44 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
url := "/audio/transcriptions"
|
|
|
|
|
|
2026-06-09 15:22:50 +08:00
|
|
|
resp, err := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].Request("POST", url, "web", nil, payload)
|
2026-05-12 17:17:44 +08:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to ASR document: %w", err)
|
|
|
|
|
}
|
|
|
|
|
if resp.StatusCode != 200 {
|
|
|
|
|
return nil, fmt.Errorf("failed to ASR document: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
|
|
|
|
}
|
Go: implement TTS for fishaudio, openrouter and asr for fishaudio (#14926)
### What problem does this PR solve?
This PR implement TTS for FishAudio and MiniMax provider and ASR for
FishAudio
**The following functionalities are now supported:**
**FishAudio:**
- [x] Text To Speech
- [x] Stream Text To Speech
- [x] Audio To Text
**OpenRouter:**
- [x] Text To Speech
**Verified examples from the CLI:**
```plaintext
**FishAudio**
RAGFlow(user)> tts with 's1@test@fishaudio' text 'He who desires but acts not, breeds pestilence.' play format 'wav' save './internal' param '{"reference_id": "90e65eaaf50e4470b8e6d43ee6afd7d5", "temperature": 0.7, "top_p": 0.7, "prosody": {"speed": 1, "volume": 0, "normalize_loudness": true}, "chunk_length": 300, "normalize": true, "sample_rate": 44100, "mp3_bitrate": 128, "latency": "normal", "max_new_tokens": 1024, "repetition_penalty": 1.2, "min_chunk_length": 50, "condition_on_previous_chunks": true, "early_stop_threshold": 1}'
Saved to directory: /home/infiniflow/Documents/development/ragflow/internal/s1_output.wav
SUCCESS
RAGFlow(user)> stream tts with 's1@test@fishaudio' text 'He who desires but acts not, breeds pestilence.' play format 'wav' save './internal' param '{"reference_id": "90e65eaaf50e4470b8e6d43ee6afd7d5", "temperature": 0.7, "top_p": 0.7, "prosody": {"speed": 1, "volume": 0, "normalize_loudness": true}, "chunk_length": 300, "normalize": true, "sample_rate": 44100, "mp3_bitrate": 128, "latency": "normal", "max_new_tokens": 1024, "repetition_penalty": 1.2, "min_chunk_length": 50, "condition_on_previous_chunks": true, "early_stop_threshold": 1}'
Saved to directory: /home/infiniflow/Documents/development/ragflow/internal/s1_output.wav
SUCCESS
RAGFlow(user)> asr with 'transcribe-1@test@fishaudio' audio './internal/test.wav' param '{"language": "en", "ignore_timestamps": true}'
+----------------------------------------------------------------------------------------------------------------------+
| text |
+----------------------------------------------------------------------------------------------------------------------+
| The examination and testimony of the experts enabled the commission to conclude that five shots may have been fired. |
+----------------------------------------------------------------------------------------------------------------------+
```
### Type of change
- [x] Bug Fix (non-breaking change which fixes an issue)
- [x] New Feature (non-breaking change which adds functionality)
- [x] Refactoring
2026-05-14 18:58:00 +08:00
|
|
|
var rawResult struct {
|
|
|
|
|
Code int `json:"code"`
|
|
|
|
|
Message string `json:"message"`
|
|
|
|
|
Data map[string]interface{} `json:"data"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err = json.Unmarshal(resp.Body, &rawResult); err != nil {
|
2026-05-12 17:17:44 +08:00
|
|
|
return nil, fmt.Errorf("ASR document failed: invalid JSON (%w)", err)
|
|
|
|
|
}
|
Go: implement TTS for fishaudio, openrouter and asr for fishaudio (#14926)
### What problem does this PR solve?
This PR implement TTS for FishAudio and MiniMax provider and ASR for
FishAudio
**The following functionalities are now supported:**
**FishAudio:**
- [x] Text To Speech
- [x] Stream Text To Speech
- [x] Audio To Text
**OpenRouter:**
- [x] Text To Speech
**Verified examples from the CLI:**
```plaintext
**FishAudio**
RAGFlow(user)> tts with 's1@test@fishaudio' text 'He who desires but acts not, breeds pestilence.' play format 'wav' save './internal' param '{"reference_id": "90e65eaaf50e4470b8e6d43ee6afd7d5", "temperature": 0.7, "top_p": 0.7, "prosody": {"speed": 1, "volume": 0, "normalize_loudness": true}, "chunk_length": 300, "normalize": true, "sample_rate": 44100, "mp3_bitrate": 128, "latency": "normal", "max_new_tokens": 1024, "repetition_penalty": 1.2, "min_chunk_length": 50, "condition_on_previous_chunks": true, "early_stop_threshold": 1}'
Saved to directory: /home/infiniflow/Documents/development/ragflow/internal/s1_output.wav
SUCCESS
RAGFlow(user)> stream tts with 's1@test@fishaudio' text 'He who desires but acts not, breeds pestilence.' play format 'wav' save './internal' param '{"reference_id": "90e65eaaf50e4470b8e6d43ee6afd7d5", "temperature": 0.7, "top_p": 0.7, "prosody": {"speed": 1, "volume": 0, "normalize_loudness": true}, "chunk_length": 300, "normalize": true, "sample_rate": 44100, "mp3_bitrate": 128, "latency": "normal", "max_new_tokens": 1024, "repetition_penalty": 1.2, "min_chunk_length": 50, "condition_on_previous_chunks": true, "early_stop_threshold": 1}'
Saved to directory: /home/infiniflow/Documents/development/ragflow/internal/s1_output.wav
SUCCESS
RAGFlow(user)> asr with 'transcribe-1@test@fishaudio' audio './internal/test.wav' param '{"language": "en", "ignore_timestamps": true}'
+----------------------------------------------------------------------------------------------------------------------+
| text |
+----------------------------------------------------------------------------------------------------------------------+
| The examination and testimony of the experts enabled the commission to conclude that five shots may have been fired. |
+----------------------------------------------------------------------------------------------------------------------+
```
### Type of change
- [x] Bug Fix (non-breaking change which fixes an issue)
- [x] New Feature (non-breaking change which adds functionality)
- [x] Refactoring
2026-05-14 18:58:00 +08:00
|
|
|
|
|
|
|
|
if rawResult.Code != 0 {
|
|
|
|
|
return nil, fmt.Errorf("%s", rawResult.Message)
|
2026-05-12 17:17:44 +08:00
|
|
|
}
|
Go: implement TTS for fishaudio, openrouter and asr for fishaudio (#14926)
### What problem does this PR solve?
This PR implement TTS for FishAudio and MiniMax provider and ASR for
FishAudio
**The following functionalities are now supported:**
**FishAudio:**
- [x] Text To Speech
- [x] Stream Text To Speech
- [x] Audio To Text
**OpenRouter:**
- [x] Text To Speech
**Verified examples from the CLI:**
```plaintext
**FishAudio**
RAGFlow(user)> tts with 's1@test@fishaudio' text 'He who desires but acts not, breeds pestilence.' play format 'wav' save './internal' param '{"reference_id": "90e65eaaf50e4470b8e6d43ee6afd7d5", "temperature": 0.7, "top_p": 0.7, "prosody": {"speed": 1, "volume": 0, "normalize_loudness": true}, "chunk_length": 300, "normalize": true, "sample_rate": 44100, "mp3_bitrate": 128, "latency": "normal", "max_new_tokens": 1024, "repetition_penalty": 1.2, "min_chunk_length": 50, "condition_on_previous_chunks": true, "early_stop_threshold": 1}'
Saved to directory: /home/infiniflow/Documents/development/ragflow/internal/s1_output.wav
SUCCESS
RAGFlow(user)> stream tts with 's1@test@fishaudio' text 'He who desires but acts not, breeds pestilence.' play format 'wav' save './internal' param '{"reference_id": "90e65eaaf50e4470b8e6d43ee6afd7d5", "temperature": 0.7, "top_p": 0.7, "prosody": {"speed": 1, "volume": 0, "normalize_loudness": true}, "chunk_length": 300, "normalize": true, "sample_rate": 44100, "mp3_bitrate": 128, "latency": "normal", "max_new_tokens": 1024, "repetition_penalty": 1.2, "min_chunk_length": 50, "condition_on_previous_chunks": true, "early_stop_threshold": 1}'
Saved to directory: /home/infiniflow/Documents/development/ragflow/internal/s1_output.wav
SUCCESS
RAGFlow(user)> asr with 'transcribe-1@test@fishaudio' audio './internal/test.wav' param '{"language": "en", "ignore_timestamps": true}'
+----------------------------------------------------------------------------------------------------------------------+
| text |
+----------------------------------------------------------------------------------------------------------------------+
| The examination and testimony of the experts enabled the commission to conclude that five shots may have been fired. |
+----------------------------------------------------------------------------------------------------------------------+
```
### Type of change
- [x] Bug Fix (non-breaking change which fixes an issue)
- [x] New Feature (non-breaking change which adds functionality)
- [x] Refactoring
2026-05-14 18:58:00 +08:00
|
|
|
|
|
|
|
|
var result CommonResponse
|
|
|
|
|
result.Code = rawResult.Code
|
2026-05-15 14:03:33 +08:00
|
|
|
result.Data = []map[string]interface{}{
|
|
|
|
|
{"text": rawResult.Data["text"].(string)},
|
|
|
|
|
}
|
2026-05-12 17:17:44 +08:00
|
|
|
result.Duration = resp.Duration
|
|
|
|
|
|
|
|
|
|
return &result, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 15:22:50 +08:00
|
|
|
func (c *CLI) OCRUserCommand(cmd *Command) (ResponseIf, error) {
|
2026-06-24 18:48:09 +08:00
|
|
|
if c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].APIKey == nil && c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].LoginToken == nil {
|
|
|
|
|
return nil, fmt.Errorf("API key not set. Please login first")
|
2026-05-12 17:17:44 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-09 15:22:50 +08:00
|
|
|
if c.Config.CLIMode != APIMode {
|
2026-05-12 17:17:44 +08:00
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var providerName, instanceName, modelName string
|
2026-06-15 10:10:14 +08:00
|
|
|
var err error
|
2026-05-12 17:17:44 +08:00
|
|
|
|
|
|
|
|
// Check if composite_model_name is provided in command
|
2026-06-15 10:10:14 +08:00
|
|
|
compositeModelName, ok := cmd.Params["composite_model_name"].(string)
|
|
|
|
|
if ok {
|
|
|
|
|
modelName, instanceName, providerName, err = common.ExtractCompositeName(compositeModelName)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
2026-05-12 17:17:44 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-15 10:10:14 +08:00
|
|
|
modelID, ok := cmd.Params["model_id"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
modelID = ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if modelID == "" && compositeModelName == "" {
|
|
|
|
|
if c.CurrentModel == nil {
|
|
|
|
|
return nil, fmt.Errorf("model name or ID not provided and no current model set. Use 'use model' command first")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Use current model if set
|
|
|
|
|
if c.CurrentModel.ModelID != "" {
|
|
|
|
|
modelID = c.CurrentModel.ModelID
|
|
|
|
|
} else {
|
|
|
|
|
providerName = c.CurrentModel.Provider
|
|
|
|
|
instanceName = c.CurrentModel.Instance
|
|
|
|
|
modelName = c.CurrentModel.Model
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-13 17:29:53 +08:00
|
|
|
var filename string
|
|
|
|
|
var fileURL string
|
|
|
|
|
var fileContent []byte
|
2026-05-12 17:17:44 +08:00
|
|
|
|
2026-05-13 17:29:53 +08:00
|
|
|
filename, ok = cmd.Params["file"].(string)
|
|
|
|
|
if ok {
|
|
|
|
|
// read file and convert to base64
|
|
|
|
|
var err error
|
|
|
|
|
fileContent, err = os.ReadFile(filename)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to read file: %w", err)
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
fileURL, ok = cmd.Params["url"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("file or url not provided")
|
|
|
|
|
}
|
2026-05-12 17:17:44 +08:00
|
|
|
}
|
2026-05-13 17:29:53 +08:00
|
|
|
|
2026-06-15 10:10:14 +08:00
|
|
|
payload := map[string]interface{}{}
|
|
|
|
|
|
|
|
|
|
if modelID == "" {
|
|
|
|
|
payload["provider_name"] = providerName
|
|
|
|
|
payload["instance_name"] = instanceName
|
|
|
|
|
payload["model_name"] = modelName
|
|
|
|
|
} else {
|
|
|
|
|
payload["model_id"] = modelID
|
2026-05-13 17:29:53 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if fileContent != nil {
|
|
|
|
|
payload["content"] = fileContent
|
|
|
|
|
} else {
|
|
|
|
|
payload["url"] = fileURL
|
2026-05-12 17:17:44 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
url := "/file/ocr"
|
|
|
|
|
|
2026-06-09 15:22:50 +08:00
|
|
|
resp, err := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].Request("POST", url, "web", nil, payload)
|
2026-05-12 17:17:44 +08:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to OCR document: %w", err)
|
|
|
|
|
}
|
|
|
|
|
if resp.StatusCode != 200 {
|
|
|
|
|
return nil, fmt.Errorf("failed to OCR document: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
|
|
|
|
}
|
2026-05-13 17:29:53 +08:00
|
|
|
var result CommonDataResponse
|
2026-05-12 17:17:44 +08:00
|
|
|
if err = json.Unmarshal(resp.Body, &result); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("OCR document failed: invalid JSON (%w)", err)
|
|
|
|
|
}
|
|
|
|
|
if result.Code != 0 {
|
|
|
|
|
return nil, fmt.Errorf("%s", result.Message)
|
|
|
|
|
}
|
|
|
|
|
result.Duration = resp.Duration
|
|
|
|
|
|
|
|
|
|
return &result, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 15:22:50 +08:00
|
|
|
func (c *CLI) ParseFileUserCommand(cmd *Command) (ResponseIf, error) {
|
2026-06-24 18:48:09 +08:00
|
|
|
if c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].APIKey == nil && c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].LoginToken == nil {
|
|
|
|
|
return nil, fmt.Errorf("API key not set. Please login first")
|
2026-05-15 12:29:52 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-09 15:22:50 +08:00
|
|
|
if c.Config.CLIMode != APIMode {
|
2026-05-15 12:29:52 +08:00
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var providerName, instanceName, modelName string
|
2026-06-15 10:10:14 +08:00
|
|
|
var err error
|
2026-05-15 12:29:52 +08:00
|
|
|
|
|
|
|
|
// Check if composite_model_name is provided in command
|
2026-06-15 10:10:14 +08:00
|
|
|
compositeModelName, ok := cmd.Params["composite_model_name"].(string)
|
|
|
|
|
if ok {
|
|
|
|
|
modelName, instanceName, providerName, err = common.ExtractCompositeName(compositeModelName)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
modelID, ok := cmd.Params["model_id"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
modelID = ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if modelID == "" && compositeModelName == "" {
|
|
|
|
|
if c.CurrentModel == nil {
|
|
|
|
|
return nil, fmt.Errorf("model name or ID not provided and no current model set. Use 'use model' command first")
|
2026-05-15 12:29:52 +08:00
|
|
|
}
|
2026-06-15 10:10:14 +08:00
|
|
|
|
2026-05-15 12:29:52 +08:00
|
|
|
// Use current model if set
|
2026-06-15 10:10:14 +08:00
|
|
|
if c.CurrentModel.ModelID != "" {
|
|
|
|
|
modelID = c.CurrentModel.ModelID
|
|
|
|
|
} else {
|
|
|
|
|
providerName = c.CurrentModel.Provider
|
|
|
|
|
instanceName = c.CurrentModel.Instance
|
|
|
|
|
modelName = c.CurrentModel.Model
|
|
|
|
|
}
|
2026-05-15 12:29:52 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var filename string
|
|
|
|
|
var fileURL string
|
|
|
|
|
var fileContent []byte
|
|
|
|
|
|
|
|
|
|
filename, ok = cmd.Params["file"].(string)
|
|
|
|
|
if ok {
|
2026-05-19 10:49:33 +08:00
|
|
|
// For online file
|
|
|
|
|
if strings.HasPrefix(filename, "http://") || strings.HasPrefix(filename, "https://") {
|
|
|
|
|
fileURL = filename
|
|
|
|
|
} else {
|
|
|
|
|
// read file and convert to base64
|
|
|
|
|
var err error
|
|
|
|
|
fileContent, err = os.ReadFile(filename)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to read file: %w", err)
|
|
|
|
|
}
|
2026-05-15 12:29:52 +08:00
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
fileURL, ok = cmd.Params["url"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("file or url not provided")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-15 10:10:14 +08:00
|
|
|
payload := map[string]interface{}{}
|
|
|
|
|
|
|
|
|
|
if modelID == "" {
|
|
|
|
|
payload["provider_name"] = providerName
|
|
|
|
|
payload["instance_name"] = instanceName
|
|
|
|
|
payload["model_name"] = modelName
|
|
|
|
|
} else {
|
|
|
|
|
payload["model_id"] = modelID
|
2026-05-15 12:29:52 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if fileContent != nil {
|
|
|
|
|
payload["content"] = fileContent
|
|
|
|
|
} else {
|
|
|
|
|
payload["url"] = fileURL
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
url := "/file/parse"
|
|
|
|
|
|
2026-06-09 15:22:50 +08:00
|
|
|
resp, err := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].Request("POST", url, "web", nil, payload)
|
2026-05-15 12:29:52 +08:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to PARSE document: %w", err)
|
|
|
|
|
}
|
|
|
|
|
if resp.StatusCode != 200 {
|
|
|
|
|
return nil, fmt.Errorf("failed to PARSE document: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
|
|
|
|
}
|
|
|
|
|
var result CommonDataResponse
|
|
|
|
|
if err = json.Unmarshal(resp.Body, &result); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("PARSE document failed: invalid JSON (%w)", err)
|
|
|
|
|
}
|
|
|
|
|
if result.Code != 0 {
|
|
|
|
|
return nil, fmt.Errorf("%s", result.Message)
|
|
|
|
|
}
|
|
|
|
|
result.Duration = resp.Duration
|
|
|
|
|
|
|
|
|
|
return &result, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-25 15:49:31 +08:00
|
|
|
func (c *CLI) APIListModelInstanceTasksCommand(cmd *Command) (ResponseIf, error) {
|
2026-06-24 18:48:09 +08:00
|
|
|
if c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].APIKey == nil && c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].LoginToken == nil {
|
|
|
|
|
return nil, fmt.Errorf("API key not set. Please login first")
|
2026-05-15 12:29:52 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-09 15:22:50 +08:00
|
|
|
if c.Config.CLIMode != APIMode {
|
2026-05-15 12:29:52 +08:00
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-25 15:49:31 +08:00
|
|
|
providerName, ok := cmd.Params["provider_name"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("no provider name")
|
|
|
|
|
}
|
2026-05-15 12:29:52 +08:00
|
|
|
|
2026-06-25 15:49:31 +08:00
|
|
|
instanceName, ok := cmd.Params["instance_name"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("no instance name")
|
2026-05-15 12:29:52 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
url := fmt.Sprintf("/providers/%s/instances/%s/tasks", providerName, instanceName)
|
|
|
|
|
|
2026-06-09 15:22:50 +08:00
|
|
|
resp, err := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].Request("GET", url, "web", nil, nil)
|
2026-05-15 12:29:52 +08:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to list tasks: %w", err)
|
|
|
|
|
}
|
|
|
|
|
if resp.StatusCode != 200 {
|
|
|
|
|
return nil, fmt.Errorf("failed to list tasks: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
|
|
|
|
}
|
|
|
|
|
var result CommonResponse
|
|
|
|
|
if err = json.Unmarshal(resp.Body, &result); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("list tasks failed: invalid JSON (%w)", err)
|
|
|
|
|
}
|
|
|
|
|
if result.Code != 0 {
|
|
|
|
|
return nil, fmt.Errorf("%s", result.Message)
|
|
|
|
|
}
|
|
|
|
|
result.Duration = resp.Duration
|
|
|
|
|
return &result, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-26 19:16:14 +08:00
|
|
|
// APIShowProviderInstanceTaskCommand shows the details of a task
|
|
|
|
|
func (c *CLI) APIShowProviderInstanceTaskCommand(cmd *Command) (ResponseIf, error) {
|
|
|
|
|
|
|
|
|
|
httpClient := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer]
|
|
|
|
|
if httpClient.APIKey == nil && httpClient.LoginToken == nil {
|
2026-06-24 18:48:09 +08:00
|
|
|
return nil, fmt.Errorf("API key not set. Please login first")
|
2026-05-15 12:29:52 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-09 15:22:50 +08:00
|
|
|
if c.Config.CLIMode != APIMode {
|
2026-05-15 12:29:52 +08:00
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-26 19:16:14 +08:00
|
|
|
providerName, ok := cmd.Params["provider_name"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("no provider name")
|
|
|
|
|
}
|
2026-05-15 12:29:52 +08:00
|
|
|
|
2026-06-26 19:16:14 +08:00
|
|
|
instanceName, ok := cmd.Params["instance_name"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("no instance name")
|
2026-05-15 12:29:52 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
taskID, ok := cmd.Params["task_id"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("task id not provided")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
url := fmt.Sprintf("/providers/%s/instances/%s/tasks/%s", providerName, instanceName, taskID)
|
|
|
|
|
|
2026-06-26 19:16:14 +08:00
|
|
|
resp, err := httpClient.Request("GET", url, "web", nil, nil)
|
2026-05-15 12:29:52 +08:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to get task: %w", err)
|
|
|
|
|
}
|
|
|
|
|
if resp.StatusCode != 200 {
|
|
|
|
|
return nil, fmt.Errorf("failed to get task: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
|
|
|
|
}
|
|
|
|
|
var result TaskResponse
|
|
|
|
|
if err = json.Unmarshal(resp.Body, &result); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("get task failed: invalid JSON (%w)", err)
|
|
|
|
|
}
|
|
|
|
|
if result.Code != 0 {
|
|
|
|
|
return nil, fmt.Errorf("%s", result.Message)
|
|
|
|
|
}
|
|
|
|
|
result.Duration = resp.Duration
|
|
|
|
|
return &result, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-26 19:16:14 +08:00
|
|
|
// APIUseModelCommand sets the current model for chat
|
|
|
|
|
func (c *CLI) APIUseModelCommand(cmd *Command) (ResponseIf, error) {
|
2026-06-24 18:48:09 +08:00
|
|
|
if c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].APIKey == nil && c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].LoginToken == nil {
|
|
|
|
|
return nil, fmt.Errorf("API key not set. Please login first")
|
2026-04-02 20:20:35 +08:00
|
|
|
}
|
2026-06-09 15:22:50 +08:00
|
|
|
if c.Config.CLIMode != APIMode {
|
2026-04-02 20:20:35 +08:00
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-15 10:10:14 +08:00
|
|
|
var modelName, instanceName, providerName string
|
|
|
|
|
var err error
|
2026-04-20 15:31:12 +08:00
|
|
|
compositeModelName, ok := cmd.Params["composite_model_name"].(string)
|
2026-06-15 10:10:14 +08:00
|
|
|
if ok {
|
|
|
|
|
modelName, instanceName, providerName, err = common.ExtractCompositeName(compositeModelName)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
modelID, ok := cmd.Params["model_id"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
modelID = ""
|
2026-04-02 20:20:35 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-15 10:10:14 +08:00
|
|
|
if modelID == "" && compositeModelName == "" {
|
|
|
|
|
return nil, fmt.Errorf("model name or ID not provided and no current model set. Use 'use model' command first")
|
2026-04-02 20:20:35 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
c.CurrentModel = &CurrentModel{
|
2026-06-15 10:10:14 +08:00
|
|
|
Provider: providerName,
|
|
|
|
|
Instance: instanceName,
|
|
|
|
|
Model: modelName,
|
|
|
|
|
ModelID: modelID,
|
2026-04-02 20:20:35 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var result SimpleResponse
|
|
|
|
|
result.Code = 0
|
|
|
|
|
result.Message = fmt.Sprintf("Current model set to: %s/%s/%s", c.CurrentModel.Provider, c.CurrentModel.Instance, c.CurrentModel.Model)
|
|
|
|
|
return &result, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 15:22:50 +08:00
|
|
|
func (c *CLI) AddCustomModel(cmd *Command) (ResponseIf, error) {
|
2026-06-24 18:48:09 +08:00
|
|
|
if c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].APIKey == nil && c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].LoginToken == nil {
|
|
|
|
|
return nil, fmt.Errorf("API key not set. Please login first")
|
2026-04-29 17:05:08 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-09 15:22:50 +08:00
|
|
|
if c.Config.CLIMode != APIMode {
|
2026-04-29 17:05:08 +08:00
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
providerName, ok := cmd.Params["provider_name"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("provider name not provided")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
instanceName, ok := cmd.Params["instance_name"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("instance name not provided")
|
|
|
|
|
}
|
|
|
|
|
|
feat(go-cli): support batch model add/remove and optional embedding dimension (#15631)
## Summary
This PR improves the Go CLI in two areas:
1. It adds batch model management support, allowing multiple models to
be added or removed in a single command.
2. It makes the `dimension` argument optional for the `embed text`
command.
These changes keep the existing single-model and explicit-dimension
behaviors compatible while making the CLI more convenient for common
workflows.
## What Changed
### 1. Batch model add/remove support
The CLI now supports operating on multiple model names provided in a
single quoted string.
Supported commands include:
```
add model 'x1 x2 x3' to provider 'vllm' instance 'test' with tokens 1024 chat think vision, token 2048 chat, token 1024 think vision;
drop model 'x1 x2 x3' from 'vllm' 'test';
remove model 'x1 x2 x3' from 'vllm' 'test';
```
For add model, each config segment after with is matched to the
corresponding model name by position.
Example mapping:
- x1 -> tokens 1024, chat + vision, thinking=true
- x2 -> tokens 2048, chat
- x3 -> tokens 1024, vision, thinking=true
The existing single-model syntax remains supported.
### 2. Optional embedding dimension
Previously, the Go CLI required dimension to be explicitly provided for
embed text.
Before:
embed text 'what is rag' 'who are you' with 'model@test@provider'
dimension 8192;
Now both forms are supported:
embed text 'what is rag' 'who are you' with 'model@test@provider'
dimension 8192;
embed text 'what is rag' 'who are you' with 'model@test@provider';
When omitted, the CLI leaves dimension unset and relies on
provider/backend behavior.
## Tests
Added parser tests covering:
- Multiple models with multiple config segments
- Model type deduplication
- Model/config count mismatch
- Drop/remove multiple models
- Optional embedding dimension parsing
2026-06-05 19:31:06 +08:00
|
|
|
models, ok := cmd.Params["models"].([]map[string]any)
|
2026-04-29 17:05:08 +08:00
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("model name not provided")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
url := fmt.Sprintf("/providers/%s/instances/%s/models", providerName, instanceName)
|
|
|
|
|
|
|
|
|
|
payload := map[string]interface{}{
|
|
|
|
|
"provider_name": providerName,
|
|
|
|
|
"instance_name": instanceName,
|
feat(go-cli): support batch model add/remove and optional embedding dimension (#15631)
## Summary
This PR improves the Go CLI in two areas:
1. It adds batch model management support, allowing multiple models to
be added or removed in a single command.
2. It makes the `dimension` argument optional for the `embed text`
command.
These changes keep the existing single-model and explicit-dimension
behaviors compatible while making the CLI more convenient for common
workflows.
## What Changed
### 1. Batch model add/remove support
The CLI now supports operating on multiple model names provided in a
single quoted string.
Supported commands include:
```
add model 'x1 x2 x3' to provider 'vllm' instance 'test' with tokens 1024 chat think vision, token 2048 chat, token 1024 think vision;
drop model 'x1 x2 x3' from 'vllm' 'test';
remove model 'x1 x2 x3' from 'vllm' 'test';
```
For add model, each config segment after with is matched to the
corresponding model name by position.
Example mapping:
- x1 -> tokens 1024, chat + vision, thinking=true
- x2 -> tokens 2048, chat
- x3 -> tokens 1024, vision, thinking=true
The existing single-model syntax remains supported.
### 2. Optional embedding dimension
Previously, the Go CLI required dimension to be explicitly provided for
embed text.
Before:
embed text 'what is rag' 'who are you' with 'model@test@provider'
dimension 8192;
Now both forms are supported:
embed text 'what is rag' 'who are you' with 'model@test@provider'
dimension 8192;
embed text 'what is rag' 'who are you' with 'model@test@provider';
When omitted, the CLI leaves dimension unset and relies on
provider/backend behavior.
## Tests
Added parser tests covering:
- Multiple models with multiple config segments
- Model type deduplication
- Model/config count mismatch
- Drop/remove multiple models
- Optional embedding dimension parsing
2026-06-05 19:31:06 +08:00
|
|
|
"models": models,
|
2026-04-29 17:05:08 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-09 15:22:50 +08:00
|
|
|
resp, err := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].Request("POST", url, "web", nil, payload)
|
2026-04-29 17:05:08 +08:00
|
|
|
if err != nil {
|
2026-05-09 17:41:54 +08:00
|
|
|
return nil, fmt.Errorf("failed to add custom model: %w", err)
|
2026-04-29 17:05:08 +08:00
|
|
|
}
|
|
|
|
|
if resp.StatusCode != 200 {
|
2026-05-09 17:41:54 +08:00
|
|
|
return nil, fmt.Errorf("failed to add custom model: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
2026-04-29 17:05:08 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-29 11:13:14 +08:00
|
|
|
return HandleSimpleResponse(resp, "add custom model")
|
2026-04-29 17:05:08 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-25 19:15:07 +08:00
|
|
|
// InsertChunksFromFile inserts chunks from a JSON file
|
2026-06-09 15:22:50 +08:00
|
|
|
func (c *CLI) InsertChunksFromFile(cmd *Command) (ResponseIf, error) {
|
|
|
|
|
if c.Config.CLIMode != APIMode {
|
2026-04-01 16:16:25 +08:00
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
filePath, ok := cmd.Params["file_path"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("file_path not provided")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
payload := map[string]interface{}{
|
|
|
|
|
"file_path": filePath,
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 15:22:50 +08:00
|
|
|
resp, err := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].Request("POST", "/tenant/insert_chunks_from_file", "web", nil, payload)
|
2026-04-01 16:16:25 +08:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to insert dataset from file: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != 200 {
|
|
|
|
|
return nil, fmt.Errorf("failed to insert dataset from file: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resJSON, err := resp.JSON()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("invalid JSON response: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
code, ok := resJSON["code"].(float64)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("invalid response format: code is not a number")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var result SimpleResponse
|
|
|
|
|
result.Code = int(code)
|
|
|
|
|
if result.Code == 0 {
|
|
|
|
|
result.Message = fmt.Sprintf("Success to insert dataset from file: %s", filePath)
|
|
|
|
|
} else {
|
|
|
|
|
result.Message = fmt.Sprintf("Failed to insert dataset from file: %v", resJSON)
|
|
|
|
|
}
|
|
|
|
|
result.Duration = 0
|
|
|
|
|
return &result, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// InsertMetadataFromFile inserts metadata from a JSON file
|
2026-06-09 15:22:50 +08:00
|
|
|
func (c *CLI) InsertMetadataFromFile(cmd *Command) (ResponseIf, error) {
|
|
|
|
|
if c.Config.CLIMode != APIMode {
|
2026-04-01 16:16:25 +08:00
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
filePath, ok := cmd.Params["file_path"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("file_path not provided")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
payload := map[string]interface{}{
|
|
|
|
|
"file_path": filePath,
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 15:22:50 +08:00
|
|
|
resp, err := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].Request("POST", "/tenant/insert_metadata_from_file", "web", nil, payload)
|
2026-04-01 16:16:25 +08:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to insert metadata from file: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != 200 {
|
|
|
|
|
return nil, fmt.Errorf("failed to insert metadata from file: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resJSON, err := resp.JSON()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("invalid JSON response: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
code, ok := resJSON["code"].(float64)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("invalid response format: code is not a number")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var result SimpleResponse
|
|
|
|
|
result.Code = int(code)
|
|
|
|
|
if result.Code == 0 {
|
|
|
|
|
result.Message = fmt.Sprintf("Success to insert metadata from file: %s", filePath)
|
|
|
|
|
} else {
|
|
|
|
|
result.Message = fmt.Sprintf("Failed to insert metadata from file: %v", resJSON)
|
|
|
|
|
}
|
|
|
|
|
result.Duration = 0
|
|
|
|
|
return &result, nil
|
|
|
|
|
}
|
2026-04-07 09:44:51 +08:00
|
|
|
|
|
|
|
|
// UpdateChunk updates a chunk in a dataset
|
2026-06-09 15:22:50 +08:00
|
|
|
func (c *CLI) UpdateChunk(cmd *Command) (ResponseIf, error) {
|
|
|
|
|
if c.Config.CLIMode != APIMode {
|
2026-04-07 09:44:51 +08:00
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
chunkID, ok := cmd.Params["chunk_id"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("chunk_id not provided")
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-25 19:15:07 +08:00
|
|
|
docID, ok := cmd.Params["doc_id"].(string)
|
2026-04-07 09:44:51 +08:00
|
|
|
if !ok {
|
2026-05-25 19:15:07 +08:00
|
|
|
return nil, fmt.Errorf("doc_id not provided")
|
2026-04-07 09:44:51 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-25 19:15:07 +08:00
|
|
|
datasetName, ok := cmd.Params["dataset_name"].(string)
|
2026-04-07 09:44:51 +08:00
|
|
|
if !ok {
|
2026-05-25 19:15:07 +08:00
|
|
|
return nil, fmt.Errorf("dataset_name not provided")
|
2026-04-07 09:44:51 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Look up dataset_id from dataset_name
|
|
|
|
|
datasetID, err := c.getDatasetID(datasetName)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to get dataset ID: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-25 19:15:07 +08:00
|
|
|
jsonBody, ok := cmd.Params["json_body"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("json_body not provided")
|
2026-04-07 09:44:51 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Parse the JSON body
|
|
|
|
|
var payload map[string]interface{}
|
|
|
|
|
if err := json.Unmarshal([]byte(jsonBody), &payload); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("invalid JSON body: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-09 09:52:31 +08:00
|
|
|
// Add IDs to payload
|
|
|
|
|
payload["dataset_id"] = datasetID
|
|
|
|
|
payload["document_id"] = docID
|
|
|
|
|
payload["chunk_id"] = chunkID
|
|
|
|
|
|
2026-06-09 15:22:50 +08:00
|
|
|
resp, err := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].Request("POST", "/chunk/update", "web", nil, payload)
|
2026-04-07 09:44:51 +08:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to update chunk: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != 200 {
|
|
|
|
|
return nil, fmt.Errorf("failed to update chunk: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resJSON, err := resp.JSON()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("invalid JSON response: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
code, ok := resJSON["code"].(float64)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("invalid response format: code is not a number")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var result SimpleResponse
|
|
|
|
|
result.Code = int(code)
|
|
|
|
|
if result.Code == 0 {
|
|
|
|
|
result.Message = fmt.Sprintf("Success to update chunk: %s", chunkID)
|
|
|
|
|
} else {
|
|
|
|
|
result.Message = fmt.Sprintf("Failed to update chunk: %v", resJSON)
|
|
|
|
|
}
|
|
|
|
|
result.Duration = 0
|
|
|
|
|
return &result, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 20:32:06 +08:00
|
|
|
// GetChunk retrieves a chunk by ID
|
2026-06-09 15:22:50 +08:00
|
|
|
func (c *CLI) GetChunk(cmd *Command) (ResponseIf, error) {
|
|
|
|
|
if c.Config.CLIMode != APIMode {
|
2026-05-20 20:32:06 +08:00
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
chunkID, ok := cmd.Params["chunk_id"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("chunk_id not provided")
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-25 19:15:07 +08:00
|
|
|
docID, ok := cmd.Params["doc_id"].(string)
|
2026-05-20 20:32:06 +08:00
|
|
|
if !ok {
|
2026-05-25 19:15:07 +08:00
|
|
|
return nil, fmt.Errorf("doc_id not provided")
|
2026-05-20 20:32:06 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-25 19:15:07 +08:00
|
|
|
datasetID, ok := cmd.Params["dataset_id"].(string)
|
2026-05-20 20:32:06 +08:00
|
|
|
if !ok {
|
2026-05-25 19:15:07 +08:00
|
|
|
return nil, fmt.Errorf("dataset_id not provided")
|
2026-05-20 20:32:06 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-09 15:22:50 +08:00
|
|
|
resp, err := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].Request("GET", fmt.Sprintf("/datasets/%s/documents/%s/chunks/%s", datasetID, docID, chunkID), "web", nil, nil)
|
2026-05-20 20:32:06 +08:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to get chunk: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != 200 {
|
|
|
|
|
return nil, fmt.Errorf("failed to get chunk: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var result ChunkResponse
|
|
|
|
|
if err = json.Unmarshal(resp.Body, &result); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("get chunk failed: invalid JSON (%w)", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if result.Code != 0 {
|
|
|
|
|
return nil, fmt.Errorf("%s", result.Message)
|
|
|
|
|
}
|
|
|
|
|
result.Duration = resp.Duration
|
|
|
|
|
|
|
|
|
|
return &result, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-07 09:44:51 +08:00
|
|
|
// SetMeta sets metadata for a document
|
2026-06-09 15:22:50 +08:00
|
|
|
func (c *CLI) SetMeta(cmd *Command) (ResponseIf, error) {
|
|
|
|
|
if c.Config.CLIMode != APIMode {
|
2026-04-07 09:44:51 +08:00
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
docID, ok := cmd.Params["doc_id"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("doc_id not provided")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
metaJSON, ok := cmd.Params["meta"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("meta not provided")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
payload := map[string]interface{}{
|
|
|
|
|
"doc_id": docID,
|
|
|
|
|
"meta": metaJSON,
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 15:22:50 +08:00
|
|
|
resp, err := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].Request("POST", "/document/set_meta", "web", nil, payload)
|
2026-04-07 09:44:51 +08:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to set metadata: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != 200 {
|
|
|
|
|
return nil, fmt.Errorf("failed to set metadata: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resJSON, err := resp.JSON()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("invalid JSON response: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
code, ok := resJSON["code"].(float64)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("invalid response format: code is not a number")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var result SimpleResponse
|
|
|
|
|
result.Code = int(code)
|
|
|
|
|
if result.Code == 0 {
|
|
|
|
|
result.Message = fmt.Sprintf("Success to set metadata for document: %s", docID)
|
|
|
|
|
} else {
|
|
|
|
|
result.Message = fmt.Sprintf("Failed to set metadata: %v", resJSON)
|
|
|
|
|
}
|
|
|
|
|
result.Duration = 0
|
|
|
|
|
return &result, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-25 19:15:07 +08:00
|
|
|
// DeleteMeta deletes metadata for a document
|
|
|
|
|
// If keys is provided, deletes specific keys; otherwise deletes entire document metadata
|
2026-06-09 15:22:50 +08:00
|
|
|
func (c *CLI) DeleteMeta(cmd *Command) (ResponseIf, error) {
|
|
|
|
|
if c.Config.CLIMode != APIMode {
|
2026-05-25 19:15:07 +08:00
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
docID, ok := cmd.Params["doc_id"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("doc_id not provided")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
payload := map[string]interface{}{
|
|
|
|
|
"doc_id": docID,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If keys provided, include in payload for deleting specific keys
|
|
|
|
|
if keysJSON, ok := cmd.Params["keys"].(string); ok {
|
|
|
|
|
payload["keys"] = keysJSON
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 15:22:50 +08:00
|
|
|
resp, err := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].Request("POST", "/document/delete_meta", "web", nil, payload)
|
2026-05-25 19:15:07 +08:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to delete metadata: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != 200 {
|
|
|
|
|
return nil, fmt.Errorf("failed to delete metadata: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resJSON, err := resp.JSON()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("invalid JSON response: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
code, ok := resJSON["code"].(float64)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("invalid response format: code is not a number")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var result SimpleResponse
|
|
|
|
|
result.Code = int(code)
|
|
|
|
|
if result.Code == 0 {
|
|
|
|
|
result.Message = fmt.Sprintf("Success to delete metadata for document: %s", docID)
|
|
|
|
|
} else {
|
|
|
|
|
result.Message = fmt.Sprintf("Failed to delete metadata: %v", resJSON)
|
|
|
|
|
}
|
|
|
|
|
result.Duration = 0
|
|
|
|
|
return &result, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-07 09:44:51 +08:00
|
|
|
// RmTags removes tags from chunks in a dataset
|
2026-06-09 15:22:50 +08:00
|
|
|
func (c *CLI) RmTags(cmd *Command) (ResponseIf, error) {
|
|
|
|
|
if c.Config.CLIMode != APIMode {
|
2026-04-07 09:44:51 +08:00
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
datasetName, ok := cmd.Params["dataset_name"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("dataset_name not provided")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
kbID, err := c.getDatasetID(datasetName)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
tags, ok := cmd.Params["tags"].([]string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("tags not provided")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
payload := map[string]interface{}{
|
|
|
|
|
"tags": tags,
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 15:22:50 +08:00
|
|
|
resp, err := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].Request("DELETE", "/datasets/"+kbID+"/tags", "web", nil, payload)
|
2026-04-07 09:44:51 +08:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to remove tags: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != 200 {
|
|
|
|
|
return nil, fmt.Errorf("failed to remove tags: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resJSON, err := resp.JSON()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("invalid JSON response: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
code, ok := resJSON["code"].(float64)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("invalid response format: code is not a number")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var result SimpleResponse
|
|
|
|
|
result.Code = int(code)
|
|
|
|
|
if result.Code == 0 {
|
|
|
|
|
result.Message = fmt.Sprintf("Success to remove tags from dataset: %s", kbID)
|
|
|
|
|
} else {
|
|
|
|
|
result.Message = fmt.Sprintf("Failed to remove tags: %v", resJSON)
|
|
|
|
|
}
|
|
|
|
|
result.Duration = 0
|
|
|
|
|
return &result, nil
|
|
|
|
|
}
|
2026-04-09 09:52:31 +08:00
|
|
|
|
|
|
|
|
// RemoveChunks removes chunks from a document
|
2026-06-09 15:22:50 +08:00
|
|
|
func (c *CLI) RemoveChunks(cmd *Command) (ResponseIf, error) {
|
|
|
|
|
if c.Config.CLIMode != APIMode {
|
2026-04-09 09:52:31 +08:00
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-25 19:15:07 +08:00
|
|
|
datasetName, ok := cmd.Params["dataset_name"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("dataset_name not provided")
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-09 09:52:31 +08:00
|
|
|
docID, ok := cmd.Params["doc_id"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("doc_id not provided")
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-25 19:15:07 +08:00
|
|
|
// Look up dataset ID by name
|
|
|
|
|
datasetID, err := c.getDatasetID(datasetName)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("dataset not found: %w", err)
|
2026-04-09 09:52:31 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-25 19:15:07 +08:00
|
|
|
payload := map[string]interface{}{}
|
|
|
|
|
|
2026-04-09 09:52:31 +08:00
|
|
|
// Check if delete_all is set
|
|
|
|
|
if deleteAll, ok := cmd.Params["delete_all"].(bool); ok && deleteAll {
|
|
|
|
|
payload["delete_all"] = true
|
|
|
|
|
} else if chunkIDs, ok := cmd.Params["chunk_ids"].([]string); ok {
|
|
|
|
|
payload["chunk_ids"] = chunkIDs
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-09 15:22:50 +08:00
|
|
|
resp, err := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].Request("DELETE", "/datasets/"+datasetID+"/documents/"+docID+"/chunks", "web", nil, payload)
|
2026-04-09 09:52:31 +08:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to remove chunks: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != 200 {
|
|
|
|
|
return nil, fmt.Errorf("failed to remove chunks: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resJSON, err := resp.JSON()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("invalid JSON response: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
code, ok := resJSON["code"].(float64)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("invalid response format: code is not a number")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var result SimpleResponse
|
|
|
|
|
result.Code = int(code)
|
|
|
|
|
if result.Code == 0 {
|
|
|
|
|
deletedCount := int64(0)
|
|
|
|
|
switch data := resJSON["data"].(type) {
|
|
|
|
|
case float64:
|
|
|
|
|
deletedCount = int64(data)
|
|
|
|
|
case map[string]interface{}:
|
|
|
|
|
if count, ok := data["deleted_count"].(float64); ok {
|
|
|
|
|
deletedCount = int64(count)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
result.Message = fmt.Sprintf("Success to remove chunks from document %s: %d chunks deleted", docID, deletedCount)
|
|
|
|
|
} else {
|
|
|
|
|
result.Message = fmt.Sprintf("Failed to remove chunks: %v", resJSON)
|
|
|
|
|
}
|
|
|
|
|
result.Duration = 0
|
|
|
|
|
return &result, nil
|
|
|
|
|
}
|
2026-05-08 15:54:27 +08:00
|
|
|
|
2026-06-09 15:22:50 +08:00
|
|
|
func (c *CLI) ParseDocumentsUserCommand(cmd *Command) (ResponseIf, error) {
|
2026-06-24 18:48:09 +08:00
|
|
|
if c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].APIKey == nil && c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].LoginToken == nil {
|
|
|
|
|
return nil, fmt.Errorf("API key not set. Please login first")
|
2026-05-25 14:00:08 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-09 15:22:50 +08:00
|
|
|
if c.Config.CLIMode != APIMode {
|
2026-05-25 14:00:08 +08:00
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
datasetID, ok := cmd.Params["dataset_id"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("dataset_id not provided")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
documents, ok := cmd.Params["documents"].([]string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("documents not provided")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
url := fmt.Sprintf("/datasets/%s/documents/parse", datasetID)
|
|
|
|
|
|
|
|
|
|
payload := map[string]interface{}{
|
|
|
|
|
"documents": documents,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Normal mode
|
2026-06-09 15:22:50 +08:00
|
|
|
resp, err := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].Request("POST", url, "web", nil, payload)
|
2026-05-25 14:00:08 +08:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to list documents: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != 200 {
|
|
|
|
|
return nil, fmt.Errorf("failed to list documents: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-29 11:13:14 +08:00
|
|
|
return HandleSimpleResponse(resp, "list documents")
|
2026-05-25 14:00:08 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-11 13:33:26 +08:00
|
|
|
func (c *CLI) UserParseLocalFile(cmd *Command) (ResponseIf, error) {
|
|
|
|
|
if c.Config.CLIMode != APIMode {
|
|
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
filename, ok := cmd.Params["filename"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("filename not provided")
|
|
|
|
|
}
|
|
|
|
|
visionModel, ok := cmd.Params["vision_model"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
visionModel = ""
|
|
|
|
|
}
|
|
|
|
|
chatModel, ok := cmd.Params["chat_model"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
chatModel = ""
|
|
|
|
|
}
|
|
|
|
|
asrModel, ok := cmd.Params["asr_model"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
asrModel = ""
|
|
|
|
|
}
|
|
|
|
|
ocrModel, ok := cmd.Params["ocr_model"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
ocrModel = ""
|
|
|
|
|
}
|
|
|
|
|
embeddingModel, ok := cmd.Params["embedding_model"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
embeddingModel = ""
|
|
|
|
|
}
|
|
|
|
|
docParseModel, ok := cmd.Params["doc_parse_model"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
docParseModel = ""
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-12 20:28:15 +08:00
|
|
|
fileType := utility.GetFileType(filename)
|
2026-06-13 19:40:43 +08:00
|
|
|
config := map[string]string{
|
|
|
|
|
"lib_type": "office_oxide",
|
|
|
|
|
}
|
|
|
|
|
fileParser, err := parser.GetParser(fileType, config)
|
2026-06-12 20:28:15 +08:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fileContent, err := os.ReadFile(filename)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to read dsl file: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err = fileParser.Parse(filename, fileContent); err != nil {
|
|
|
|
|
return nil, formatRequestError("parse local file", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 13:33:26 +08:00
|
|
|
var result SimpleResponse
|
|
|
|
|
result.Code = 0
|
fix(codeql): close remaining 44 CodeQL alerts post-merge (#16408)
## Summary
After #16407 merged, 44 of the original 93 CodeQL alerts were still open
on the default branch. This PR closes the remaining ones by:
1. **Moving 32 existing `// codeql[...]` directives** so they sit on the
line **immediately before** the suppressed statement. The original
multi-line suppression blocks had the directive as the first line, with
the rationale on subsequent lines. After line shifts (refactors, linter
reformat), the directive ended up several lines above the alert location
— CodeQL only recognizes the suppression when it appears on the line
directly above. (32 alerts across 27 files.)
2. **Adding 9 new `// codeql[...]` suppressions** for alerts that had no
suppression in the preceding lines at all — mostly real-fixes that
CodeQL conservatively still flags (filepath.Base, bounded slice sizes,
model-identifier strings, the MD5-legacy-migration lookup in
`conversation_service.py`).
## Files changed
- `api/db/services/conversation_service.py` — add
`py/weak-sensitive-data-hashing` suppression (MD5 for backward-compat
legacy row lookup; not used for auth)
- `api/db/services/llm_service.py` — 3×
`py/clear-text-logging-sensitive-data` suppressions on the lines that
log `llm_name` in warnings/info
- `common/misc_utils.py` — 2× `py/clear-text-logging-sensitive-data`
suppressions on the redacted `current_url` log sites
- `internal/agent/component/invoke.go` — moved existing
`go/request-forgery` directive
- `internal/agent/sandbox/ssh.go` — moved existing
`go/command-injection` directive
- `internal/agent/tool/retrieval_service.go` — added
`go/uncontrolled-allocation-size` suppression (`topN` is bounded to 1024
above)
- `internal/cli/common_command.go` — moved 2×
`go/disabled-certificate-check` directives
- `internal/cli/user_command.go` — added `go/clear-text-logging`
suppression (filepath.Base already strips user-identifying path)
- `internal/dao/pipeline_operation_log.go` — moved 2× `go/sql-injection`
directives
- `internal/dao/user_canvas.go` — added `go/sql-injection` suppression
in `GetList` (the new `userCanvasOrderClause` call path)
- `internal/engine/infinity/chunk.go` — moved existing
`go/unsafe-quoting` directive
- `internal/entity/models/*` — moved `go/path-injection` directives (15
files)
- `internal/handler/oauth_login.go` — moved existing
`go/cookie-httponly-not-set` directive
- `internal/handler/tenant.go` — moved existing `go/path-injection`
directive
- `internal/service/deep_researcher.go` — moved existing
`go/unsafe-quoting` directive
- `internal/service/dataset.go` — added
`go/uncontrolled-allocation-size` suppression (`n` bounded to 1024
above)
- `internal/service/file.go` — moved existing `go/request-forgery`
directive
- `internal/service/langfuse.go` — moved 2× `go/request-forgery`
directives
- `internal/utility/mcp_client.go` — moved 3× `go/request-forgery`
directives
- `internal/utility/smtp.go` — moved existing `go/email-injection`
directive
- `rag/prompts/generator.py` — added
`py/clear-text-logging-sensitive-data` suppression
- `web/.../use-provider-fields.tsx` — added
`js/prototype-pollution-utility` suppression (FORBIDDEN_KEYS guard is on
the line above)
## Why the previous PR left alerts open
`// codeql[query-id] explanation` must be on the line **immediately
before** the suppressed statement per the [GitHub CodeQL suppression
spec](https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/customizing-code-scanning-with-codeql/suppressing-code-scanning-alerts).
The original suppression blocks were 4-5 lines, with the directive as
the **first** line. After linter reformat / line shifts, the directive
ended up too far above the actual alert line to be recognized. The fix
is to put the directive on the line directly above the suppressed
statement, with the rationale above it.
## Test plan
- All 9 modified Python files `ast.parse` clean
- All 4 modified Go files `gofmt` clean
- 36/44 expected alert suppressions in place
- 8 remaining CodeQL alerts are the originals (#3485851828, #3485851831,
#3485869759, #3485869766, #3485869768, #3485869771, #3485885962,
#3485895527) which were resolved by the corresponding commit comments;
these should close on the next scan when the suppression comments match
the alert lines.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-27 20:49:06 +08:00
|
|
|
// codeql[go/clear-text-logging] False positive: filename is
|
|
|
|
|
// reduced to filepath.Base(...) so the full path (which can
|
|
|
|
|
// contain user-identifying directory components) never reaches
|
|
|
|
|
// the log. The format is operator-facing status output, not a
|
|
|
|
|
// server log.
|
fix(security): address 93 CodeQL code-scanning alerts across 61 files (#16407)
## Summary
Resolves all 93 open alerts at
https://github.com/infiniflow/ragflow/security/code-scanning by rule:
| Rule | Count | Treatment |
|------|-------|-----------|
| py/clear-text-logging-sensitive-data | 23 | Real fix — log scrubbing |
| go/path-injection | 15 | Real fix where possible, suppression with
rationale |
| go/request-forgery | 8 | Suppression with rationale
(operator-controlled URLs) |
| go/clear-text-logging | 10 | Real fix — log scrubbing |
| go/unsafe-quoting | 5 | Real fix — escape or refactor |
| go/sql-injection | 3 | Real fix — orderby whitelist + CodeQL comment |
| go/uncontrolled-allocation-size | 2 | Real fix — cap to 1024 |
| go/incorrect-integer-conversion | 3 | Real fix — ParseInt + range
check |
| go/insecure-hostkeycallback | 1 | Real fix — known_hosts file |
| go/disabled-certificate-check | 2 | Suppression with rationale |
| go/command-injection | 1 | Suppression (sanitized via shq()) |
| go/email-injection | 1 | Suppression with rationale |
| go/cookie-httponly-not-set | 1 | Suppression (SPA bootstrap) |
| js/stack-trace-exposure | 1 | Real fix — generic client message |
| js/prototype-pollution-utility | 1 | Real fix — reject
__proto__/constructor/prototype |
| py/weak-sensitive-data-hashing | 1 | Real fix — MD5 → SHA-256 |
| py/incomplete-url-substring-sanitization | 3 | Real fix —
urlparse(hostname) |
| py/paramiko-missing-host-key-validation | 1 | Real fix —
load_system_host_keys + RejectPolicy |
| cpp/integer-multiplication-cast-to-long | 2 | Real fix — cast to
size_t |
## Real fixes (with measurable security improvement)
**SSH host key verification (Go + Python)**
Replace `InsecureIgnoreHostKey()` / `paramiko.AutoAddPolicy()` with
proper host key verification against a known_hosts file (configurable
via `SSH_KNOWN_HOSTS` env / `known_hosts` config field; fail-closed when
unset). Loads `~/.ssh/known_hosts` first via `load_system_host_keys()`
so existing setups keep working.
**SQL injection in `user_canvas`**
Add `userCanvasOrderableColumns` whitelist + `userCanvasOrderClause`
helper. Both `GetList()` and `ListByTenantIDs()` now route the
user-supplied `orderby` query param through the helper, defaulting to
`create_time` on miss.
**SQL injection in `pipeline_operation_log`**
Existing whitelist documented via CodeQL comment.
**Real SQL injection in `infinity/chunk.go:931`**
Escape `'` → `''` on user-controlled `questionText` before splicing into
`filter_fulltext(...)` SQL filter.
**Real SQL injection in `elasticsearch/sql.go:75`**
Defense-in-depth escape on tokenizer output before splicing into
`MATCH(...)`.
**Python code injection in `result_protocol.go`**
Replace raw JSON literal embedding into Python/JS expressions with
base64 + `json.loads` / `JSON.parse(Buffer.from(...,
'base64').toString('utf8'))`. Eliminates both the unsafe-quoting sink
and the brittleness of mixing JSON true/false/null with Python syntax.
**URL substring check bypass in `embedding_model.py`**
Replace `if "dashscope-intl.aliyuncs.com" in u` with
`urlparse(u).hostname == "dashscope-intl.aliyuncs.com"` so a base_url
like `https://attacker.example/?u=dashscope-intl.aliyuncs.com` cannot
bypass the routing.
**Prototype pollution in `setNestedValue` (TS)**
Reject `__proto__`/`constructor`/`prototype` keys before any assignment.
**Integer overflow**
- scrypt params via `ParseInt` + non-positive check
(`internal/common/password.go`)
- `topN` and `n` caps to 1024 (retrieval_service.go, dataset.go)
- `nalloc*statesize` cast to `size_t` (cpp/re2/onepass.cc)
**Cookie httponly**
Set explicitly with rationale: this is the OAuth bootstrap cookie
intentionally read by the SPA.
**Stack trace exposure**
Replace `error.message` in HTTP 500 response with generic `"internal
error"`; full error still logged server-side via `console.error`.
**Weak hashing**
MD5 → SHA-256 for deterministic `conv_id` derivation
(`conversation_service.py`).
**Log scrubbing**
Remove or redact user-controlled / sensitive content from clear-text
logs across 8 ingestion parsers, `llm_service.py` ×11,
`tenant_llm_service.py` ×7, `misc_utils.py` ×4, `redis_conn.py` ×10,
`conftest.py` ×4, `init_data.py`, `dataset_api_service.py`,
`generator.py`, `mysql_migration.py`, `cli.go`, `user_command.go`,
`pdf_parser.go`. Most patterns converted to parameterized logging
(`logging.info("...: %d", n)`) or static messages.
## CodeQL suppressions (each with rationale)
For alerts where the data flow is genuinely safe but CodeQL can't see
the context — operator-controlled URLs, sanitized inputs, etc. — I added
`// codeql[go/<rule>] <rationale>` annotations rather than dismissing
them, so future readers can audit the rationale inline:
- `internal/agent/component/invoke.go:135` — Invoke is a generic canvas
HTTP client
- `internal/service/langfuse.go` ×2 — host is per-tenant operator config
- `internal/service/file.go:1184` — already SSRF-guarded by
`assertURLSafe`
- `internal/utility/mcp_client.go` ×3 — already `AssertURLSafe` +
IP-pinned
- `internal/entity/models/bedrock.go` — sigv4-signed request, URL can't
be tampered
- `internal/service/deep_researcher.go:269` — `callback` is SSE display
string, not SQL
- `internal/engine/infinity/chunk.go:346` — UUIDs can't contain `'` (RFC
4122)
- `internal/cli/common_command.go` ×2 — CLI trusts operator-configured
URL
- `internal/utility/smtp.go:194` — msg is server-built, not user form
input
- `internal/entity/models/*` ×14 (path-injection) — audio file paths are
caller-supplied
## Test plan
- ✅ All 13 modified Go packages build cleanly
- ✅ 663 tests pass across `internal/agent/sandbox`, `internal/common`,
`internal/agent/component`, `internal/engine/infinity`, `internal/dao`
- ✅ All 11 modified Python files parse via `ast.parse`
- ✅ TypeScript `tsc --noEmit` clean on the modified
`use-provider-fields.tsx`
- ✅ `node --check` clean on the modified JS file
🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-27 19:48:29 +08:00
|
|
|
result.Message = fmt.Sprintf("Success to parse local file %q, vision: %v, chat: %v, asr: %v, ocr: %v, embedding: %v, doc_parse: %v", filepath.Base(filename), visionModel, chatModel, asrModel, ocrModel, embeddingModel, docParseModel)
|
2026-06-11 13:33:26 +08:00
|
|
|
fmt.Println(result.Message)
|
|
|
|
|
return &result, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-08 15:54:27 +08:00
|
|
|
// formatRequestError Uniformly handle and format network errors in HTTP requests
|
|
|
|
|
func formatRequestError(action string, err error) error {
|
|
|
|
|
if err == nil {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var netErr net.Error
|
|
|
|
|
|
|
|
|
|
switch {
|
|
|
|
|
case errors.Is(err, io.EOF), errors.Is(err, io.ErrUnexpectedEOF):
|
|
|
|
|
return fmt.Errorf("%s failed - connection closed (EOF): upstream overloaded or proxy timeout: %w", action, err)
|
|
|
|
|
case errors.As(err, &netErr) && netErr.Timeout():
|
|
|
|
|
return fmt.Errorf("%s failed - request timeout: server took too long to respond: %w", action, err)
|
|
|
|
|
default:
|
|
|
|
|
return fmt.Errorf("%s failed: %w", action, err)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-11 13:33:26 +08:00
|
|
|
|
2026-06-12 14:56:44 +08:00
|
|
|
func (c *CLI) ListUserIngestionTasks(cmd *Command) (ResponseIf, error) {
|
2026-06-24 18:48:09 +08:00
|
|
|
if c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].APIKey == nil && c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].LoginToken == nil {
|
|
|
|
|
return nil, fmt.Errorf("API key not set. Please login first")
|
2026-06-12 14:56:44 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if c.Config.CLIMode != APIMode {
|
|
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
datasetID, ok := cmd.Params["dataset_id"].(*string)
|
|
|
|
|
if !ok {
|
|
|
|
|
datasetID = nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
payload := map[string]interface{}{
|
|
|
|
|
"dataset_id": datasetID,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resp, err := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].Request("GET", "/datasets/ingestion/tasks", "web", nil, payload)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to list ingestion tasks: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != 200 {
|
|
|
|
|
return nil, fmt.Errorf("failed to list ingestion tasks:: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var result CommonResponse
|
|
|
|
|
if err = json.Unmarshal(resp.Body, &result); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("list ingestion tasks: failed: invalid JSON (%w)", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if result.Code != 0 {
|
|
|
|
|
return nil, fmt.Errorf("%s", result.Message)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
result.Duration = resp.Duration
|
|
|
|
|
return &result, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-25 20:36:50 +08:00
|
|
|
// APIShowLogLevelCommand sets the log level for the system.
|
|
|
|
|
func (c *CLI) APIShowLogLevelCommand(cmd *Command) (ResponseIf, error) {
|
|
|
|
|
if c.Config.CLIMode != APIMode {
|
|
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resp, err := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].Request("GET", "/system/config/log", "web", nil, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to get log level config: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != 200 {
|
|
|
|
|
return nil, fmt.Errorf("failed to get log level config: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var result CommonDataResponse
|
|
|
|
|
if err = json.Unmarshal(resp.Body, &result); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("get log level config failed: invalid JSON (%w)", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if result.Code != 0 {
|
|
|
|
|
return nil, fmt.Errorf("%s", result.Message)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
result.Duration = resp.Duration
|
|
|
|
|
return &result, nil
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// APIListEnvironmentsCommand lists all system environments (api mode only).
|
|
|
|
|
func (c *CLI) APIListEnvironmentsCommand(cmd *Command) (ResponseIf, error) {
|
|
|
|
|
if c.Config.CLIMode != APIMode {
|
|
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].APIKey == nil && c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].LoginToken == nil {
|
|
|
|
|
return nil, fmt.Errorf("API key not set. Please login first")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resp, err := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].Request("GET", "/system/environments", "web", nil, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to list environments: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != 200 {
|
|
|
|
|
return nil, fmt.Errorf("failed to list environments: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var result CommonResponse
|
|
|
|
|
if err = json.Unmarshal(resp.Body, &result); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("list environments failed: invalid JSON (%w)", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if result.Code != 0 {
|
|
|
|
|
return nil, fmt.Errorf("%s", result.Message)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
result.Duration = resp.Duration
|
|
|
|
|
return &result, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// APIListVariablesCommand lists all system variables (api mode only).
|
|
|
|
|
func (c *CLI) APIListVariablesCommand(cmd *Command) (ResponseIf, error) {
|
|
|
|
|
if c.Config.CLIMode != APIMode {
|
|
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].APIKey == nil && c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].LoginToken == nil {
|
|
|
|
|
return nil, fmt.Errorf("API key not set. Please login first")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resp, err := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].Request("GET", "/system/variables", "web", nil, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to list variables: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != 200 {
|
|
|
|
|
return nil, fmt.Errorf("failed to list variables: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var result CommonResponse
|
|
|
|
|
if err = json.Unmarshal(resp.Body, &result); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("list environments failed: invalid JSON (%w)", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if result.Code != 0 {
|
|
|
|
|
return nil, fmt.Errorf("%s", result.Message)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
result.Duration = resp.Duration
|
|
|
|
|
return &result, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-12 14:56:44 +08:00
|
|
|
func (c *CLI) UserStartIngestionCommand(cmd *Command) (ResponseIf, error) {
|
2026-06-24 18:48:09 +08:00
|
|
|
if c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].APIKey == nil && c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].LoginToken == nil {
|
|
|
|
|
return nil, fmt.Errorf("API key not set. Please login first")
|
2026-06-12 14:56:44 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if c.Config.CLIMode != APIMode {
|
|
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
documentID, ok := cmd.Params["document_id"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("document_id not provided")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
datasetID, ok := cmd.Params["dataset_id"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("dataset_id not provided")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
payload := map[string]interface{}{
|
|
|
|
|
"documents": []string{documentID},
|
|
|
|
|
"dataset_id": datasetID,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
url := fmt.Sprintf("/datasets/%s/documents/parse", datasetID)
|
|
|
|
|
|
|
|
|
|
resp, err := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].Request("POST", url, "web", nil, payload)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to ingest file: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != 200 {
|
|
|
|
|
return nil, fmt.Errorf("failed to ingest file: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var result CommonResponse
|
|
|
|
|
if err = json.Unmarshal(resp.Body, &result); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("ingest file failed: invalid JSON (%w)", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if result.Code != 0 {
|
|
|
|
|
return nil, fmt.Errorf("%s", result.Message)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
result.Duration = resp.Duration
|
|
|
|
|
return &result, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *CLI) UserStopIngestionCommand(cmd *Command) (ResponseIf, error) {
|
2026-06-24 18:48:09 +08:00
|
|
|
if c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].APIKey == nil && c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].LoginToken == nil {
|
|
|
|
|
return nil, fmt.Errorf("API key not set. Please login first")
|
2026-06-12 14:56:44 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if c.Config.CLIMode != APIMode {
|
|
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
tasks, ok := cmd.Params["tasks"].([]string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("uri not provided")
|
|
|
|
|
}
|
|
|
|
|
payload := map[string]interface{}{
|
|
|
|
|
"tasks": tasks,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resp, err := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].Request("PUT", "/datasets/ingestion/tasks", "web", nil, payload)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to ingest file: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != 200 {
|
|
|
|
|
return nil, fmt.Errorf("failed to ingest file: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var result CommonResponse
|
|
|
|
|
if err = json.Unmarshal(resp.Body, &result); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("ingest file failed: invalid JSON (%w)", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if result.Code != 0 {
|
|
|
|
|
return nil, fmt.Errorf("%s", result.Message)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
result.Duration = resp.Duration
|
|
|
|
|
return &result, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *CLI) UserRemoveTaskCommand(cmd *Command) (ResponseIf, error) {
|
2026-06-24 18:48:09 +08:00
|
|
|
if c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].APIKey == nil && c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].LoginToken == nil {
|
|
|
|
|
return nil, fmt.Errorf("API key not set. Please login first")
|
2026-06-12 14:56:44 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if c.Config.CLIMode != APIMode {
|
|
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
tasks, ok := cmd.Params["tasks"].([]string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("tasks not provided")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
payload := map[string]interface{}{
|
|
|
|
|
"tasks": tasks,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resp, err := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer].Request("DELETE", "/datasets/ingestion/tasks", "web", nil, payload)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to remove tasks: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if resp.StatusCode != 200 {
|
|
|
|
|
return nil, fmt.Errorf("failed to remove tasks: HTTP %d, body: %s", resp.StatusCode, string(resp.Body))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var result CommonResponse
|
|
|
|
|
if err = json.Unmarshal(resp.Body, &result); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("remove tasks failed: invalid JSON (%w)", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if result.Code != 0 {
|
|
|
|
|
return nil, fmt.Errorf("%s", result.Message)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
result.Duration = resp.Duration
|
|
|
|
|
return &result, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 13:33:26 +08:00
|
|
|
func (c *CLI) ChunkCommand(cmd *Command) (ResponseIf, error) {
|
|
|
|
|
if c.Config.CLIMode != APIMode {
|
|
|
|
|
return nil, fmt.Errorf("this command is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-12 17:58:36 +08:00
|
|
|
var result ExplainResponse
|
|
|
|
|
start := time.Now()
|
|
|
|
|
|
2026-06-11 13:33:26 +08:00
|
|
|
filename, ok := cmd.Params["filename"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("filename not provided")
|
|
|
|
|
}
|
2026-06-12 17:58:36 +08:00
|
|
|
dslFilename, ok := cmd.Params["dsl"].(string)
|
2026-06-11 13:33:26 +08:00
|
|
|
if !ok {
|
|
|
|
|
return nil, fmt.Errorf("dsl not provided")
|
|
|
|
|
}
|
2026-06-12 17:58:36 +08:00
|
|
|
dsl, err := os.ReadFile(dslFilename)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to read dsl file: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 13:33:26 +08:00
|
|
|
explain, ok := cmd.Params["explain"].(bool)
|
|
|
|
|
if !ok {
|
|
|
|
|
explain = false
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-12 17:58:36 +08:00
|
|
|
engine := ingestion.NewChunkEngine()
|
|
|
|
|
plan, err := engine.Compile(string(dsl))
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("compile failed: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 13:33:26 +08:00
|
|
|
if explain {
|
2026-06-12 17:58:36 +08:00
|
|
|
|
|
|
|
|
explanation, err := engine.Explain(plan)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("explain error: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
result.Message = explanation
|
2026-06-11 13:33:26 +08:00
|
|
|
} else {
|
2026-06-12 17:58:36 +08:00
|
|
|
fileToChunking, err := os.ReadFile(filename)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to read file: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
chunkContext, err := engine.Execute(plan, string(fileToChunking))
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("chunking error: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, resultChunk := range chunkContext.ResultChunks {
|
|
|
|
|
fmt.Printf("Chunk index: %d\n", resultChunk.Index)
|
|
|
|
|
fmt.Printf("Chunk size: %d\n", resultChunk.Size)
|
|
|
|
|
fmt.Printf("Chunk content: \n%s\n", resultChunk.Content)
|
|
|
|
|
}
|
2026-06-11 13:33:26 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-12 17:58:36 +08:00
|
|
|
result.Duration = time.Since(start).Seconds()
|
2026-06-11 13:33:26 +08:00
|
|
|
result.Code = 0
|
|
|
|
|
result.Message = fmt.Sprintf("Success to chunk %s", filename)
|
|
|
|
|
return &result, nil
|
|
|
|
|
}
|
2026-06-18 18:07:27 +08:00
|
|
|
|
|
|
|
|
// OpenaiChat dispatches the parsed OPENAI_CHAT command to either a
|
|
|
|
|
// non-streaming oneshot call or a streaming SSE call, depending on the
|
|
|
|
|
// `stream` option.
|
|
|
|
|
func (c *CLI) OpenaiChat(cmd *Command) (ResponseIf, error) {
|
|
|
|
|
if c.Config.CLIMode != APIMode {
|
|
|
|
|
return nil, fmt.Errorf("OPENAI_CHAT is only allowed in USER mode")
|
|
|
|
|
}
|
|
|
|
|
httpClient := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer]
|
2026-06-24 18:48:09 +08:00
|
|
|
if httpClient.APIKey == nil && httpClient.LoginToken == nil {
|
|
|
|
|
return nil, fmt.Errorf("API key not set. Please login first")
|
2026-06-18 18:07:27 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
body, err := buildOpenaiChatRequestBody(cmd)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
chatID, _ := cmd.Params["chat_id"].(string)
|
|
|
|
|
url := fmt.Sprintf("/openai/%s/chat/completions", chatID)
|
|
|
|
|
|
|
|
|
|
stream, _ := cmd.Params["stream"].(bool)
|
|
|
|
|
if stream {
|
|
|
|
|
return c.streamOpenaiChat(url, body)
|
|
|
|
|
}
|
|
|
|
|
return c.oneshotOpenaiChat(url, body)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// allowedExtraBodyKeys enumerates every top-level key the server
|
|
|
|
|
// accepts under `extra_body`. Anything else is rejected at CLI
|
|
|
|
|
// build time so the user gets a clear error before the request
|
|
|
|
|
// goes over the wire.
|
|
|
|
|
var allowedExtraBodyKeys = map[string]struct{}{
|
|
|
|
|
"reference": {},
|
|
|
|
|
"reference_metadata": {},
|
|
|
|
|
"metadata_condition": {},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// validateExtraBody checks the shape of an extra_body payload
|
|
|
|
|
// supplied by the user. It rejects:
|
|
|
|
|
//
|
|
|
|
|
// - Unknown top-level keys (typos and unsupported fields).
|
|
|
|
|
// - reference_metadata that's not an object, or whose
|
|
|
|
|
// sub-fields have the wrong type.
|
|
|
|
|
// - metadata_condition that's not an object, or whose
|
|
|
|
|
// conditions are missing required fields.
|
|
|
|
|
//
|
|
|
|
|
// The error message names the offending path so the user can
|
|
|
|
|
// fix the JSON literal in their command without having to read
|
|
|
|
|
// the server source.
|
|
|
|
|
func validateExtraBody(eb map[string]interface{}) error {
|
|
|
|
|
for k := range eb {
|
|
|
|
|
if _, ok := allowedExtraBodyKeys[k]; !ok {
|
|
|
|
|
return fmt.Errorf("OPENAI_CHAT extra_body: unknown field %q (valid: reference, reference_metadata, metadata_condition)", k)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// reference_metadata: { include?: bool, fields?: string[] }
|
|
|
|
|
if v, present := eb["reference_metadata"]; present {
|
|
|
|
|
rm, ok := v.(map[string]interface{})
|
|
|
|
|
if !ok {
|
|
|
|
|
return fmt.Errorf("OPENAI_CHAT extra_body.reference_metadata must be an object, got %T", v)
|
|
|
|
|
}
|
|
|
|
|
if inc, ok := rm["include"]; ok {
|
|
|
|
|
if _, ok := inc.(bool); !ok {
|
|
|
|
|
return fmt.Errorf("OPENAI_CHAT extra_body.reference_metadata.include must be a boolean, got %T", inc)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if fields, ok := rm["fields"]; ok {
|
|
|
|
|
arr, ok := fields.([]interface{})
|
|
|
|
|
if !ok {
|
|
|
|
|
return fmt.Errorf("OPENAI_CHAT extra_body.reference_metadata.fields must be an array, got %T", fields)
|
|
|
|
|
}
|
|
|
|
|
for i, item := range arr {
|
|
|
|
|
if _, ok := item.(string); !ok {
|
|
|
|
|
return fmt.Errorf("OPENAI_CHAT extra_body.reference_metadata.fields[%d] must be a string, got %T", i, item)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// metadata_condition: { logic?: "and"|"or", conditions?: [{key, operator, value}, ...] }
|
|
|
|
|
if v, present := eb["metadata_condition"]; present {
|
|
|
|
|
mc, ok := v.(map[string]interface{})
|
|
|
|
|
if !ok {
|
|
|
|
|
return fmt.Errorf("OPENAI_CHAT extra_body.metadata_condition must be an object, got %T", v)
|
|
|
|
|
}
|
|
|
|
|
if logic, ok := mc["logic"]; ok {
|
|
|
|
|
s, ok := logic.(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return fmt.Errorf("OPENAI_CHAT extra_body.metadata_condition.logic must be a string, got %T", logic)
|
|
|
|
|
}
|
|
|
|
|
if s != "and" && s != "or" {
|
|
|
|
|
return fmt.Errorf("OPENAI_CHAT extra_body.metadata_condition.logic must be \"and\" or \"or\", got %q", s)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if conds, ok := mc["conditions"]; ok {
|
|
|
|
|
arr, ok := conds.([]interface{})
|
|
|
|
|
if !ok {
|
|
|
|
|
return fmt.Errorf("OPENAI_CHAT extra_body.metadata_condition.conditions must be an array, got %T", conds)
|
|
|
|
|
}
|
|
|
|
|
for i, item := range arr {
|
|
|
|
|
cond, ok := item.(map[string]interface{})
|
|
|
|
|
if !ok {
|
|
|
|
|
return fmt.Errorf("OPENAI_CHAT extra_body.metadata_condition.conditions[%d] must be an object, got %T", i, item)
|
|
|
|
|
}
|
|
|
|
|
if _, ok := cond["key"]; !ok {
|
|
|
|
|
return fmt.Errorf("OPENAI_CHAT extra_body.metadata_condition.conditions[%d] missing required field 'key'", i)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// buildOpenaiChatRequestBody assembles the JSON payload that
|
|
|
|
|
// /api/v1/openai/<chat_id>/chat/completions expects
|
|
|
|
|
//
|
|
|
|
|
// RAGFlow-specific knobs (e.g. `reference`, `reference_metadata`,
|
|
|
|
|
// `metadata_condition`) flow in via the user-supplied `extra_body`
|
|
|
|
|
// JSON literal, which is validated against the `allowedExtraBodyKeys`
|
|
|
|
|
// allowlist above before the request goes out. `stop` and `user` are
|
|
|
|
|
// not first-class CLI options — the Python server does not inspect
|
|
|
|
|
// them, and the Go server has dropped them from its request struct;
|
|
|
|
|
// the parser rejects them as "unknown option" so there is exactly
|
|
|
|
|
// one place to set them.
|
|
|
|
|
//
|
|
|
|
|
// The `messages` array is built from three optional sources, in
|
|
|
|
|
// this order:
|
|
|
|
|
// 1. `system` — single system message (if supplied)
|
|
|
|
|
// 2. `history` — prior turns encoded as
|
|
|
|
|
// "user:...,assistant:..." (if supplied)
|
|
|
|
|
// 3. positional <msg> — always the trailing user turn
|
|
|
|
|
func buildOpenaiChatRequestBody(cmd *Command) (map[string]interface{}, error) {
|
|
|
|
|
msg, _ := cmd.Params["message"].(string)
|
|
|
|
|
model, _ := cmd.Params["model"].(string)
|
|
|
|
|
temp, _ := cmd.Params["temperature"].(float64)
|
|
|
|
|
maxTokens, _ := cmd.Params["max_tokens"].(int)
|
|
|
|
|
stream, _ := cmd.Params["stream"].(bool)
|
|
|
|
|
|
|
|
|
|
messages := make([]map[string]interface{}, 0, 4)
|
|
|
|
|
if v, ok := cmd.Params["system"].(string); ok && v != "" {
|
|
|
|
|
messages = append(messages, map[string]interface{}{"role": "system", "content": v})
|
|
|
|
|
}
|
|
|
|
|
if v, ok := cmd.Params["history_raw"].(string); ok && v != "" {
|
|
|
|
|
delimiter, _ := cmd.Params["history_delimiter"].(string)
|
|
|
|
|
turns, err := parseHistory(v, delimiter)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("OPENAI_CHAT history: %w", err)
|
|
|
|
|
}
|
|
|
|
|
for _, t := range turns {
|
|
|
|
|
messages = append(messages, map[string]interface{}{
|
|
|
|
|
"role": t["role"],
|
|
|
|
|
"content": t["content"],
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
messages = append(messages, map[string]interface{}{"role": "user", "content": msg})
|
|
|
|
|
|
|
|
|
|
body := map[string]interface{}{
|
|
|
|
|
"model": model,
|
|
|
|
|
"messages": messages,
|
|
|
|
|
"stream": stream,
|
|
|
|
|
}
|
|
|
|
|
// Only emit generation params when the user actually set them
|
|
|
|
|
// (zero is the parser-default for "unset" and matches Python's
|
|
|
|
|
// behavior of dropping the field).
|
|
|
|
|
if temp != 0.0 {
|
|
|
|
|
body["temperature"] = temp
|
|
|
|
|
}
|
|
|
|
|
if maxTokens != 0 {
|
|
|
|
|
body["max_tokens"] = maxTokens
|
|
|
|
|
}
|
|
|
|
|
if v, ok := cmd.Params["top_p"].(float64); ok && v != 0.0 {
|
|
|
|
|
body["top_p"] = v
|
|
|
|
|
}
|
|
|
|
|
if v, ok := cmd.Params["frequency_penalty"].(float64); ok && v != 0.0 {
|
|
|
|
|
body["frequency_penalty"] = v
|
|
|
|
|
}
|
|
|
|
|
if v, ok := cmd.Params["presence_penalty"].(float64); ok && v != 0.0 {
|
|
|
|
|
body["presence_penalty"] = v
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var extraBody map[string]interface{}
|
|
|
|
|
if v, ok := cmd.Params["extra_body"].(string); ok && v != "" {
|
|
|
|
|
if err := json.Unmarshal([]byte(v), &extraBody); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("OPENAI_CHAT extra_body: invalid JSON: %w", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Validate the user's extra_body against the server's accepted
|
|
|
|
|
// schema before the request goes over the wire.
|
|
|
|
|
if err := validateExtraBody(extraBody); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if len(extraBody) > 0 {
|
|
|
|
|
body["extra_body"] = extraBody
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return body, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// oneshotOpenaiChat performs a non-streaming POST and returns an
|
|
|
|
|
// OpenAIChatResponse parsed from the JSON envelope. It calls the
|
|
|
|
|
// same HTTPClient.Request used by every other CLI command.
|
|
|
|
|
func (c *CLI) oneshotOpenaiChat(url string, body map[string]interface{}) (ResponseIf, error) {
|
|
|
|
|
httpClient := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer]
|
|
|
|
|
resp, err := httpClient.Request("POST", url, "web", nil, body)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("openai_chat request: %w", err)
|
|
|
|
|
}
|
|
|
|
|
if resp.StatusCode != 200 {
|
|
|
|
|
// Python wraps errors as `{"code":..., "message":...}`. Surface
|
|
|
|
|
// the body verbatim so the user can read the upstream error.
|
|
|
|
|
return &OpenAIChatResponse{
|
|
|
|
|
Code: resp.StatusCode,
|
|
|
|
|
Message: string(resp.Body),
|
|
|
|
|
raw: resp.Body,
|
|
|
|
|
}, nil
|
|
|
|
|
}
|
|
|
|
|
out := &OpenAIChatResponse{
|
|
|
|
|
Duration: resp.Duration,
|
|
|
|
|
raw: resp.Body,
|
|
|
|
|
}
|
|
|
|
|
var wrapped struct {
|
|
|
|
|
Code int `json:"code"`
|
|
|
|
|
Message string `json:"message"`
|
|
|
|
|
Data *openAIChatData `json:"data"`
|
|
|
|
|
}
|
|
|
|
|
if err := json.Unmarshal(resp.Body, &wrapped); err == nil && wrapped.Data != nil {
|
|
|
|
|
out.Code = wrapped.Code
|
|
|
|
|
out.Message = wrapped.Message
|
|
|
|
|
out.Data = wrapped.Data
|
|
|
|
|
if len(wrapped.Data.Choices) > 0 {
|
|
|
|
|
out.Reasoning = wrapped.Data.Choices[0].Message.ReasoningContent
|
|
|
|
|
}
|
|
|
|
|
return out, nil
|
|
|
|
|
}
|
|
|
|
|
// Unwrapped (Go handler) shape.
|
|
|
|
|
if err := json.Unmarshal(resp.Body, &out.Data); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("openai_chat: invalid response JSON: %w", err)
|
|
|
|
|
}
|
|
|
|
|
if out.Data != nil && len(out.Data.Choices) > 0 {
|
|
|
|
|
out.Reasoning = out.Data.Choices[0].Message.ReasoningContent
|
|
|
|
|
}
|
|
|
|
|
return out, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// streamOpenaiChat performs a streaming POST and prints SSE chunks to
|
|
|
|
|
// stdout as they arrive
|
|
|
|
|
func (c *CLI) streamOpenaiChat(url string, body map[string]interface{}) (ResponseIf, error) {
|
|
|
|
|
httpClient := c.APIServerClientMap[c.Config.APIClientConfig.CurrentAPIServer]
|
|
|
|
|
resp, err := httpClient.Request("POST", url, "web", nil, body)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("openai_chat stream: %w", err)
|
|
|
|
|
}
|
|
|
|
|
if resp.StatusCode != 200 {
|
|
|
|
|
return &OpenAIChatResponse{
|
|
|
|
|
Code: resp.StatusCode,
|
|
|
|
|
Message: string(resp.Body),
|
|
|
|
|
Duration: resp.Duration,
|
|
|
|
|
raw: resp.Body,
|
|
|
|
|
}, nil
|
|
|
|
|
}
|
|
|
|
|
full := string(resp.Body)
|
|
|
|
|
var (
|
|
|
|
|
fullContent string
|
|
|
|
|
fullReason string
|
|
|
|
|
resolvedMod string
|
|
|
|
|
)
|
|
|
|
|
for _, line := range strings.Split(full, "\n") {
|
|
|
|
|
line = strings.TrimSpace(line)
|
|
|
|
|
if !strings.HasPrefix(line, "data:") {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
|
|
|
|
if payload == "" || payload == "[DONE]" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
var chunk struct {
|
|
|
|
|
Model string `json:"model"`
|
|
|
|
|
Choices []struct {
|
|
|
|
|
Delta struct {
|
|
|
|
|
Content string `json:"content"`
|
|
|
|
|
ReasoningContent string `json:"reasoning_content"`
|
|
|
|
|
} `json:"delta"`
|
|
|
|
|
FinishReason string `json:"finish_reason"`
|
|
|
|
|
} `json:"choices"`
|
|
|
|
|
Usage *openAIChatUsage `json:"usage"`
|
|
|
|
|
}
|
|
|
|
|
if err := json.Unmarshal([]byte(payload), &chunk); err != nil {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if chunk.Model != "" {
|
|
|
|
|
resolvedMod = chunk.Model
|
|
|
|
|
}
|
|
|
|
|
if len(chunk.Choices) > 0 {
|
|
|
|
|
if d := chunk.Choices[0].Delta.Content; d != "" {
|
|
|
|
|
fullContent += d
|
|
|
|
|
}
|
|
|
|
|
if r := chunk.Choices[0].Delta.ReasoningContent; r != "" {
|
|
|
|
|
fullReason += r
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fullContent = strings.TrimLeft(fullContent, "\n\r")
|
|
|
|
|
fullReason = strings.TrimLeft(fullReason, "\n\r")
|
|
|
|
|
fullContent = stripThinkTags(fullContent)
|
|
|
|
|
fullReason = stripThinkTags(fullReason)
|
|
|
|
|
return &OpenAIChatResponse{
|
|
|
|
|
Duration: resp.Duration,
|
|
|
|
|
Reasoning: fullReason,
|
|
|
|
|
Data: &openAIChatData{
|
|
|
|
|
Model: resolvedMod,
|
|
|
|
|
Choices: []openAIChatChoice{{Message: openAIChatMessage{Content: fullContent, ReasoningContent: fullReason}}},
|
|
|
|
|
},
|
|
|
|
|
streamed: true,
|
|
|
|
|
raw: resp.Body,
|
|
|
|
|
}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// stripThinkTags removes <think>…</think> wrappers from a streamed answer
|
|
|
|
|
func stripThinkTags(s string) string {
|
|
|
|
|
var thinkTagRE = regexp.MustCompile(`(?s)<think>.*?</think>`)
|
|
|
|
|
return thinkTagRE.ReplaceAllString(s, "")
|
|
|
|
|
}
|