WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,477 @@
|
||||
# WeKnora HTTP 客户端
|
||||
|
||||
这个包提供了与WeKnora服务进行交互的客户端库,支持所有基于HTTP的接口调用,使其他模块更方便地集成WeKnora服务,无需直接编写HTTP请求代码。
|
||||
|
||||
## 主要功能
|
||||
|
||||
该客户端包含以下主要功能模块:
|
||||
|
||||
1. **会话管理**:创建、获取、更新和删除会话
|
||||
2. **知识库管理**:创建、获取、更新和删除知识库
|
||||
3. **知识管理**:添加、获取和删除知识内容
|
||||
4. **空间管理**:空间的CRUD操作
|
||||
5. **知识问答**:支持普通问答和流式问答
|
||||
6. **Agent问答**:支持基于Agent的智能问答,包含思考过程、工具调用和反思
|
||||
7. **分块管理**:查询、更新和删除知识分块
|
||||
8. **消息管理**:获取和删除会话消息
|
||||
9. **模型管理**:创建、获取、更新和删除模型
|
||||
10. **沙箱技能**:向沙箱配置安装技能(zip 上传,或从 ClawHub / SkillHub / GitHub 等来源),并配置技能所需的环境变量
|
||||
11. **长期记忆**:当前用户的跨会话记忆(设置开关、条目增删改、确认/否决、主题、文档亲和度、导出、立刻整理)
|
||||
12. **认证**:登录、刷新令牌、切换激活空间(`SwitchTenant` 会写入最近活跃租户偏好)
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 创建客户端实例
|
||||
|
||||
```go
|
||||
import (
|
||||
"context"
|
||||
"github.com/Tencent/WeKnora/client"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 创建客户端实例
|
||||
apiClient := client.NewClient(
|
||||
"http://api.example.com",
|
||||
client.WithToken("your-auth-token"),
|
||||
client.WithTimeout(30*time.Second),
|
||||
)
|
||||
```
|
||||
|
||||
### 空间配置
|
||||
|
||||
客户端支持通过 `WithTenantID` 设置默认空间,请求时会自动携带 `X-Tenant-ID` 请求头:
|
||||
|
||||
```go
|
||||
tenantID := uint64(10000)
|
||||
apiClient := client.NewClient(
|
||||
"http://api.example.com",
|
||||
client.WithToken("your-auth-token"),
|
||||
client.WithTenantID(tenantID),
|
||||
)
|
||||
```
|
||||
|
||||
如果某个请求需要临时切换空间,可以在 `context` 中设置 `TenantID`,值可以是 `uint64`、`*uint64` 或字符串形式的数字,客户端会优先使用该值:
|
||||
|
||||
```go
|
||||
ctx := context.WithValue(context.Background(), "TenantID", uint64(10000))
|
||||
// 调用任意客户端方法时传入 ctx,即可切换到空间 10000
|
||||
```
|
||||
|
||||
### 示例:创建知识库并上传文件
|
||||
|
||||
```go
|
||||
// 创建知识库
|
||||
kb := &client.KnowledgeBase{
|
||||
Name: "测试知识库",
|
||||
Description: "这是一个测试知识库",
|
||||
ChunkingConfig: client.ChunkingConfig{
|
||||
ChunkSize: 500,
|
||||
ChunkOverlap: 50,
|
||||
Separators: []string{"\n\n", "\n", ". ", "? ", "! "},
|
||||
},
|
||||
ImageProcessingConfig: client.ImageProcessingConfig{
|
||||
ModelID: "image_model_id",
|
||||
},
|
||||
EmbeddingModelID: "embedding_model_id",
|
||||
SummaryModelID: "summary_model_id",
|
||||
}
|
||||
|
||||
kb, err := apiClient.CreateKnowledgeBase(context.Background(), kb)
|
||||
if err != nil {
|
||||
// 处理错误
|
||||
}
|
||||
|
||||
// 上传知识文件并添加元数据
|
||||
metadata := map[string]string{
|
||||
"source": "local",
|
||||
"type": "document",
|
||||
}
|
||||
knowledge, err := apiClient.CreateKnowledgeFromFile(context.Background(), kb.ID, "path/to/file.pdf", metadata)
|
||||
if err != nil {
|
||||
// 处理错误
|
||||
}
|
||||
```
|
||||
|
||||
### 示例:创建会话并进行问答
|
||||
|
||||
```go
|
||||
// 创建会话
|
||||
sessionRequest := &client.CreateSessionRequest{
|
||||
KnowledgeBaseID: knowledgeBaseID,
|
||||
SessionStrategy: &client.SessionStrategy{
|
||||
MaxRounds: 10,
|
||||
EnableRewrite: true,
|
||||
FallbackStrategy: "fixed_answer",
|
||||
FallbackResponse: "抱歉,我无法回答这个问题",
|
||||
EmbeddingTopK: 5,
|
||||
KeywordThreshold: 0.5,
|
||||
VectorThreshold: 0.7,
|
||||
RerankModelID: "rerank_model_id",
|
||||
RerankTopK: 3,
|
||||
RerankThreshold: 0.8,
|
||||
SummaryModelID: "summary_model_id",
|
||||
},
|
||||
}
|
||||
|
||||
session, err := apiClient.CreateSession(context.Background(), sessionRequest)
|
||||
if err != nil {
|
||||
// 处理错误
|
||||
}
|
||||
|
||||
// 普通问答
|
||||
answer, err := apiClient.KnowledgeQA(context.Background(), session.ID, &client.KnowledgeQARequest{
|
||||
Query: "什么是人工智能?",
|
||||
})
|
||||
if err != nil {
|
||||
// 处理错误
|
||||
}
|
||||
|
||||
// 流式问答
|
||||
err = apiClient.KnowledgeQAStream(context.Background(), session.ID, &client.KnowledgeQARequest{
|
||||
Query: "什么是机器学习?",
|
||||
KnowledgeBaseIDs: []string{knowledgeBaseID}, // 可选:指定知识库
|
||||
WebSearchEnabled: false, // 可选:是否启用网络搜索
|
||||
}, func(response *client.StreamResponse) error {
|
||||
// 处理每个响应片段
|
||||
fmt.Print(response.Content)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
// 处理错误
|
||||
}
|
||||
```
|
||||
|
||||
### 示例:Agent智能问答
|
||||
|
||||
Agent问答提供更强大的智能对话能力,支持工具调用、思考过程展示和自我反思。
|
||||
|
||||
```go
|
||||
// 创建Agent会话
|
||||
agentSession := apiClient.NewAgentSession(session.ID)
|
||||
|
||||
// 进行Agent问答,带完整事件处理
|
||||
err := agentSession.Ask(context.Background(), "搜索机器学习相关知识并总结要点",
|
||||
func(resp *client.AgentStreamResponse) error {
|
||||
switch resp.ResponseType {
|
||||
case client.AgentResponseTypeThinking:
|
||||
// Agent正在思考
|
||||
if resp.Done {
|
||||
fmt.Printf("💭 思考: %s\n", resp.Content)
|
||||
}
|
||||
|
||||
case client.AgentResponseTypeToolCall:
|
||||
// Agent调用工具
|
||||
if resp.Data != nil {
|
||||
toolName := resp.Data["tool_name"]
|
||||
fmt.Printf("🔧 调用工具: %v\n", toolName)
|
||||
}
|
||||
|
||||
case client.AgentResponseTypeToolResult:
|
||||
// 工具执行结果
|
||||
fmt.Printf("✓ 工具结果: %s\n", resp.Content)
|
||||
|
||||
case client.AgentResponseTypeReferences:
|
||||
// 知识引用
|
||||
if resp.KnowledgeReferences != nil {
|
||||
fmt.Printf("📚 找到 %d 条相关知识\n", len(resp.KnowledgeReferences))
|
||||
for _, ref := range resp.KnowledgeReferences {
|
||||
fmt.Printf(" - [%.3f] %s\n", ref.Score, ref.KnowledgeTitle)
|
||||
}
|
||||
}
|
||||
|
||||
case client.AgentResponseTypeAnswer:
|
||||
// 最终答案(流式输出)
|
||||
fmt.Print(resp.Content)
|
||||
if resp.Done {
|
||||
fmt.Println() // 结束后换行
|
||||
}
|
||||
|
||||
case client.AgentResponseTypeReflection:
|
||||
// Agent的自我反思
|
||||
if resp.Done {
|
||||
fmt.Printf("🤔 反思: %s\n", resp.Content)
|
||||
}
|
||||
|
||||
case client.AgentResponseTypeError:
|
||||
// 错误信息
|
||||
fmt.Printf("❌ 错误: %s\n", resp.Content)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
// 处理错误
|
||||
}
|
||||
|
||||
// 简化版:只关心最终答案
|
||||
var finalAnswer string
|
||||
err = agentSession.Ask(context.Background(), "什么是深度学习?",
|
||||
func(resp *client.AgentStreamResponse) error {
|
||||
if resp.ResponseType == client.AgentResponseTypeAnswer {
|
||||
finalAnswer += resp.Content
|
||||
}
|
||||
return nil
|
||||
})
|
||||
```
|
||||
|
||||
### Agent事件类型说明
|
||||
|
||||
| 事件类型 | 说明 | 何时触发 |
|
||||
|---------|------|---------|
|
||||
| `AgentResponseTypeThinking` | Agent思考过程 | Agent分析问题和制定计划时 |
|
||||
| `AgentResponseTypeToolCall` | 工具调用 | Agent决定使用某个工具时 |
|
||||
| `AgentResponseTypeToolResult` | 工具执行结果 | 工具执行完成后 |
|
||||
| `AgentResponseTypeReferences` | 知识引用 | 检索到相关知识时 |
|
||||
| `AgentResponseTypeAnswer` | 最终答案 | Agent生成回答时(流式) |
|
||||
| `AgentResponseTypeArtifactsPending` | 生成文件上传中 | 回答结束后、文件写入对象存储完成前 |
|
||||
| `AgentResponseTypeReflection` | 自我反思 | Agent评估自己的回答时 |
|
||||
| `AgentResponseTypeError` | 错误 | 发生错误时 |
|
||||
|
||||
### Agent问答测试工具
|
||||
|
||||
我们提供了一个交互式命令行工具用于测试Agent功能:
|
||||
|
||||
```bash
|
||||
cd client/cmd/agent_test
|
||||
go build -o agent_test
|
||||
./agent_test -url http://localhost:8080 -kb <knowledge_base_id>
|
||||
```
|
||||
|
||||
该工具支持:
|
||||
- 创建和管理会话
|
||||
- 交互式Agent问答
|
||||
- 实时显示所有Agent事件
|
||||
- 性能统计和调试信息
|
||||
|
||||
详细使用说明请参考 `client/cmd/agent_test/README.md`。
|
||||
|
||||
### Agent问答的高级用法
|
||||
|
||||
更多高级用法示例,请参考 `agent_example.go` 文件,包括:
|
||||
- 基础Agent问答
|
||||
- 工具调用跟踪
|
||||
- 知识引用捕获
|
||||
- 完整事件跟踪
|
||||
- 自定义错误处理
|
||||
- 流取消控制
|
||||
- 多会话管理
|
||||
|
||||
```
|
||||
|
||||
### 示例:管理模型
|
||||
|
||||
```go
|
||||
// 创建模型
|
||||
modelRequest := &client.CreateModelRequest{
|
||||
Name: "测试模型",
|
||||
Type: client.ModelTypeChat,
|
||||
Source: client.ModelSourceInternal,
|
||||
Description: "这是一个测试模型",
|
||||
Parameters: client.ModelParameters{
|
||||
"temperature": 0.7,
|
||||
"top_p": 0.9,
|
||||
},
|
||||
IsDefault: true,
|
||||
}
|
||||
model, err := apiClient.CreateModel(context.Background(), modelRequest)
|
||||
if err != nil {
|
||||
// 处理错误
|
||||
}
|
||||
|
||||
// 列出所有模型
|
||||
models, err := apiClient.ListModels(context.Background())
|
||||
if err != nil {
|
||||
// 处理错误
|
||||
}
|
||||
```
|
||||
|
||||
### 示例:管理知识分块
|
||||
|
||||
```go
|
||||
// 列出知识分块
|
||||
chunks, total, err := apiClient.ListKnowledgeChunks(context.Background(), knowledgeID, 1, 10)
|
||||
if err != nil {
|
||||
// 处理错误
|
||||
}
|
||||
|
||||
// 更新分块
|
||||
updateRequest := &client.UpdateChunkRequest{
|
||||
Content: "更新后的分块内容",
|
||||
IsEnabled: true,
|
||||
}
|
||||
updatedChunk, err := apiClient.UpdateChunk(context.Background(), knowledgeID, chunkID, updateRequest)
|
||||
if err != nil {
|
||||
// 处理错误
|
||||
}
|
||||
```
|
||||
|
||||
### 示例:重新解析知识
|
||||
|
||||
```go
|
||||
// 重新解析知识(删除现有内容并重新解析)
|
||||
// 适用场景:
|
||||
// 1. 原始解析失败,需要重试
|
||||
// 2. 更新了解析配置(如分块策略、多模态设置等),需要重新解析
|
||||
// 3. 知识内容已更新,需要刷新解析结果
|
||||
|
||||
knowledge, err := apiClient.ReparseKnowledge(context.Background(), knowledgeID)
|
||||
if err != nil {
|
||||
// 处理错误
|
||||
}
|
||||
|
||||
// 知识将进入 "pending" 状态,异步重新解析
|
||||
fmt.Printf("Knowledge ID: %s\n", knowledge.ID)
|
||||
fmt.Printf("Parse Status: %s\n", knowledge.ParseStatus) // "pending"
|
||||
fmt.Printf("Enable Status: %s\n", knowledge.EnableStatus) // "disabled"
|
||||
|
||||
// 可以轮询检查解析状态
|
||||
for {
|
||||
time.Sleep(5 * time.Second)
|
||||
knowledge, err := apiClient.GetKnowledge(context.Background(), knowledgeID)
|
||||
if err != nil {
|
||||
// 处理错误
|
||||
}
|
||||
|
||||
if knowledge.ParseStatus == "completed" {
|
||||
fmt.Println("Knowledge re-parsing completed!")
|
||||
break
|
||||
} else if knowledge.ParseStatus == "failed" {
|
||||
fmt.Printf("Knowledge re-parsing failed: %s\n", knowledge.ErrorMessage)
|
||||
break
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 示例:取消解析
|
||||
|
||||
```go
|
||||
// 取消正在进行的解析任务(资源紧张 / 上传错误文件时使用)
|
||||
// - 已经 completed / failed 的知识不能取消
|
||||
// - 已写入的分块/索引会保留,可后续调用 ReparseKnowledge 重新解析
|
||||
|
||||
knowledge, err := apiClient.CancelKnowledgeParse(context.Background(), knowledgeID)
|
||||
if err != nil {
|
||||
// 处理错误
|
||||
}
|
||||
fmt.Printf("Parse Status: %s\n", knowledge.ParseStatus) // "cancelled"
|
||||
```
|
||||
|
||||
### 示例:查看文档解析追踪(Span 树)
|
||||
|
||||
```go
|
||||
// 获取文档解析流水线的 Span 树(root → stage → subspan)
|
||||
// - attempt 传 0 表示获取最新一次解析尝试
|
||||
// - 始终返回 5 个标准阶段:docreader / chunking / embedding / multimodal / postprocess
|
||||
trace, err := apiClient.GetKnowledgeProcessingSpans(context.Background(), knowledgeID, 0)
|
||||
if err != nil {
|
||||
// 处理错误
|
||||
}
|
||||
fmt.Printf("ParseStatus=%s CurrentStage=%s\n", trace.ParseStatus, trace.CurrentStage)
|
||||
for _, stage := range trace.Trace.Children {
|
||||
fmt.Printf("- %s: %s (%dms)\n", stage.Name, stage.Status, stage.DurationMs)
|
||||
}
|
||||
```
|
||||
|
||||
### 示例:获取会话消息
|
||||
|
||||
```go
|
||||
// 获取最近消息
|
||||
messages, err := apiClient.GetRecentMessages(context.Background(), sessionID, 10)
|
||||
if err != nil {
|
||||
// 处理错误
|
||||
}
|
||||
|
||||
// 获取指定时间之前的消息
|
||||
beforeTime := time.Now().Add(-24 * time.Hour)
|
||||
olderMessages, err := apiClient.GetMessagesBefore(context.Background(), sessionID, beforeTime, 10)
|
||||
if err != nil {
|
||||
// 处理错误
|
||||
}
|
||||
```
|
||||
|
||||
### 示例:从托管平台安装沙箱技能
|
||||
|
||||
`source` 必须写明确:ClawHub 用 `@owner/slug`,ClawHub 上的 skills.sh 条目用完整 `https://clawhub.ai/skills-sh/owner/repo/slug` 或 `skills-sh:owner/repo/slug`,GitHub / SkillHub 粘贴完整 URL。不要传裸的 `owner/slug`。
|
||||
|
||||
```go
|
||||
skillID, err := apiClient.InstallSandboxSkillFromSource(
|
||||
context.Background(), sandboxConfigID, "@owner/slug")
|
||||
if err != nil {
|
||||
// 处理错误
|
||||
}
|
||||
_ = skillID // 用 skillID 订阅 /sandbox-configs/{id}/skills/{skillID}/install-events
|
||||
```
|
||||
|
||||
### 示例:停止卡住的安装
|
||||
|
||||
服务重启后安装行可能一直停在 `installing`,界面无法重试或卸载。停止会立刻改写该行(进程内若还有 goroutine 也会取消),之后可以再调重试或卸载。
|
||||
|
||||
```go
|
||||
skill, err := apiClient.StopSandboxSkill(context.Background(), sandboxConfigID, skillID)
|
||||
if err != nil {
|
||||
// 处理错误
|
||||
}
|
||||
_ = skill
|
||||
```
|
||||
|
||||
### 示例:重试失败的安装
|
||||
|
||||
安装失败的原因常与安装包无关(沙箱不可达、依赖源超时)。服务端保留着原始安装包,重试无需再传一次。
|
||||
|
||||
```go
|
||||
skillID, err := apiClient.ReinstallSandboxSkill(context.Background(), sandboxConfigID, skillID)
|
||||
if err != nil {
|
||||
// 处理错误
|
||||
}
|
||||
```
|
||||
|
||||
### 示例:查看已安装技能的文件
|
||||
|
||||
```go
|
||||
files, err := apiClient.ListSandboxSkillFiles(context.Background(), sandboxConfigID, skillID)
|
||||
if err != nil {
|
||||
// 处理错误
|
||||
}
|
||||
content, err := apiClient.GetSandboxSkillFile(context.Background(), sandboxConfigID, skillID, "SKILL.md")
|
||||
if err != nil {
|
||||
// 处理错误
|
||||
}
|
||||
_ = files
|
||||
_ = content
|
||||
```
|
||||
|
||||
### 示例:配置技能的环境变量
|
||||
|
||||
技能安装时会声明它需要哪些环境变量。值分两层:空间级由管理员设置、对所有人生效;个人级只对**当前调用身份**生效,并覆盖空间级。任何接口都不会回读已保存的值,只报告是否已设置。
|
||||
|
||||
用 API Key 调用与网页登录是两种不同身份:在网页里填的个人级值不会作用于 API Key 发起的执行。集成场景请优先用空间级值。
|
||||
|
||||
```go
|
||||
// 空间级:对该空间所有人生效,需要 Admin 及以上权限
|
||||
skill, err := apiClient.SetSandboxSkillEnvValues(
|
||||
context.Background(), sandboxConfigID, skillID,
|
||||
map[string]string{"TAVILY_API_KEY": "tvly-xxxxx"})
|
||||
if err != nil {
|
||||
// 处理错误
|
||||
}
|
||||
|
||||
// 个人级:只对当前调用身份生效
|
||||
err = apiClient.SetMySkillEnvVar(
|
||||
context.Background(), skillID, "TAVILY_API_KEY", "tvly-yyyyy")
|
||||
if err != nil {
|
||||
// 处理错误
|
||||
}
|
||||
|
||||
// 查看哪些变量还没填。清空一个值用 Delete,而不是写入空字符串
|
||||
groups, err := apiClient.ListMyEnvVars(context.Background())
|
||||
if err != nil {
|
||||
// 处理错误
|
||||
}
|
||||
_ = skill
|
||||
_ = groups
|
||||
```
|
||||
|
||||
## 完整示例
|
||||
|
||||
请参考 `example.go` 文件中的 `ExampleUsage` 函数,其中展示了客户端的完整使用流程。
|
||||
@@ -0,0 +1,291 @@
|
||||
# WeKnora HTTP Client
|
||||
|
||||
This package provides a client library for interacting with WeKnora services, supporting all HTTP-based interface calls, making it easier for other modules to integrate with WeKnora services without having to write HTTP request code directly.
|
||||
|
||||
## Main Features
|
||||
|
||||
The client includes the following main functional modules:
|
||||
|
||||
1. **Session Management**: Create, retrieve, update, and delete sessions
|
||||
2. **Knowledge Base Management**: Create, retrieve, update, and delete knowledge bases
|
||||
3. **Knowledge Management**: Add, retrieve, and delete knowledge content
|
||||
4. **Workspace Management**: CRUD operations for workspaces
|
||||
5. **Knowledge Q&A**: Supports regular Q&A and streaming Q&A
|
||||
6. **Chunk Management**: Query, update, and delete knowledge chunks
|
||||
7. **Message Management**: Retrieve and delete session messages
|
||||
8. **Model Management**: Create, retrieve, update, and delete models
|
||||
9. **Evaluation Function**: Start evaluation tasks and get evaluation results
|
||||
10. **Sandbox skills**: Install a skill onto a sandbox config (zip upload, or ClawHub / SkillHub / GitHub source) and configure the environment variables it needs
|
||||
11. **Long-term memory**: The caller's cross-session memories (settings, items, confirm/reject, topics, document affinity, export, consolidate)
|
||||
12. **Auth**: Login, refresh tokens, and switch the active workspace (`SwitchTenant` records the last-active-tenant preference)
|
||||
|
||||
## Usage
|
||||
|
||||
### Creating Client Instance
|
||||
|
||||
```go
|
||||
import (
|
||||
"context"
|
||||
"github.com/Tencent/WeKnora/client"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Create client instance
|
||||
apiClient := client.NewClient(
|
||||
"http://api.example.com",
|
||||
client.WithToken("your-auth-token"),
|
||||
client.WithTimeout(30*time.Second),
|
||||
)
|
||||
```
|
||||
|
||||
### Workspace Configuration
|
||||
|
||||
You can set a default workspace with `WithTenantID`; the client will automatically send the `X-Tenant-ID` header:
|
||||
|
||||
```go
|
||||
tenantID := uint64(10000)
|
||||
apiClient := client.NewClient(
|
||||
"http://api.example.com",
|
||||
client.WithToken("your-auth-token"),
|
||||
client.WithTenantID(tenantID),
|
||||
)
|
||||
```
|
||||
|
||||
If a single request needs a different workspace, set `TenantID` in the request context. The value can be a `uint64`, `*uint64`, or a numeric string, and it will take precedence over the client default:
|
||||
|
||||
```go
|
||||
ctx := context.WithValue(context.Background(), "TenantID", uint64(10000))
|
||||
// Pass ctx into any client method to switch to workspace 10000 for that request
|
||||
```
|
||||
|
||||
### Example: Create Knowledge Base and Upload File
|
||||
|
||||
```go
|
||||
// Create knowledge base
|
||||
kb := &client.KnowledgeBase{
|
||||
Name: "Test Knowledge Base",
|
||||
Description: "This is a test knowledge base",
|
||||
ChunkingConfig: client.ChunkingConfig{
|
||||
ChunkSize: 500,
|
||||
ChunkOverlap: 50,
|
||||
Separators: []string{"\n\n", "\n", ". ", "? ", "! "},
|
||||
},
|
||||
ImageProcessingConfig: client.ImageProcessingConfig{
|
||||
ModelID: "image_model_id",
|
||||
},
|
||||
EmbeddingModelID: "embedding_model_id",
|
||||
SummaryModelID: "summary_model_id",
|
||||
}
|
||||
|
||||
kb, err := apiClient.CreateKnowledgeBase(context.Background(), kb)
|
||||
if err != nil {
|
||||
// Handle error
|
||||
}
|
||||
|
||||
// Upload knowledge file with metadata
|
||||
metadata := map[string]string{
|
||||
"source": "local",
|
||||
"type": "document",
|
||||
}
|
||||
knowledge, err := apiClient.CreateKnowledgeFromFile(context.Background(), kb.ID, "path/to/file.pdf", metadata)
|
||||
if err != nil {
|
||||
// Handle error
|
||||
}
|
||||
```
|
||||
|
||||
### Example: Create Session and Chat
|
||||
|
||||
```go
|
||||
// Create session
|
||||
sessionRequest := &client.CreateSessionRequest{
|
||||
KnowledgeBaseID: knowledgeBaseID,
|
||||
SessionStrategy: &client.SessionStrategy{
|
||||
MaxRounds: 10,
|
||||
EnableRewrite: true,
|
||||
FallbackStrategy: "fixed_answer",
|
||||
FallbackResponse: "Sorry, I cannot answer this question",
|
||||
EmbeddingTopK: 5,
|
||||
KeywordThreshold: 0.5,
|
||||
VectorThreshold: 0.7,
|
||||
RerankModelID: "rerank_model_id",
|
||||
RerankTopK: 3,
|
||||
RerankThreshold: 0.8,
|
||||
SummaryModelID: "summary_model_id",
|
||||
},
|
||||
}
|
||||
|
||||
session, err := apiClient.CreateSession(context.Background(), sessionRequest)
|
||||
if err != nil {
|
||||
// Handle error
|
||||
}
|
||||
|
||||
// Regular Q&A
|
||||
answer, err := apiClient.KnowledgeQA(context.Background(), session.ID, &client.KnowledgeQARequest{
|
||||
Query: "What is artificial intelligence?",
|
||||
})
|
||||
if err != nil {
|
||||
// Handle error
|
||||
}
|
||||
|
||||
// Streaming Q&A
|
||||
err = apiClient.KnowledgeQAStream(context.Background(), session.ID, "What is machine learning?", func(response *client.StreamResponse) error {
|
||||
// Handle each response chunk
|
||||
fmt.Print(response.Content)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
// Handle error
|
||||
}
|
||||
```
|
||||
|
||||
### Example: Managing Models
|
||||
|
||||
```go
|
||||
// Create model
|
||||
modelRequest := &client.CreateModelRequest{
|
||||
Name: "Test Model",
|
||||
Type: client.ModelTypeChat,
|
||||
Source: client.ModelSourceInternal,
|
||||
Description: "This is a test model",
|
||||
Parameters: client.ModelParameters{
|
||||
"temperature": 0.7,
|
||||
"top_p": 0.9,
|
||||
},
|
||||
IsDefault: true,
|
||||
}
|
||||
model, err := apiClient.CreateModel(context.Background(), modelRequest)
|
||||
if err != nil {
|
||||
// Handle error
|
||||
}
|
||||
|
||||
// List all models
|
||||
models, err := apiClient.ListModels(context.Background())
|
||||
if err != nil {
|
||||
// Handle error
|
||||
}
|
||||
```
|
||||
|
||||
### Example: Managing Knowledge Chunks
|
||||
|
||||
```go
|
||||
// List knowledge chunks
|
||||
chunks, total, err := apiClient.ListKnowledgeChunks(context.Background(), knowledgeID, 1, 10)
|
||||
if err != nil {
|
||||
// Handle error
|
||||
}
|
||||
|
||||
// Update chunk
|
||||
updateRequest := &client.UpdateChunkRequest{
|
||||
Content: "Updated chunk content",
|
||||
IsEnabled: true,
|
||||
}
|
||||
updatedChunk, err := apiClient.UpdateChunk(context.Background(), knowledgeID, chunkID, updateRequest)
|
||||
if err != nil {
|
||||
// Handle error
|
||||
}
|
||||
```
|
||||
|
||||
### Example: Getting Session Messages
|
||||
|
||||
```go
|
||||
// Get recent messages
|
||||
messages, err := apiClient.GetRecentMessages(context.Background(), sessionID, 10)
|
||||
if err != nil {
|
||||
// Handle error
|
||||
}
|
||||
|
||||
// Get messages before a specific time
|
||||
beforeTime := time.Now().Add(-24 * time.Hour)
|
||||
olderMessages, err := apiClient.GetMessagesBefore(context.Background(), sessionID, beforeTime, 10)
|
||||
if err != nil {
|
||||
// Handle error
|
||||
}
|
||||
```
|
||||
|
||||
### Example: Install a sandbox skill from a registry
|
||||
|
||||
`source` must be explicit: `@owner/slug` for ClawHub, a full `https://clawhub.ai/skills-sh/owner/repo/slug` (or `skills-sh:owner/repo/slug`) for federated skills.sh listings, or a full GitHub / SkillHub URL. Bare `owner/slug` is rejected.
|
||||
|
||||
```go
|
||||
skillID, err := apiClient.InstallSandboxSkillFromSource(
|
||||
context.Background(), sandboxConfigID, "@owner/slug")
|
||||
if err != nil {
|
||||
// Handle error
|
||||
}
|
||||
_ = skillID // follow /sandbox-configs/{id}/skills/{skillID}/install-events
|
||||
```
|
||||
|
||||
### Example: Stop a stuck install
|
||||
|
||||
After a process restart the row may sit at `installing` with nothing running, which hides retry and uninstall. Stop rewrites the row immediately (and cancels the in-process goroutine if one is still alive). Then retry or uninstall as usual.
|
||||
|
||||
```go
|
||||
skill, err := apiClient.StopSandboxSkill(context.Background(), sandboxConfigID, skillID)
|
||||
if err != nil {
|
||||
// Handle error
|
||||
}
|
||||
_ = skill
|
||||
```
|
||||
|
||||
### Example: Retry a failed install
|
||||
|
||||
Installs usually fail for reasons the bundle cannot fix — an unreachable
|
||||
sandbox, a package index that timed out. The server still holds the archive,
|
||||
so the retry needs nothing from you.
|
||||
|
||||
```go
|
||||
skillID, err := apiClient.ReinstallSandboxSkill(context.Background(), sandboxConfigID, skillID)
|
||||
if err != nil {
|
||||
// Handle error
|
||||
}
|
||||
```
|
||||
|
||||
### Example: Browse files of an installed skill
|
||||
|
||||
```go
|
||||
files, err := apiClient.ListSandboxSkillFiles(context.Background(), sandboxConfigID, skillID)
|
||||
if err != nil {
|
||||
// Handle error
|
||||
}
|
||||
content, err := apiClient.GetSandboxSkillFile(context.Background(), sandboxConfigID, skillID, "SKILL.md")
|
||||
if err != nil {
|
||||
// Handle error
|
||||
}
|
||||
_ = files
|
||||
_ = content
|
||||
```
|
||||
|
||||
### Example: Configure a skill's environment variables
|
||||
|
||||
A skill declares the environment variables it needs when it is installed. Values live in two layers: a workspace value an admin sets for everybody, and a per-identity value that overrides it for the caller alone. No endpoint reads a stored value back; they only report whether one is set.
|
||||
|
||||
An API key and a web login are different identities: a personal value entered through the web UI does not apply to runs driven by an API key. Prefer workspace values for integrations.
|
||||
|
||||
```go
|
||||
// Workspace-wide, applies to everybody; requires Admin or above
|
||||
skill, err := apiClient.SetSandboxSkillEnvValues(
|
||||
context.Background(), sandboxConfigID, skillID,
|
||||
map[string]string{"TAVILY_API_KEY": "tvly-xxxxx"})
|
||||
if err != nil {
|
||||
// Handle error
|
||||
}
|
||||
|
||||
// Applies to the calling identity alone
|
||||
err = apiClient.SetMySkillEnvVar(
|
||||
context.Background(), skillID, "TAVILY_API_KEY", "tvly-yyyyy")
|
||||
if err != nil {
|
||||
// Handle error
|
||||
}
|
||||
|
||||
// See what is still unset. Clearing a value is a delete, not a write of ""
|
||||
groups, err := apiClient.ListMyEnvVars(context.Background())
|
||||
if err != nil {
|
||||
// Handle error
|
||||
}
|
||||
_ = skill
|
||||
_ = groups
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
Please refer to the `ExampleUsage` function in the `example.go` file, which demonstrates the complete usage flow of the client.
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
// Package client provides the implementation for interacting with the WeKnora API
|
||||
// The Agent related interfaces are used to manage agent-based question-answering
|
||||
package client
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MentionedItem represents a mentioned item in the request
|
||||
type MentionedItem struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"` // "kb", "file", "tag", "mcp", or "skill"
|
||||
KBType string `json:"kb_type"` // "document" or "faq" (only for kb type)
|
||||
KBID string `json:"kb_id"` // Parent knowledge base for file/tag mentions
|
||||
KBName string `json:"kb_name"` // Display name for parent KB
|
||||
}
|
||||
|
||||
// AgentQARequest agent Q&A request payload.
|
||||
type AgentQARequest struct {
|
||||
Query string `json:"query"` // Required query text
|
||||
KnowledgeBaseIDs []string `json:"knowledge_base_ids,omitempty"` // Optional KBs for this query
|
||||
KnowledgeIDs []string `json:"knowledge_ids,omitempty"` // Optional specific knowledge IDs for this query
|
||||
AgentEnabled bool `json:"agent_enabled"` // Whether to run in agent mode
|
||||
AgentID string `json:"agent_id,omitempty"` // Optional custom agent ID
|
||||
WebSearchEnabled bool `json:"web_search_enabled"` // Whether to enable web search
|
||||
SummaryModelID string `json:"summary_model_id,omitempty"` // Optional summary model override
|
||||
MentionedItems []MentionedItem `json:"mentioned_items,omitempty"` // @mentioned knowledge bases and files
|
||||
DisableTitle bool `json:"disable_title,omitempty"` // Whether to disable auto title generation
|
||||
MCPServiceIDs []string `json:"mcp_service_ids,omitempty"` // Optional MCP service allow list (deprecated)
|
||||
Images []ImageAttachment `json:"images,omitempty"` // Attached images for multimodal chat
|
||||
Channel string `json:"channel,omitempty"` // Source channel: "web", "api", "im", etc.
|
||||
}
|
||||
|
||||
// AgentResponseType defines the type of agent response
|
||||
type AgentResponseType string
|
||||
|
||||
const (
|
||||
// AgentResponseTypeThinking is emitted while the agent is reasoning.
|
||||
AgentResponseTypeThinking AgentResponseType = "thinking"
|
||||
// AgentResponseTypeToolCall is emitted when the agent invokes a tool.
|
||||
AgentResponseTypeToolCall AgentResponseType = "tool_call"
|
||||
// AgentResponseTypeToolResult is emitted when a tool returns.
|
||||
AgentResponseTypeToolResult AgentResponseType = "tool_result"
|
||||
// AgentResponseTypeReferences is emitted with knowledge references.
|
||||
AgentResponseTypeReferences AgentResponseType = "references"
|
||||
// AgentResponseTypeAnswer is emitted for answer tokens.
|
||||
AgentResponseTypeAnswer AgentResponseType = "answer"
|
||||
// AgentResponseTypeReflection is emitted for agent reflection.
|
||||
AgentResponseTypeReflection AgentResponseType = "reflection"
|
||||
// AgentResponseTypeError is emitted when the agent fails.
|
||||
AgentResponseTypeError AgentResponseType = "error"
|
||||
// AgentResponseTypeComplete is emitted when the agent run has finished.
|
||||
AgentResponseTypeComplete AgentResponseType = "complete"
|
||||
// AgentResponseTypeArtifactsPending is emitted while skill-generated files
|
||||
// are still being collected after the answer has streamed.
|
||||
AgentResponseTypeArtifactsPending AgentResponseType = "artifacts_pending"
|
||||
)
|
||||
|
||||
// AgentStreamResponse agent streaming response
|
||||
type AgentStreamResponse struct {
|
||||
ID string `json:"id"` // Unique identifier
|
||||
ResponseType AgentResponseType `json:"response_type"` // Response type
|
||||
Content string `json:"content,omitempty"` // Current content fragment
|
||||
Done bool `json:"done"` // Whether completed
|
||||
KnowledgeReferences []*SearchResult `json:"knowledge_references"` // Knowledge references
|
||||
Data map[string]interface{} `json:"data,omitempty"` // Additional event data
|
||||
}
|
||||
|
||||
// AgentEventCallback is called for each streaming event
|
||||
// Return error to stop processing the stream
|
||||
type AgentEventCallback func(*AgentStreamResponse) error
|
||||
|
||||
// AgentQAStream performs agent-based Q&A with SSE streaming using default agent settings.
|
||||
// Deprecated: prefer AgentQAStreamWithRequest to customize agent behavior.
|
||||
func (c *Client) AgentQAStream(ctx context.Context, sessionID string, query string, callback AgentEventCallback) error {
|
||||
req := &AgentQARequest{
|
||||
Query: query,
|
||||
AgentEnabled: true,
|
||||
}
|
||||
return c.AgentQAStreamWithRequest(ctx, sessionID, req, callback)
|
||||
}
|
||||
|
||||
// AgentQAStreamWithRequest performs agent-based Q&A with SSE streaming using the full request payload.
|
||||
// Pass ResourceURLOptions to receive public HTTP(S) file URLs in the stream.
|
||||
func (c *Client) AgentQAStreamWithRequest(ctx context.Context,
|
||||
sessionID string, request *AgentQARequest, callback AgentEventCallback,
|
||||
opts ...ResourceURLOptions,
|
||||
) error {
|
||||
if request == nil {
|
||||
return fmt.Errorf("agent QA request cannot be nil")
|
||||
}
|
||||
if strings.TrimSpace(request.Query) == "" {
|
||||
return fmt.Errorf("agent QA query cannot be empty")
|
||||
}
|
||||
|
||||
path := fmt.Sprintf("/api/v1/agent-chat/%s", sessionID)
|
||||
queryParams := url.Values{}
|
||||
if len(opts) > 0 {
|
||||
applyResourceURLQuery(queryParams, &opts[0])
|
||||
}
|
||||
resp, err := c.doRequestStream(ctx, http.MethodPost, path, request, queryParams)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return newAPIError(resp.StatusCode, body)
|
||||
}
|
||||
|
||||
// Process SSE stream
|
||||
return c.processAgentSSEStream(resp.Body, callback)
|
||||
}
|
||||
|
||||
// processAgentSSEStream processes the SSE stream and invokes callback for each event
|
||||
func (c *Client) processAgentSSEStream(reader io.Reader, callback AgentEventCallback) error {
|
||||
scanner := bufio.NewScanner(reader)
|
||||
// Default 64KiB per-line cap truncates large SSE data lines (the
|
||||
// references event bundles chunk contents that can reach hundreds of
|
||||
// KiB). Raise the cap so those lines parse instead of erroring with
|
||||
// "bufio.Scanner: token too long".
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
|
||||
var dataBuffer string
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
|
||||
// Empty line indicates the end of an event
|
||||
if line == "" {
|
||||
if dataBuffer != "" {
|
||||
var streamResponse AgentStreamResponse
|
||||
if err := json.Unmarshal([]byte(dataBuffer), &streamResponse); err != nil {
|
||||
return fmt.Errorf("failed to parse SSE data: %w", err)
|
||||
}
|
||||
|
||||
if err := callback(&streamResponse); err != nil {
|
||||
return err
|
||||
}
|
||||
// A terminal error frame is the stream's final outcome even if a
|
||||
// buggy/proxied server leaves the HTTP connection open and never
|
||||
// follows it with `complete` or EOF. Deliver it to the callback
|
||||
// first, then terminate the SDK call with an error.
|
||||
if streamResponse.ResponseType == AgentResponseTypeError && streamResponse.Done {
|
||||
return NewSSEStreamError(streamResponse.Content)
|
||||
}
|
||||
dataBuffer = ""
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Process lines with event: prefix (for future use)
|
||||
if strings.HasPrefix(line, "event:") {
|
||||
// Event type is available but not currently used
|
||||
// eventType := strings.TrimSpace(line[6:])
|
||||
continue
|
||||
}
|
||||
|
||||
// Process lines with data: prefix
|
||||
if strings.HasPrefix(line, "data:") {
|
||||
dataBuffer = strings.TrimSpace(line[5:]) // Remove "data:" prefix
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return fmt.Errorf("failed to read SSE stream: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AgentSession is a wrapper for agent-based interactions
|
||||
type AgentSession struct {
|
||||
client *Client
|
||||
sessionID string
|
||||
}
|
||||
|
||||
// NewAgentSession creates a new agent session wrapper
|
||||
func (c *Client) NewAgentSession(sessionID string) *AgentSession {
|
||||
return &AgentSession{
|
||||
client: c,
|
||||
sessionID: sessionID,
|
||||
}
|
||||
}
|
||||
|
||||
// Ask sends a query to the agent with default agent-enabled behavior.
|
||||
func (as *AgentSession) Ask(ctx context.Context, query string, callback AgentEventCallback) error {
|
||||
return as.client.AgentQAStream(ctx, as.sessionID, query, callback)
|
||||
}
|
||||
|
||||
// AskWithRequest sends a customized agent request for this session.
|
||||
func (as *AgentSession) AskWithRequest(
|
||||
ctx context.Context,
|
||||
request *AgentQARequest,
|
||||
callback AgentEventCallback,
|
||||
) error {
|
||||
return as.client.AgentQAStreamWithRequest(ctx, as.sessionID, request, callback)
|
||||
}
|
||||
|
||||
// GetSessionID returns the session ID
|
||||
func (as *AgentSession) GetSessionID() string {
|
||||
return as.sessionID
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
// Package client provides the implementation for interacting with the WeKnora API
|
||||
// The Agent management interfaces are used to manage custom agents (CRUD operations)
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Agent represents an agent entity
|
||||
type Agent struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Avatar string `json:"avatar"`
|
||||
IsBuiltin bool `json:"is_builtin"`
|
||||
TenantID uint64 `json:"tenant_id"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
Config *AgentConfig `json:"config"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CreatorName string `json:"creator_name,omitempty"`
|
||||
}
|
||||
|
||||
// AgentMode is an agent's operating mode (AgentConfig.AgentMode). It mirrors
|
||||
// the server constants in internal/types/custom_agent.go.
|
||||
type AgentMode string
|
||||
|
||||
const (
|
||||
// AgentModeQuickAnswer is the RAG mode for quick Q&A.
|
||||
AgentModeQuickAnswer AgentMode = "quick-answer"
|
||||
// AgentModeSmartReasoning is the ReAct mode for multi-step reasoning.
|
||||
AgentModeSmartReasoning AgentMode = "smart-reasoning"
|
||||
)
|
||||
|
||||
// AllAgentModes returns every agent operating mode the server recognises, in a
|
||||
// stable order. Use this instead of re-typing the set so callers can't drift
|
||||
// from the SDK.
|
||||
func AllAgentModes() []AgentMode {
|
||||
return []AgentMode{AgentModeQuickAnswer, AgentModeSmartReasoning}
|
||||
}
|
||||
|
||||
// KBSelectionMode controls how an agent selects knowledge bases
|
||||
// (AgentConfig.KBSelectionMode). It mirrors the server's documented values
|
||||
// ("all" | "selected" | "none") in internal/types/custom_agent.go.
|
||||
type KBSelectionMode string
|
||||
|
||||
const (
|
||||
// KBSelectionModeAll retrieves across every knowledge base.
|
||||
KBSelectionModeAll KBSelectionMode = "all"
|
||||
// KBSelectionModeSelected retrieves only the agent's attached KBs
|
||||
// (AgentConfig.KnowledgeBases).
|
||||
KBSelectionModeSelected KBSelectionMode = "selected"
|
||||
// KBSelectionModeNone disables knowledge base retrieval.
|
||||
KBSelectionModeNone KBSelectionMode = "none"
|
||||
)
|
||||
|
||||
// AllKBSelectionModes returns every KB selection mode the server recognises, in
|
||||
// a stable order. Use this instead of re-typing the set so callers can't drift
|
||||
// from the SDK.
|
||||
func AllKBSelectionModes() []KBSelectionMode {
|
||||
return []KBSelectionMode{KBSelectionModeAll, KBSelectionModeSelected, KBSelectionModeNone}
|
||||
}
|
||||
|
||||
// AgentConfig represents the configuration for an agent.
|
||||
// Field names and JSON tags mirror internal/types.CustomAgentConfig.
|
||||
type AgentConfig struct {
|
||||
AgentMode string `json:"agent_mode"`
|
||||
AgentType string `json:"agent_type,omitempty"`
|
||||
SystemPrompt string `json:"system_prompt"`
|
||||
SystemPromptID string `json:"system_prompt_id,omitempty"`
|
||||
ContextTemplate string `json:"context_template"`
|
||||
ContextTemplateID string `json:"context_template_id,omitempty"`
|
||||
ModelID string `json:"model_id"`
|
||||
RerankModelID string `json:"rerank_model_id"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
MaxCompletionTokens int `json:"max_completion_tokens"`
|
||||
Thinking *bool `json:"thinking"`
|
||||
CitationEnabled *bool `json:"citation_enabled"`
|
||||
MaxIterations int `json:"max_iterations"` // -1 = unlimited
|
||||
LLMCallTimeout int `json:"llm_call_timeout,omitempty"`
|
||||
AllowedTools []string `json:"allowed_tools"`
|
||||
MCPSelectionMode string `json:"mcp_selection_mode"`
|
||||
MCPServices []string `json:"mcp_services"`
|
||||
SkillsSelectionMode string `json:"skills_selection_mode"`
|
||||
SelectedSkills []string `json:"selected_skills"`
|
||||
KBSelectionMode string `json:"kb_selection_mode"`
|
||||
KnowledgeBases []string `json:"knowledge_bases"`
|
||||
RetrieveKBOnlyWhenMentioned bool `json:"retrieve_kb_only_when_mentioned"`
|
||||
RetainRetrievalHistory bool `json:"retain_retrieval_history"`
|
||||
ImageUploadEnabled bool `json:"image_upload_enabled"`
|
||||
VLMModelID string `json:"vlm_model_id"`
|
||||
AudioUploadEnabled bool `json:"audio_upload_enabled"`
|
||||
ASRModelID string `json:"asr_model_id"`
|
||||
ImageStorageProvider string `json:"image_storage_provider"`
|
||||
SupportedFileTypes []string `json:"supported_file_types"`
|
||||
DataAnalysisEnabled bool `json:"data_analysis_enabled"`
|
||||
FAQPriorityEnabled bool `json:"faq_priority_enabled"`
|
||||
FAQDirectAnswerThreshold float64 `json:"faq_direct_answer_threshold"`
|
||||
FAQScoreBoost float64 `json:"faq_score_boost"`
|
||||
WebSearchEnabled bool `json:"web_search_enabled"`
|
||||
WebSearchMaxResults int `json:"web_search_max_results"`
|
||||
WebSearchProviderID string `json:"web_search_provider_id,omitempty"`
|
||||
WebFetchEnabled bool `json:"web_fetch_enabled"`
|
||||
WebFetchTopN int `json:"web_fetch_top_n,omitempty"`
|
||||
MultiTurnEnabled bool `json:"multi_turn_enabled"`
|
||||
HistoryTurns int `json:"history_turns"`
|
||||
EmbeddingTopK int `json:"embedding_top_k"`
|
||||
KeywordThreshold float64 `json:"keyword_threshold"`
|
||||
VectorThreshold float64 `json:"vector_threshold"`
|
||||
RerankTopK int `json:"rerank_top_k"`
|
||||
RerankThreshold float64 `json:"rerank_threshold"`
|
||||
EnableQueryExpansion bool `json:"enable_query_expansion"`
|
||||
EnableRewrite bool `json:"enable_rewrite"`
|
||||
RewritePromptSystem string `json:"rewrite_prompt_system"`
|
||||
RewritePromptUser string `json:"rewrite_prompt_user"`
|
||||
QueryUnderstandModelID string `json:"query_understand_model_id,omitempty"`
|
||||
FallbackStrategy string `json:"fallback_strategy"`
|
||||
FallbackResponse string `json:"fallback_response"`
|
||||
FallbackPrompt string `json:"fallback_prompt"`
|
||||
QuestionSuggestions *QuestionSuggestionConfig `json:"question_suggestions,omitempty"`
|
||||
}
|
||||
|
||||
type QuestionSuggestionConfig struct {
|
||||
Starters StarterSuggestionConfig `json:"starters"`
|
||||
FollowUps FollowUpSuggestionConfig `json:"follow_ups"`
|
||||
}
|
||||
|
||||
type StarterSuggestionConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Mode string `json:"mode"`
|
||||
Items []string `json:"items"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
type FollowUpSuggestionConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Mode string `json:"mode"`
|
||||
Count int `json:"count"`
|
||||
ModelID string `json:"model_id,omitempty"`
|
||||
AdditionalInstruction string `json:"additional_instruction,omitempty"`
|
||||
Categories []string `json:"categories,omitempty"`
|
||||
MaxContextTurns int `json:"max_context_turns"`
|
||||
SuppressOnFallback bool `json:"suppress_on_fallback"`
|
||||
SuppressWhenAnswerAsksQuestion bool `json:"suppress_when_answer_asks_question"`
|
||||
KnowledgeFallback bool `json:"knowledge_fallback"`
|
||||
AllowRegenerate bool `json:"allow_regenerate"`
|
||||
}
|
||||
|
||||
// CreateAgentRequest represents the request to create an agent.
|
||||
// JSON field names mirror internal/handler.CreateAgentRequest.
|
||||
type CreateAgentRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Avatar string `json:"avatar"`
|
||||
Config *AgentConfig `json:"config"`
|
||||
}
|
||||
|
||||
// UpdateAgentRequest represents the request to update an agent.
|
||||
// JSON field names mirror internal/handler.UpdateAgentRequest.
|
||||
type UpdateAgentRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Avatar string `json:"avatar"`
|
||||
Config *AgentConfig `json:"config"`
|
||||
}
|
||||
|
||||
// AgentResponse represents the API response containing a single agent
|
||||
type AgentResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data Agent `json:"data"`
|
||||
}
|
||||
|
||||
// AgentListResponse represents the API response containing a list of agents
|
||||
type AgentListResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data []Agent `json:"data"`
|
||||
}
|
||||
|
||||
// AgentPlaceholdersResponse represents the API response for placeholder definitions
|
||||
type AgentPlaceholdersResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data map[string]json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
// CreateAgent creates a new custom agent
|
||||
func (c *Client) CreateAgent(ctx context.Context, request *CreateAgentRequest) (*Agent, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, "/api/v1/agents", request, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response AgentResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &response.Data, nil
|
||||
}
|
||||
|
||||
// ListAgents retrieves all agents for the current tenant
|
||||
func (c *Client) ListAgents(ctx context.Context) ([]Agent, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, "/api/v1/agents", nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response AgentListResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return response.Data, nil
|
||||
}
|
||||
|
||||
// GetAgent retrieves an agent by its ID
|
||||
func (c *Client) GetAgent(ctx context.Context, agentID string) (*Agent, error) {
|
||||
path := fmt.Sprintf("/api/v1/agents/%s", agentID)
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, path, nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response AgentResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &response.Data, nil
|
||||
}
|
||||
|
||||
// UpdateAgent updates an existing agent
|
||||
func (c *Client) UpdateAgent(ctx context.Context, agentID string, request *UpdateAgentRequest) (*Agent, error) {
|
||||
path := fmt.Sprintf("/api/v1/agents/%s", agentID)
|
||||
resp, err := c.doRequest(ctx, http.MethodPut, path, request, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response AgentResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &response.Data, nil
|
||||
}
|
||||
|
||||
// DeleteAgent deletes a custom agent by its ID
|
||||
func (c *Client) DeleteAgent(ctx context.Context, agentID string) error {
|
||||
path := fmt.Sprintf("/api/v1/agents/%s", agentID)
|
||||
resp, err := c.doRequest(ctx, http.MethodDelete, path, nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
return parseResponse(resp, &response)
|
||||
}
|
||||
|
||||
// CopyAgent creates a copy of an existing agent
|
||||
func (c *Client) CopyAgent(ctx context.Context, agentID string) (*Agent, error) {
|
||||
path := fmt.Sprintf("/api/v1/agents/%s/copy", agentID)
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, path, nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response AgentResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &response.Data, nil
|
||||
}
|
||||
|
||||
// GetAgentPlaceholders retrieves all available prompt placeholder definitions
|
||||
func (c *Client) GetAgentPlaceholders(ctx context.Context) (map[string]json.RawMessage, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, "/api/v1/agents/placeholders", nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response AgentPlaceholdersResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return response.Data, nil
|
||||
}
|
||||
|
||||
// SuggestedQuestion represents a suggested question for an agent
|
||||
type SuggestedQuestion struct {
|
||||
Question string `json:"question"` // Question text
|
||||
Source string `json:"source"` // Source: "faq", "document", or "agent_config"
|
||||
KnowledgeBaseID string `json:"knowledge_base_id,omitempty"` // Source knowledge base ID
|
||||
}
|
||||
|
||||
// SuggestedQuestionsRequest represents the options for getting suggested questions
|
||||
type SuggestedQuestionsRequest struct {
|
||||
KnowledgeBaseIDs []string // Optional: override agent's KB scope
|
||||
KnowledgeIDs []string // Optional: limit to specific knowledge items
|
||||
TagScopes []SuggestedQuestionTagScope // Optional: limit to tags within their parent KBs
|
||||
Limit int // Optional: max questions to return (default 6)
|
||||
}
|
||||
|
||||
// SuggestedQuestionTagScope preserves the KB-local identity of tag IDs.
|
||||
type SuggestedQuestionTagScope struct {
|
||||
KnowledgeBaseID string `json:"knowledge_base_id"`
|
||||
TagIDs []string `json:"tag_ids"`
|
||||
}
|
||||
|
||||
// SuggestedQuestionsResponse represents the API response for suggested questions
|
||||
type SuggestedQuestionsResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Questions []SuggestedQuestion `json:"questions"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// GetSuggestedQuestions retrieves suggested questions for the given agent,
|
||||
// based on its associated knowledge bases. The returned questions can be
|
||||
// displayed as quick-start prompts in the chat UI.
|
||||
//
|
||||
// When request is nil, uses the agent's default knowledge base configuration.
|
||||
func (c *Client) GetSuggestedQuestions(ctx context.Context, agentID string, request *SuggestedQuestionsRequest) ([]SuggestedQuestion, error) {
|
||||
path := fmt.Sprintf("/api/v1/agents/%s/suggested-questions", agentID)
|
||||
|
||||
query := url.Values{}
|
||||
if request != nil {
|
||||
if len(request.KnowledgeBaseIDs) > 0 {
|
||||
query.Set("knowledge_base_ids", strings.Join(request.KnowledgeBaseIDs, ","))
|
||||
}
|
||||
if len(request.KnowledgeIDs) > 0 {
|
||||
query.Set("knowledge_ids", strings.Join(request.KnowledgeIDs, ","))
|
||||
}
|
||||
if len(request.TagScopes) > 0 {
|
||||
encoded, err := json.Marshal(request.TagScopes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal tag scopes: %w", err)
|
||||
}
|
||||
query.Set("tag_scopes", string(encoded))
|
||||
}
|
||||
if request.Limit > 0 {
|
||||
query.Set("limit", strconv.Itoa(request.Limit))
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, path, nil, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response SuggestedQuestionsResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return response.Data.Questions, nil
|
||||
}
|
||||
+250
@@ -0,0 +1,250 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LoginRequest is the body for POST /api/v1/auth/login.
|
||||
type LoginRequest struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// LoginResponse is the body returned by POST /api/v1/auth/login.
|
||||
//
|
||||
// Token is the JWT access token; RefreshToken renews it via Auth.Refresh.
|
||||
// ActiveTenant is the tenant whose ID is encoded in the JWT — every
|
||||
// subsequent request is scoped to it until /auth/switch-tenant is called.
|
||||
// A successful switch also records that tenant as the user's last-active
|
||||
// preference, so the next login and refresh land in the same workspace.
|
||||
// Memberships lists every tenant the user can access along with their
|
||||
// role in each, so callers can build a tenant switcher UI without a
|
||||
// follow-up request.
|
||||
type LoginResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message,omitempty"`
|
||||
User *AuthUser `json:"user,omitempty"`
|
||||
// ActiveTenant is the tenant whose ID is encoded in the JWT. Use
|
||||
// this field; Tenant is preserved as a compatibility alias only.
|
||||
ActiveTenant *AuthTenant `json:"active_tenant,omitempty"`
|
||||
// Tenant is a deprecated alias kept so SDK callers compiled against
|
||||
// the pre-RBAC release continue to find the field by name. The
|
||||
// server emits active_tenant; this field is populated on the client
|
||||
// side by GetTenant() and will be removed in a future major release.
|
||||
//
|
||||
// Deprecated: use ActiveTenant.
|
||||
Tenant *AuthTenant `json:"-"`
|
||||
Memberships []AuthMembership `json:"memberships,omitempty"`
|
||||
Token string `json:"token,omitempty"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
}
|
||||
|
||||
// GetTenant returns the active tenant, preferring ActiveTenant and
|
||||
// falling back to the deprecated Tenant alias. SDK code should prefer
|
||||
// reading ActiveTenant directly; this helper exists to keep callers that
|
||||
// only know about the old field name compiling and working.
|
||||
func (r *LoginResponse) GetTenant() *AuthTenant {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
if r.ActiveTenant != nil {
|
||||
return r.ActiveTenant
|
||||
}
|
||||
return r.Tenant
|
||||
}
|
||||
|
||||
// AuthMembership pairs a tenant ID with the user's role in that tenant.
|
||||
// Mirrors types.Membership on the server.
|
||||
type AuthMembership struct {
|
||||
TenantID uint64 `json:"tenant_id"`
|
||||
TenantName string `json:"tenant_name,omitempty"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
// AuthUser is the principal returned by /auth/login and /auth/me.
|
||||
//
|
||||
// Fields mirror the server's UserInfo projection (no PasswordHash).
|
||||
type AuthUser struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Avatar string `json:"avatar,omitempty"`
|
||||
TenantID uint64 `json:"tenant_id"`
|
||||
IsActive bool `json:"is_active"`
|
||||
CanAccessAllTenants bool `json:"can_access_all_tenants,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at,omitempty"`
|
||||
}
|
||||
|
||||
// AuthTenant is the tenant projection returned alongside AuthUser.
|
||||
type AuthTenant struct {
|
||||
ID uint64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// AuthCapabilities mirrors GET /auth/me capabilities for SPA feature gates.
|
||||
type AuthCapabilities struct {
|
||||
CanCreateTenant bool `json:"can_create_tenant"`
|
||||
AutoAcceptInvitation bool `json:"auto_accept_invitation"`
|
||||
}
|
||||
|
||||
// CurrentUserResponse is the body of GET /api/v1/auth/me.
|
||||
type CurrentUserResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
User *AuthUser `json:"user,omitempty"`
|
||||
Tenant *AuthTenant `json:"tenant,omitempty"`
|
||||
Memberships []AuthMembership `json:"memberships,omitempty"`
|
||||
TenantRequired bool `json:"tenant_required,omitempty"`
|
||||
Capabilities *AuthCapabilities `json:"capabilities,omitempty"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// Canonical paths for the auth endpoints. Exposed so callers building
|
||||
// HTTP middleware (e.g. the CLI's 401-retry transport) can classify
|
||||
// requests without re-hardcoding the literals.
|
||||
const (
|
||||
PathAuthLogin = "/api/v1/auth/login"
|
||||
PathAuthRefresh = "/api/v1/auth/refresh"
|
||||
PathAuthSwitchTenant = "/api/v1/auth/switch-tenant"
|
||||
)
|
||||
|
||||
// RefreshTokenResponse is the body of POST /api/v1/auth/refresh.
|
||||
type RefreshTokenResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message,omitempty"`
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
|
||||
// Login authenticates with email + password and returns the JWT access token,
|
||||
// refresh token, and principal info. Maps to POST /api/v1/auth/login.
|
||||
//
|
||||
// Used by `weknora auth login`.
|
||||
func (c *Client) Login(ctx context.Context, req LoginRequest) (*LoginResponse, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, "/api/v1/auth/login", req, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("login request: %w", err)
|
||||
}
|
||||
var out LoginResponse
|
||||
if err := parseResponse(resp, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 后端已将 tenant 字段重命名为 active_tenant;为照顾仍读取旧字段名的下游
|
||||
// 调用者,在反序列化后镜像一份到 Tenant 上。两者总是指向同一指针。
|
||||
out.Tenant = out.ActiveTenant
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// SwitchTenantRequest is the body for POST /api/v1/auth/switch-tenant.
|
||||
type SwitchTenantRequest struct {
|
||||
TenantID uint64 `json:"tenant_id"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
}
|
||||
|
||||
// SwitchTenant re-issues a token pair scoped to TenantID and records that
|
||||
// tenant as the user's last-active preference (next login and refresh land
|
||||
// there). A preference-write failure fails the whole call — no new tokens
|
||||
// are issued. Maps to POST /api/v1/auth/switch-tenant.
|
||||
func (c *Client) SwitchTenant(ctx context.Context, req SwitchTenantRequest) (*LoginResponse, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, PathAuthSwitchTenant, req, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("switch tenant: %w", err)
|
||||
}
|
||||
var out LoginResponse
|
||||
if err := parseResponse(resp, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.Tenant = out.ActiveTenant
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// GetCurrentUser returns the currently authenticated principal and the
|
||||
// tenant projection. Maps to GET /api/v1/auth/me; the bearer token must
|
||||
// already be set on the client (use WithBearerToken).
|
||||
//
|
||||
// Used by `weknora auth status` and `weknora whoami`.
|
||||
func (c *Client) GetCurrentUser(ctx context.Context) (*CurrentUserResponse, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, "/api/v1/auth/me", nil, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get current user: %w", err)
|
||||
}
|
||||
var out CurrentUserResponse
|
||||
if err := parseResponse(resp, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// RefreshToken renews the JWT access token using a refresh token.
|
||||
// Maps to POST /api/v1/auth/refresh.
|
||||
//
|
||||
// Callers (`weknora auth refresh`) read the refresh token from secrets,
|
||||
// invoke this method, and persist both new tokens. The SDK does not touch
|
||||
// the secrets store directly — it stays a transport-only layer.
|
||||
func (c *Client) RefreshToken(ctx context.Context, refreshToken string) (*RefreshTokenResponse, error) {
|
||||
body := struct {
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
}{RefreshToken: refreshToken}
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, "/api/v1/auth/refresh", body, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("refresh token: %w", err)
|
||||
}
|
||||
var out RefreshTokenResponse
|
||||
if err := parseResponse(resp, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// ChangePasswordRequest is the body for POST /api/v1/auth/change-password.
|
||||
type ChangePasswordRequest struct {
|
||||
OldPassword string `json:"old_password"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
|
||||
// ChangePasswordResponse is returned on successful password rotation.
|
||||
type ChangePasswordResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// ChangePassword rotates the caller's password. On success every outstanding
|
||||
// session is revoked server-side; callers should discard local tokens.
|
||||
// Maps to POST /api/v1/auth/change-password.
|
||||
func (c *Client) ChangePassword(ctx context.Context, req ChangePasswordRequest) (*ChangePasswordResponse, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, "/api/v1/auth/change-password", req, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("change password: %w", err)
|
||||
}
|
||||
var out ChangePasswordResponse
|
||||
if err := parseResponse(resp, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// AuthConfigResponse is returned by GET /api/v1/auth/config.
|
||||
// The endpoint is unauthenticated so the SPA can decide whether to show
|
||||
// the register tab and which password complexity rules to apply.
|
||||
type AuthConfigResponse struct {
|
||||
Success bool `json:"success"`
|
||||
RegistrationMode string `json:"registration_mode"`
|
||||
ComplexPasswordEnabled bool `json:"complex_password_enabled"`
|
||||
}
|
||||
|
||||
// GetAuthConfig fetches public authentication settings.
|
||||
// Maps to GET /api/v1/auth/config.
|
||||
func (c *Client) GetAuthConfig(ctx context.Context) (*AuthConfigResponse, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, "/api/v1/auth/config", nil, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get auth config: %w", err)
|
||||
}
|
||||
var out AuthConfigResponse
|
||||
if err := parseResponse(resp, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
// Package client provides the implementation for interacting with the WeKnora API
|
||||
// This package encapsulates CRUD operations for server resources and provides a friendly interface for callers
|
||||
// The Chunk related interfaces are used to manage document chunks in the knowledge base
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// Chunk represents the information about a document chunk
|
||||
// Chunks are the basic units of storage and indexing in the knowledge base
|
||||
type Chunk struct {
|
||||
ID string `json:"id"` // Unique identifier of the chunk
|
||||
SeqID int64 `json:"seq_id"` // Auto-increment integer ID for external API usage
|
||||
KnowledgeID string `json:"knowledge_id"` // Identifier of the parent knowledge
|
||||
KnowledgeBaseID string `json:"knowledge_base_id"` // ID of the knowledge base
|
||||
TenantID uint64 `json:"tenant_id"` // Tenant ID
|
||||
TagID string `json:"tag_id"` // Optional tag ID for categorization
|
||||
Content string `json:"content"` // Text content of the chunk
|
||||
ChunkIndex int `json:"chunk_index"` // Index position of chunk in the document
|
||||
IsEnabled bool `json:"is_enabled"` // Whether this chunk is enabled
|
||||
Status int `json:"status"` // Status of the chunk
|
||||
StartAt int `json:"start_at"` // Starting position in original text
|
||||
EndAt int `json:"end_at"` // Ending position in original text
|
||||
PreChunkID string `json:"pre_chunk_id"` // Previous chunk ID
|
||||
NextChunkID string `json:"next_chunk_id"` // Next chunk ID
|
||||
ChunkType string `json:"chunk_type"` // Chunk type (text, image_ocr, etc.)
|
||||
ParentChunkID string `json:"parent_chunk_id"` // Parent chunk ID
|
||||
RelationChunks any `json:"relation_chunks"` // Relation chunk IDs
|
||||
IndirectRelationChunks any `json:"indirect_relation_chunks"` // Indirect relation chunk IDs
|
||||
Metadata any `json:"metadata"` // Metadata for the chunk
|
||||
ContentHash string `json:"content_hash"` // Content hash for quick matching
|
||||
ImageInfo string `json:"image_info"` // Image information
|
||||
CreatedAt string `json:"created_at"` // Creation time
|
||||
UpdatedAt string `json:"updated_at"` // Last update time
|
||||
}
|
||||
|
||||
// ChunkResponse represents the response for a single chunk
|
||||
// API response structure containing a single chunk information
|
||||
type ChunkResponse struct {
|
||||
Success bool `json:"success"` // Whether operation was successful
|
||||
Data Chunk `json:"data"` // Chunk data
|
||||
}
|
||||
|
||||
// ChunkListResponse represents the response for a list of chunks
|
||||
// API response structure for returning a list of chunks
|
||||
type ChunkListResponse struct {
|
||||
Success bool `json:"success"` // Whether operation was successful
|
||||
Data []Chunk `json:"data"` // List of chunks
|
||||
Total int64 `json:"total"` // Total count
|
||||
Page int `json:"page"` // Current page
|
||||
PageSize int `json:"page_size"` // Items per page
|
||||
}
|
||||
|
||||
// UpdateChunkRequest represents the request structure for updating a chunk
|
||||
// Used for requesting chunk information updates
|
||||
type UpdateChunkRequest struct {
|
||||
Content string `json:"content"` // Chunk content
|
||||
Embedding []float32 `json:"embedding"` // Vector embedding
|
||||
ChunkIndex int `json:"chunk_index"` // Chunk index
|
||||
IsEnabled bool `json:"is_enabled"` // Whether enabled
|
||||
StartAt int `json:"start_at"` // Start position
|
||||
EndAt int `json:"end_at"` // End position
|
||||
ImageInfo string `json:"image_info"` // Image information
|
||||
}
|
||||
|
||||
// ListKnowledgeChunks lists all chunks under a knowledge document
|
||||
// Queries all chunks by knowledge ID with pagination support
|
||||
// Parameters:
|
||||
// - ctx: Context
|
||||
// - knowledgeID: Knowledge ID
|
||||
// - page: Page number, starts from 1
|
||||
// - pageSize: Number of items per page
|
||||
// - chunkTypes: Optional chunk type filter (e.g. "text", "image_caption", "image_ocr").
|
||||
// When empty, the server defaults to text chunks only.
|
||||
//
|
||||
// Returns:
|
||||
// - []Chunk: List of chunks
|
||||
// - int64: Total count
|
||||
// - error: Error information
|
||||
func (c *Client) ListKnowledgeChunks(ctx context.Context,
|
||||
knowledgeID string, page int, pageSize int, chunkTypes ...string,
|
||||
) ([]Chunk, int64, error) {
|
||||
path := fmt.Sprintf("/api/v1/chunks/%s", knowledgeID)
|
||||
|
||||
queryParams := url.Values{}
|
||||
queryParams.Add("page", strconv.Itoa(page))
|
||||
queryParams.Add("page_size", strconv.Itoa(pageSize))
|
||||
for _, ct := range chunkTypes {
|
||||
queryParams.Add("chunk_type", ct)
|
||||
}
|
||||
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, path, nil, queryParams)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
var response ChunkListResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return response.Data, response.Total, nil
|
||||
}
|
||||
|
||||
// UpdateChunk updates a chunk's information
|
||||
// Updates information for a specific chunk under a knowledge document
|
||||
// Parameters:
|
||||
// - ctx: Context
|
||||
// - knowledgeID: Knowledge ID
|
||||
// - chunkID: Chunk ID
|
||||
// - request: Update request
|
||||
//
|
||||
// Returns:
|
||||
// - *Chunk: Updated chunk
|
||||
// - error: Error information
|
||||
func (c *Client) UpdateChunk(ctx context.Context,
|
||||
knowledgeID string, chunkID string, request *UpdateChunkRequest,
|
||||
) (*Chunk, error) {
|
||||
path := fmt.Sprintf("/api/v1/chunks/%s/%s", knowledgeID, chunkID)
|
||||
resp, err := c.doRequest(ctx, http.MethodPut, path, request, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response ChunkResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &response.Data, nil
|
||||
}
|
||||
|
||||
// DeleteChunk deletes a specific chunk
|
||||
// Deletes a specific chunk under a knowledge document
|
||||
// Parameters:
|
||||
// - ctx: Context
|
||||
// - knowledgeID: Knowledge ID
|
||||
// - chunkID: Chunk ID
|
||||
//
|
||||
// Returns:
|
||||
// - error: Error information
|
||||
func (c *Client) DeleteChunk(ctx context.Context, knowledgeID string, chunkID string) error {
|
||||
path := fmt.Sprintf("/api/v1/chunks/%s/%s", knowledgeID, chunkID)
|
||||
resp, err := c.doRequest(ctx, http.MethodDelete, path, nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
return parseResponse(resp, &response)
|
||||
}
|
||||
|
||||
// GetChunkByIDOnly retrieves a chunk by its ID without requiring knowledge ID
|
||||
func (c *Client) GetChunkByIDOnly(ctx context.Context, chunkID string) (*Chunk, error) {
|
||||
path := fmt.Sprintf("/api/v1/chunks/by-id/%s", chunkID)
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, path, nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response ChunkResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &response.Data, nil
|
||||
}
|
||||
|
||||
// DeleteGeneratedQuestion deletes a generated question from a chunk
|
||||
func (c *Client) DeleteGeneratedQuestion(ctx context.Context, chunkID string, questionID string) error {
|
||||
path := fmt.Sprintf("/api/v1/chunks/%s/delete-question", chunkID)
|
||||
req := map[string]string{"question_id": questionID}
|
||||
resp, err := c.doRequest(ctx, http.MethodDelete, path, req, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
return parseResponse(resp, &response)
|
||||
}
|
||||
|
||||
// DeleteChunksByKnowledgeID deletes all chunks under a knowledge document
|
||||
// Batch deletes all chunks under the specified knowledge document
|
||||
// Parameters:
|
||||
// - ctx: Context
|
||||
// - knowledgeID: Knowledge ID
|
||||
//
|
||||
// Returns:
|
||||
// - error: Error information
|
||||
func (c *Client) DeleteChunksByKnowledgeID(ctx context.Context, knowledgeID string) error {
|
||||
path := fmt.Sprintf("/api/v1/chunks/%s", knowledgeID)
|
||||
resp, err := c.doRequest(ctx, http.MethodDelete, path, nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
return parseResponse(resp, &response)
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
// Package client provides the implementation for interacting with the WeKnora API
|
||||
// This package encapsulates CRUD operations for server resources and provides a friendly interface for callers
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Client is the client for interacting with the WeKnora service.
|
||||
//
|
||||
// Authentication uses one of two credential kinds:
|
||||
// - API key (long-lived, set via X-API-Key) — see WithAPIKey
|
||||
// - Bearer JWT (short-lived, set via Authorization)— see WithBearerToken
|
||||
//
|
||||
// Both may be configured simultaneously; X-API-Key takes precedence at the
|
||||
// HTTP layer. The legacy WithToken is kept as an alias for WithAPIKey for two
|
||||
// minor versions of compatibility.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
// streamTimeout is zero by default so long-lived SSE responses are not
|
||||
// severed by the ordinary request timeout. An explicit WithTimeout option
|
||||
// sets both timeouts, preserving the public option's all-request semantics.
|
||||
streamTimeout time.Duration
|
||||
apiKey string
|
||||
bearerToken string
|
||||
tenantID *uint64
|
||||
}
|
||||
|
||||
// ClientOption defines client configuration options
|
||||
type ClientOption func(*Client)
|
||||
|
||||
// WithTimeout sets the timeout for ordinary and streaming HTTP requests.
|
||||
// Streaming requests have no timeout by default; callers that set this option
|
||||
// explicitly are asking for the same upper bound to apply to SSE streams.
|
||||
func WithTimeout(timeout time.Duration) ClientOption {
|
||||
return func(c *Client) {
|
||||
c.httpClient.Timeout = timeout
|
||||
c.streamTimeout = timeout
|
||||
}
|
||||
}
|
||||
|
||||
// WithTransport overrides the underlying http.RoundTripper on the SDK's
|
||||
// HTTP client. The default Timeout (and any WithTimeout override) is
|
||||
// preserved. Intended for callers (the CLI's authretry layer; metrics or
|
||||
// signing middleware) that want to wrap the transport without replacing
|
||||
// the whole http.Client.
|
||||
//
|
||||
// Passing nil restores http.DefaultTransport.
|
||||
func WithTransport(rt http.RoundTripper) ClientOption {
|
||||
return func(c *Client) {
|
||||
c.httpClient.Transport = rt
|
||||
}
|
||||
}
|
||||
|
||||
// WithAPIKey sets the long-lived API key sent as the X-API-Key header.
|
||||
func WithAPIKey(key string) ClientOption {
|
||||
return func(c *Client) {
|
||||
c.apiKey = key
|
||||
}
|
||||
}
|
||||
|
||||
// WithBearerToken sets the JWT bearer token sent as the Authorization header.
|
||||
// Used after a successful auth.login to call authenticated endpoints with the
|
||||
// short-lived access token.
|
||||
func WithBearerToken(token string) ClientOption {
|
||||
return func(c *Client) {
|
||||
c.bearerToken = token
|
||||
}
|
||||
}
|
||||
|
||||
// WithToken is the v0.x compatibility alias for WithAPIKey. Prefer WithAPIKey
|
||||
// (or WithBearerToken for JWT). Will be removed in the next major; the alias
|
||||
// is preserved for two minor versions per ADR.
|
||||
//
|
||||
// Deprecated: use WithAPIKey for X-API-Key, WithBearerToken for JWT.
|
||||
func WithToken(token string) ClientOption {
|
||||
return WithAPIKey(token)
|
||||
}
|
||||
|
||||
// WithTenantID sets X-Tenant-ID on every request. Use only for explicit
|
||||
// cross-tenant access by callers with CanAccessAllTenants — the server's
|
||||
// auth middleware runs the cross-tenant gate whenever this header is
|
||||
// present on a bearer request, even when the value matches the credential's
|
||||
// own tenant, and 403s normal users. JWT bearer tokens and tenant-scoped
|
||||
// API keys carry tenant identity intrinsically, so the header is redundant
|
||||
// (and harmful) for default-tenant traffic. Per-request override via the
|
||||
// "TenantID" context value still applies.
|
||||
func WithTenantID(tenantID uint64) ClientOption {
|
||||
return func(c *Client) {
|
||||
c.tenantID = &tenantID
|
||||
}
|
||||
}
|
||||
|
||||
// NewClient creates a new client instance
|
||||
func NewClient(baseURL string, options ...ClientOption) *Client {
|
||||
client := &Client{
|
||||
baseURL: baseURL,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
|
||||
for _, option := range options {
|
||||
option(client)
|
||||
}
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
// buildRequest constructs the authenticated *http.Request shared by doRequest
|
||||
// and doRequestStream: serializes the JSON body, composes URL + query, and
|
||||
// applies auth headers.
|
||||
func (c *Client) buildRequest(ctx context.Context,
|
||||
method, path string, body interface{}, query url.Values,
|
||||
) (*http.Request, error) {
|
||||
var reqBody io.Reader
|
||||
if body != nil {
|
||||
jsonData, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to serialize request body: %w", err)
|
||||
}
|
||||
reqBody = bytes.NewBuffer(jsonData)
|
||||
}
|
||||
|
||||
u := fmt.Sprintf("%s%s", c.baseURL, path)
|
||||
if len(query) > 0 {
|
||||
u = fmt.Sprintf("%s?%s", u, query.Encode())
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, u, reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
c.applyAuthHeaders(ctx, req)
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// doRequest executes an HTTP request subject to the client's blanket Timeout
|
||||
// (default 30s). Use for ordinary request/response calls.
|
||||
func (c *Client) doRequest(ctx context.Context,
|
||||
method, path string, body interface{}, query url.Values,
|
||||
) (*http.Response, error) {
|
||||
req, err := c.buildRequest(ctx, method, path, body, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.httpClient.Do(req)
|
||||
}
|
||||
|
||||
// doRequestStream executes a streaming (SSE) request without the client's
|
||||
// default 30-second blanket Timeout. http.Client.Timeout covers reading the
|
||||
// response body, so applying that default would sever long-running chat /
|
||||
// session-ask / continue-stream responses. Stream lifetime is governed by ctx
|
||||
// unless the caller explicitly supplied WithTimeout, in which case that upper
|
||||
// bound is preserved. The Transport is shared with c.httpClient so connection
|
||||
// pooling is unaffected.
|
||||
func (c *Client) doRequestStream(ctx context.Context,
|
||||
method, path string, body interface{}, query url.Values,
|
||||
) (*http.Response, error) {
|
||||
req, err := c.buildRequest(ctx, method, path, body, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sc := *c.httpClient // shallow copy: shares Transport, override Timeout
|
||||
sc.Timeout = c.streamTimeout
|
||||
return sc.Do(req)
|
||||
}
|
||||
|
||||
// applyAuthHeaders sets X-API-Key, Authorization, X-Request-ID, and X-Tenant-ID
|
||||
// on req based on client config and ctx values. Used by doRequest and any
|
||||
// caller that builds its own *http.Request (currently CreateKnowledgeFromFile,
|
||||
// which uses multipart and can't go through doRequest).
|
||||
func (c *Client) applyAuthHeaders(ctx context.Context, req *http.Request) {
|
||||
if c.apiKey != "" {
|
||||
req.Header.Set("X-API-Key", c.apiKey)
|
||||
}
|
||||
if c.bearerToken != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.bearerToken)
|
||||
}
|
||||
if requestID := ctx.Value("RequestID"); requestID != nil {
|
||||
if s, ok := requestID.(string); ok {
|
||||
req.Header.Set("X-Request-ID", s)
|
||||
}
|
||||
}
|
||||
|
||||
tenantID := c.tenantID
|
||||
if ctxTenant := ctx.Value("TenantID"); ctxTenant != nil {
|
||||
switch v := ctxTenant.(type) {
|
||||
case *uint64:
|
||||
if v != nil {
|
||||
tenantID = v
|
||||
}
|
||||
case uint64:
|
||||
tenantID = &v
|
||||
case string:
|
||||
if parsed, err := strconv.ParseUint(v, 10, 64); err == nil {
|
||||
tenantID = &parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
if tenantID != nil {
|
||||
req.Header.Set("X-Tenant-ID", strconv.FormatUint(*tenantID, 10))
|
||||
}
|
||||
}
|
||||
|
||||
// Raw performs a raw HTTP request against the WeKnora API with the client's
|
||||
// auth headers, X-Request-ID, and X-Tenant-ID injection applied.
|
||||
//
|
||||
// Experimental: this method is intended for one-off integrations and the
|
||||
// `weknora api` CLI passthrough. The signature, return type, and behavior may
|
||||
// change in any minor version. Prefer typed methods (ListKnowledgeBases,
|
||||
// GetKnowledgeBase, etc.) when they exist.
|
||||
func (c *Client) Raw(ctx context.Context, method, path string, body interface{}) (*http.Response, error) {
|
||||
return c.doRequest(ctx, method, path, body, nil)
|
||||
}
|
||||
|
||||
// APIError is a typed non-2xx HTTP response from the WeKnora API. Recover it
|
||||
// with errors.As to branch on the HTTP status or the server's structured error
|
||||
// code without parsing the Error() string:
|
||||
//
|
||||
// var apiErr *client.APIError
|
||||
// if errors.As(err, &apiErr) && apiErr.StatusCode == 404 { ... }
|
||||
//
|
||||
// Error() intentionally preserves the legacy "HTTP error <status>: <body>"
|
||||
// format so existing string-matching consumers keep working unchanged.
|
||||
type APIError struct {
|
||||
StatusCode int // HTTP status (401, 404, 409, 429, 500, …)
|
||||
Body string // raw response body
|
||||
Code int // server's structured error code ({"code":N}); 0 when absent/non-JSON
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
return fmt.Sprintf("HTTP error %d: %s", e.StatusCode, e.Body)
|
||||
}
|
||||
|
||||
// Server error codes are the values of the {"code":N} field in an error
|
||||
// response body, surfaced as APIError.Code. These mirror the generic codes in
|
||||
// the server's internal/errors/errors.go so consumers branch on a named
|
||||
// constant instead of a magic number. Domain-specific codes (2xxx+) are
|
||||
// omitted until a consumer needs to branch on one.
|
||||
const (
|
||||
ServerErrBadRequest = 1000
|
||||
ServerErrUnauthorized = 1001
|
||||
ServerErrForbidden = 1002
|
||||
ServerErrNotFound = 1003
|
||||
ServerErrMethodNotAllowed = 1004
|
||||
ServerErrConflict = 1005
|
||||
ServerErrTooManyRequests = 1006
|
||||
ServerErrInternalServer = 1007
|
||||
ServerErrServiceUnavailable = 1008
|
||||
ServerErrTimeout = 1009
|
||||
ServerErrValidation = 1010
|
||||
)
|
||||
|
||||
// newAPIError builds an *APIError from a non-2xx response's status and raw body,
|
||||
// extracting the server's structured {"code":N} when the body is JSON.
|
||||
func newAPIError(status int, body []byte) *APIError {
|
||||
return &APIError{StatusCode: status, Body: string(body), Code: extractServerCode(body)}
|
||||
}
|
||||
|
||||
// extractServerCode pulls the server's structured error code from a JSON error
|
||||
// body ({"code":1003,...}). Returns 0 when the body isn't JSON or carries no code.
|
||||
func extractServerCode(body []byte) int {
|
||||
var env struct {
|
||||
Code int `json:"code"`
|
||||
}
|
||||
if json.Unmarshal(body, &env) == nil {
|
||||
return env.Code
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// parseResponse parses an HTTP response
|
||||
func parseResponse(resp *http.Response, target interface{}) error {
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return newAPIError(resp.StatusCode, body)
|
||||
}
|
||||
|
||||
if target == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return json.NewDecoder(resp.Body).Decode(target)
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Environment variable sources, as reported by ListMyEnvVars. The three states
|
||||
// are distinct: a value the workspace filled in for everybody and one the
|
||||
// caller filled in for themselves lead to different actions, and neither is
|
||||
// "nobody has filled this in".
|
||||
const (
|
||||
EnvSourceUnset = "unset"
|
||||
EnvSourceWorkspace = "workspace"
|
||||
EnvSourceUser = "user"
|
||||
)
|
||||
|
||||
// EnvVarView is one environment variable as its owner may see it. There is
|
||||
// deliberately no value field: the API never reads a stored value back out.
|
||||
type EnvVarView struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Required bool `json:"required,omitempty"`
|
||||
// Source is one of EnvSourceUnset, EnvSourceWorkspace or EnvSourceUser,
|
||||
// and reports which layer an execution would actually take the value from.
|
||||
Source string `json:"source"`
|
||||
// UpdatedAt is set only for the caller's own value.
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
// SkillEnvGroup is one skill's declared credentials.
|
||||
type SkillEnvGroup struct {
|
||||
SkillID string `json:"skill_id"`
|
||||
SkillName string `json:"skill_name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Vars []EnvVarView `json:"vars"`
|
||||
}
|
||||
|
||||
// ConfigEnvGroup is one sandbox config: the caller's own config-wide variables
|
||||
// plus the declared credentials of the skills installed on it.
|
||||
type ConfigEnvGroup struct {
|
||||
SandboxConfigID string `json:"sandbox_config_id"`
|
||||
SandboxConfigName string `json:"sandbox_config_name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Vars []EnvVarView `json:"vars"`
|
||||
Skills []SkillEnvGroup `json:"skills"`
|
||||
}
|
||||
|
||||
type myEnvVarsResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data []ConfigEnvGroup `json:"data"`
|
||||
}
|
||||
|
||||
// envVarRequest is the body every write endpoint takes. It carries no identity:
|
||||
// the server derives whose values are touched from the credentials on the
|
||||
// request, so one caller can never write another's.
|
||||
type envVarRequest struct {
|
||||
SkillID string `json:"skill_id,omitempty"`
|
||||
SandboxConfigID string `json:"sandbox_config_id,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value,omitempty"`
|
||||
}
|
||||
|
||||
// ListMyEnvVars returns every sandbox config of the workspace with the caller's
|
||||
// own config-wide variables and the credentials its skills declared, each
|
||||
// reporting whether it is unset, filled in workspace-wide, or filled in by the
|
||||
// caller. Values are never returned.
|
||||
//
|
||||
// Values are stored per calling identity, not per user: a run driven by an API
|
||||
// key does not see values entered through the web UI, and vice versa. Use
|
||||
// SetSandboxSkillEnvValues for a credential that should apply to everybody.
|
||||
func (c *Client) ListMyEnvVars(ctx context.Context) ([]ConfigEnvGroup, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, "/api/v1/me/env-vars", nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var response myEnvVarsResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response.Data, nil
|
||||
}
|
||||
|
||||
// SetMySkillEnvVar stores the caller's own value for one variable a skill
|
||||
// declared. It overrides the workspace-wide value for this caller only, and is
|
||||
// injected only into executions that name the skill. A name the skill did not
|
||||
// declare is refused.
|
||||
func (c *Client) SetMySkillEnvVar(ctx context.Context, skillID, name, value string) error {
|
||||
if skillID == "" {
|
||||
return fmt.Errorf("skill ID is required")
|
||||
}
|
||||
if name == "" {
|
||||
return fmt.Errorf("environment variable name is required")
|
||||
}
|
||||
body := envVarRequest{SkillID: skillID, Name: name, Value: value}
|
||||
resp, err := c.doRequest(ctx, http.MethodPut, "/api/v1/me/env-vars/skill", body, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return parseResponse(resp, nil)
|
||||
}
|
||||
|
||||
// DeleteMySkillEnvVar removes the caller's own value. The workspace-wide value,
|
||||
// if there is one, applies again afterwards. Deleting a value that was never
|
||||
// set returns a 404. Clearing is a delete rather than a write of "", so there
|
||||
// is one unambiguous way to revoke.
|
||||
func (c *Client) DeleteMySkillEnvVar(ctx context.Context, skillID, name string) error {
|
||||
if skillID == "" {
|
||||
return fmt.Errorf("skill ID is required")
|
||||
}
|
||||
if name == "" {
|
||||
return fmt.Errorf("environment variable name is required")
|
||||
}
|
||||
body := envVarRequest{SkillID: skillID, Name: name}
|
||||
resp, err := c.doRequest(ctx, http.MethodDelete, "/api/v1/me/env-vars/skill", body, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return parseResponse(resp, nil)
|
||||
}
|
||||
|
||||
// SetMySandboxEnvVar stores the caller's own value for one config-wide
|
||||
// variable. Unlike a skill variable the name is free-form, and the value is
|
||||
// injected into every skill script and shell command this caller runs on that
|
||||
// sandbox config. Names starting with WEKNORA_, and names the sandbox relies on
|
||||
// such as PATH, are refused.
|
||||
func (c *Client) SetMySandboxEnvVar(ctx context.Context, configID, name, value string) error {
|
||||
if configID == "" {
|
||||
return fmt.Errorf("sandbox config ID is required")
|
||||
}
|
||||
if name == "" {
|
||||
return fmt.Errorf("environment variable name is required")
|
||||
}
|
||||
body := envVarRequest{SandboxConfigID: configID, Name: name, Value: value}
|
||||
resp, err := c.doRequest(ctx, http.MethodPut, "/api/v1/me/env-vars/sandbox", body, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return parseResponse(resp, nil)
|
||||
}
|
||||
|
||||
// DeleteMySandboxEnvVar removes the caller's own config-wide variable.
|
||||
func (c *Client) DeleteMySandboxEnvVar(ctx context.Context, configID, name string) error {
|
||||
if configID == "" {
|
||||
return fmt.Errorf("sandbox config ID is required")
|
||||
}
|
||||
if name == "" {
|
||||
return fmt.Errorf("environment variable name is required")
|
||||
}
|
||||
body := envVarRequest{SandboxConfigID: configID, Name: name}
|
||||
resp, err := c.doRequest(ctx, http.MethodDelete, "/api/v1/me/env-vars/sandbox", body, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return parseResponse(resp, nil)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// Package client provides the implementation for interacting with the WeKnora API
|
||||
// The Evaluation related interfaces are used for starting and retrieving model evaluation task results
|
||||
// Evaluation tasks can be used to measure model performance and
|
||||
// compare different embedding models, chat models, and reranking models
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// EvaluationTask represents an evaluation task
|
||||
// Contains basic information about a model evaluation task
|
||||
type EvaluationTask struct {
|
||||
ID string `json:"id"` // Task unique identifier
|
||||
Status string `json:"status"` // Task status: pending, running, completed, failed
|
||||
Progress int `json:"progress"` // Task progress, integer value 0-100
|
||||
DatasetID string `json:"dataset_id"` // Evaluation dataset ID
|
||||
EmbeddingID string `json:"embedding_id"` // Embedding model ID
|
||||
ChatID string `json:"chat_id"` // Chat model ID
|
||||
RerankID string `json:"rerank_id"` // Reranking model ID
|
||||
CreatedAt string `json:"created_at"` // Task creation time
|
||||
CompleteAt string `json:"complete_at"` // Task completion time
|
||||
ErrorMsg string `json:"error_msg"` // Error message, has value when task fails
|
||||
}
|
||||
|
||||
// EvaluationResult represents the evaluation results
|
||||
// Contains detailed evaluation result information
|
||||
type EvaluationResult struct {
|
||||
TaskID string `json:"task_id"` // Associated task ID
|
||||
Status string `json:"status"` // Task status
|
||||
Progress int `json:"progress"` // Task progress
|
||||
TotalQueries int `json:"total_queries"` // Total number of queries
|
||||
TotalSamples int `json:"total_samples"` // Total number of samples
|
||||
Metrics map[string]float64 `json:"metrics"` // Evaluation metrics collection
|
||||
QueriesStat []map[string]interface{} `json:"queries_stat"` // Statistics for each query
|
||||
CreatedAt string `json:"created_at"` // Creation time
|
||||
CompleteAt string `json:"complete_at"` // Completion time
|
||||
ErrorMsg string `json:"error_msg"` // Error message
|
||||
}
|
||||
|
||||
// EvaluationRequest represents an evaluation request
|
||||
// Parameters used to start a new evaluation task
|
||||
type EvaluationRequest struct {
|
||||
DatasetID string `json:"dataset_id"` // Dataset ID to evaluate
|
||||
EmbeddingModelID string `json:"embedding_id"` // Embedding model ID
|
||||
ChatModelID string `json:"chat_id"` // Chat model ID
|
||||
RerankModelID string `json:"rerank_id"` // Reranking model ID
|
||||
}
|
||||
|
||||
// EvaluationTaskResponse represents an evaluation task response
|
||||
// API response structure for evaluation tasks
|
||||
type EvaluationTaskResponse struct {
|
||||
Success bool `json:"success"` // Whether operation was successful
|
||||
Data EvaluationTask `json:"data"` // Evaluation task data
|
||||
}
|
||||
|
||||
// EvaluationResultResponse represents an evaluation result response
|
||||
// API response structure for evaluation results
|
||||
type EvaluationResultResponse struct {
|
||||
Success bool `json:"success"` // Whether operation was successful
|
||||
Data EvaluationResult `json:"data"` // Evaluation result data
|
||||
}
|
||||
|
||||
// StartEvaluation starts an evaluation task
|
||||
// Creates and starts a new evaluation task based on provided parameters
|
||||
// Parameters:
|
||||
// - ctx: Context, used for passing request context information such as deadline, cancellation signals, etc.
|
||||
// - request: Evaluation request parameters, including dataset ID and model IDs
|
||||
//
|
||||
// Returns:
|
||||
// - *EvaluationTask: Created evaluation task information
|
||||
// - error: Error information if the request fails
|
||||
func (c *Client) StartEvaluation(ctx context.Context, request *EvaluationRequest) (*EvaluationTask, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, "/api/v1/evaluation", request, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response EvaluationTaskResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &response.Data, nil
|
||||
}
|
||||
|
||||
// GetEvaluationResult retrieves evaluation results
|
||||
// Retrieves detailed results for an evaluation task by task ID
|
||||
// Parameters:
|
||||
// - ctx: Context, used for passing request context information
|
||||
// - taskID: Evaluation task ID, used to identify the specific evaluation task to query
|
||||
//
|
||||
// Returns:
|
||||
// - *EvaluationResult: Detailed evaluation task results
|
||||
// - error: Error information if the request fails
|
||||
func (c *Client) GetEvaluationResult(ctx context.Context, taskID string) (*EvaluationResult, error) {
|
||||
queryParams := url.Values{}
|
||||
queryParams.Add("task_id", taskID)
|
||||
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, "/api/v1/evaluation", nil, queryParams)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response EvaluationResultResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &response.Data, nil
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ExampleUsage demonstrates the complete usage flow of the WeKnora client, including:
|
||||
// - Creating a client instance
|
||||
// - Creating a knowledge base
|
||||
// - Uploading knowledge files
|
||||
// - Creating a session
|
||||
// - Performing question-answering
|
||||
// - Using streaming question-answering
|
||||
// - Managing models
|
||||
// - Managing knowledge chunks
|
||||
// - Getting session messages
|
||||
// - Cleaning up resources
|
||||
func ExampleUsage() {
|
||||
// Create a client instance
|
||||
tenantID := uint64(10000) // default tenant for all requests from this client
|
||||
apiClient := NewClient(
|
||||
"http://localhost:8080",
|
||||
WithToken("your-auth-token"),
|
||||
WithTimeout(30*time.Second),
|
||||
WithTenantID(tenantID), // default tenant for all requests from this client
|
||||
)
|
||||
|
||||
// Per-request tenant override example:
|
||||
// You can override the tenant for a single request by setting the "TenantID" value in the context.
|
||||
// e.g. ctx := context.WithValue(context.Background(), "TenantID", &tenantID)
|
||||
// then pass `ctx` to any client method to use tenant 2 for that request only.
|
||||
|
||||
// 1. Create a knowledge base
|
||||
fmt.Println("1. Creating knowledge base...")
|
||||
kb := &KnowledgeBase{
|
||||
Name: "Test Knowledge Base",
|
||||
Description: "This is a test knowledge base",
|
||||
ChunkingConfig: ChunkingConfig{
|
||||
ChunkSize: 500,
|
||||
ChunkOverlap: 50,
|
||||
Separators: []string{"\n\n", "\n", ". ", "? ", "! "},
|
||||
},
|
||||
ImageProcessingConfig: ImageProcessingConfig{
|
||||
ModelID: "image_model_id",
|
||||
},
|
||||
EmbeddingModelID: "embedding_model_id",
|
||||
SummaryModelID: "summary_model_id",
|
||||
}
|
||||
|
||||
createdKB, err := apiClient.CreateKnowledgeBase(context.Background(), kb)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to create knowledge base: %v\n", err)
|
||||
return
|
||||
}
|
||||
fmt.Printf("Knowledge base created successfully: ID=%s, Name=%s\n", createdKB.ID, createdKB.Name)
|
||||
|
||||
// 2. Upload knowledge file
|
||||
fmt.Println("\n2. Uploading knowledge file...")
|
||||
filePath := "path/to/sample.pdf" // Sample file path
|
||||
|
||||
// Check if file exists before uploading
|
||||
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||||
fmt.Printf("File does not exist: %s, skipping upload step\n", filePath)
|
||||
} else {
|
||||
// Add metadata
|
||||
metadata := map[string]string{
|
||||
"source": "local",
|
||||
"type": "document",
|
||||
}
|
||||
knowledge, err := apiClient.CreateKnowledgeFromFile(context.Background(), createdKB.ID, filePath, metadata, nil, "", "", nil)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to upload knowledge file: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("File uploaded successfully: Knowledge ID=%s, Title=%s\n", knowledge.ID, knowledge.Title)
|
||||
}
|
||||
}
|
||||
|
||||
// Create text knowledge (alternative to file upload)
|
||||
// Note: This is just an example, the client package may not support creating text knowledge directly
|
||||
// In actual use, refer to the methods provided in client.knowledge.go
|
||||
fmt.Println("\nCreating text knowledge (example)")
|
||||
fmt.Println("Title: Test Text Knowledge")
|
||||
fmt.Println("Description: Test knowledge created from text")
|
||||
|
||||
// 3. Create a model
|
||||
fmt.Println("\n3. Creating model...")
|
||||
modelRequest := &CreateModelRequest{
|
||||
Name: "Test Model",
|
||||
Type: ModelTypeKnowledgeQA,
|
||||
Source: ModelSourceLocal,
|
||||
Description: "This is a test model",
|
||||
Parameters: ModelParameters{
|
||||
"temperature": 0.7,
|
||||
"top_p": 0.9,
|
||||
},
|
||||
IsDefault: true,
|
||||
}
|
||||
|
||||
model, err := apiClient.CreateModel(context.Background(), modelRequest)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to create model: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("Model created successfully: ID=%s, Name=%s\n", model.ID, model.Name)
|
||||
}
|
||||
|
||||
// List all models
|
||||
models, err := apiClient.ListModels(context.Background())
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to get model list: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("System has %d models\n", len(models))
|
||||
}
|
||||
|
||||
// 4. Create a session
|
||||
fmt.Println("\n4. Creating session...")
|
||||
sessionRequest := &CreateSessionRequest{
|
||||
Title: "Test Session",
|
||||
Description: "A test session for knowledge Q&A",
|
||||
}
|
||||
|
||||
session, err := apiClient.CreateSession(context.Background(), sessionRequest)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to create session: %v\n", err)
|
||||
return
|
||||
}
|
||||
fmt.Printf("Session created successfully: ID=%s\n", session.ID)
|
||||
|
||||
// 5. Perform knowledge Q&A (using streaming API)
|
||||
fmt.Println("\n5. Performing knowledge Q&A...")
|
||||
question := "What is artificial intelligence?"
|
||||
fmt.Printf("Question: %s\nAnswer: ", question)
|
||||
|
||||
// Use streaming API for Q&A (Note: Client may only provide streaming Q&A API)
|
||||
var answer strings.Builder
|
||||
var references []*SearchResult
|
||||
|
||||
err = apiClient.KnowledgeQAStream(context.Background(),
|
||||
session.ID,
|
||||
&KnowledgeQARequest{Query: question},
|
||||
func(response *StreamResponse) error {
|
||||
if response.ResponseType == ResponseTypeAnswer {
|
||||
answer.WriteString(response.Content)
|
||||
}
|
||||
|
||||
if response.Done && len(response.KnowledgeReferences) > 0 {
|
||||
references = response.KnowledgeReferences
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("Q&A failed: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("%s\n", answer.String())
|
||||
if len(references) > 0 {
|
||||
fmt.Println("References:")
|
||||
for i, ref := range references {
|
||||
fmt.Printf("%d. %s\n", i+1, ref.Content[:min(50, len(ref.Content))]+"...")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Perform another streaming Q&A
|
||||
fmt.Println("\n6. Performing streaming Q&A...")
|
||||
streamQuestion := "What is machine learning?"
|
||||
fmt.Printf("Question: %s\nAnswer: ", streamQuestion)
|
||||
|
||||
err = apiClient.KnowledgeQAStream(context.Background(),
|
||||
session.ID,
|
||||
&KnowledgeQARequest{Query: streamQuestion},
|
||||
func(response *StreamResponse) error {
|
||||
fmt.Print(response.Content)
|
||||
return nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Printf("\nStreaming Q&A failed: %v\n", err)
|
||||
}
|
||||
fmt.Println() // Line break
|
||||
|
||||
// 7. Get session messages
|
||||
fmt.Println("\n7. Getting session messages...")
|
||||
messages, err := apiClient.GetRecentMessages(context.Background(), session.ID, 10)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to get session messages: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("Retrieved %d recent messages:\n", len(messages))
|
||||
for i, msg := range messages {
|
||||
fmt.Printf("%d. Role: %s, Content: %s\n", i+1, msg.Role, msg.Content[:min(30, len(msg.Content))]+"...")
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Manage knowledge chunks
|
||||
// Assume we have uploaded knowledge and have a knowledge ID
|
||||
knowledgeID := "knowledge_id_example" // In actual use, use a real knowledge ID
|
||||
|
||||
fmt.Println("\n8. Managing knowledge chunks...")
|
||||
chunks, total, err := apiClient.ListKnowledgeChunks(context.Background(), knowledgeID, 1, 10)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to get knowledge chunks: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("Knowledge has %d chunks, retrieved %d chunks\n", total, len(chunks))
|
||||
|
||||
if len(chunks) > 0 {
|
||||
// Update the first chunk
|
||||
chunkID := chunks[0].ID
|
||||
updateRequest := &UpdateChunkRequest{
|
||||
Content: "Updated chunk content - " + chunks[0].Content,
|
||||
IsEnabled: true,
|
||||
}
|
||||
|
||||
updatedChunk, err := apiClient.UpdateChunk(context.Background(), knowledgeID, chunkID, updateRequest)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to update chunk: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("Chunk updated successfully: ID=%s\n", updatedChunk.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 10. Clean up resources (optional, in actual use, keep or delete as needed)
|
||||
fmt.Println("\n10. Cleaning up resources...")
|
||||
if session != nil {
|
||||
if err := apiClient.DeleteSession(context.Background(), session.ID); err != nil {
|
||||
fmt.Printf("Failed to delete session: %v\n", err)
|
||||
} else {
|
||||
fmt.Println("Session deleted")
|
||||
}
|
||||
}
|
||||
|
||||
// Delete knowledge (assuming we have a valid knowledge ID)
|
||||
if knowledgeID != "" {
|
||||
if err := apiClient.DeleteKnowledge(context.Background(), knowledgeID); err != nil {
|
||||
fmt.Printf("Failed to delete knowledge: %v\n", err)
|
||||
} else {
|
||||
fmt.Println("Knowledge deleted")
|
||||
}
|
||||
}
|
||||
|
||||
if createdKB != nil {
|
||||
if err := apiClient.DeleteKnowledgeBase(context.Background(), createdKB.ID); err != nil {
|
||||
fmt.Printf("Failed to delete knowledge base: %v\n", err)
|
||||
} else {
|
||||
fmt.Println("Knowledge base deleted")
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("\nExample completed")
|
||||
}
|
||||
|
||||
// min returns the smaller of two integers
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
+468
@@ -0,0 +1,468 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FAQEntry represents a FAQ item stored under a knowledge base.
|
||||
type FAQEntry struct {
|
||||
ID int64 `json:"id"`
|
||||
ChunkID string `json:"chunk_id"`
|
||||
KnowledgeID string `json:"knowledge_id"`
|
||||
KnowledgeBaseID string `json:"knowledge_base_id"`
|
||||
TagID int64 `json:"tag_id"`
|
||||
TagName string `json:"tag_name"`
|
||||
IsEnabled bool `json:"is_enabled"`
|
||||
IsRecommended bool `json:"is_recommended"`
|
||||
StandardQuestion string `json:"standard_question"`
|
||||
SimilarQuestions []string `json:"similar_questions"`
|
||||
NegativeQuestions []string `json:"negative_questions"`
|
||||
Answers []string `json:"answers"`
|
||||
AnswerStrategy string `json:"answer_strategy"`
|
||||
IndexMode string `json:"index_mode"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Score float64 `json:"score,omitempty"`
|
||||
MatchType string `json:"match_type,omitempty"`
|
||||
ChunkType string `json:"chunk_type"`
|
||||
// MatchedQuestion is the actual question text that was matched in FAQ search
|
||||
// Could be the standard question or one of the similar questions
|
||||
MatchedQuestion string `json:"matched_question,omitempty"`
|
||||
}
|
||||
|
||||
// FAQEntryPayload is used to create or update a FAQ entry.
|
||||
type FAQEntryPayload struct {
|
||||
// ID is optional, used for data migration to specify seq_id (must be less than auto-increment start value 100000000)
|
||||
ID *int64 `json:"id,omitempty"`
|
||||
StandardQuestion string `json:"standard_question"`
|
||||
SimilarQuestions []string `json:"similar_questions,omitempty"`
|
||||
NegativeQuestions []string `json:"negative_questions,omitempty"`
|
||||
Answers []string `json:"answers"`
|
||||
AnswerStrategy *string `json:"answer_strategy,omitempty"`
|
||||
TagID int64 `json:"tag_id,omitempty"`
|
||||
TagName string `json:"tag_name,omitempty"`
|
||||
IsEnabled *bool `json:"is_enabled,omitempty"`
|
||||
IsRecommended *bool `json:"is_recommended,omitempty"`
|
||||
}
|
||||
|
||||
// FAQBatchUpsertPayload represents the request body for batch import (append/replace).
|
||||
type FAQBatchUpsertPayload struct {
|
||||
Entries []FAQEntryPayload `json:"entries"`
|
||||
Mode string `json:"mode"`
|
||||
KnowledgeID string `json:"knowledge_id,omitempty"`
|
||||
TaskID string `json:"task_id,omitempty"` // Optional, if not provided, a UUID will be generated
|
||||
DryRun bool `json:"dry_run,omitempty"` // If true, only validate without importing
|
||||
}
|
||||
|
||||
// FAQEntryFieldsUpdate represents the fields that can be updated for a single FAQ entry.
|
||||
type FAQEntryFieldsUpdate struct {
|
||||
IsEnabled *bool `json:"is_enabled,omitempty"`
|
||||
IsRecommended *bool `json:"is_recommended,omitempty"`
|
||||
TagID *int64 `json:"tag_id,omitempty"`
|
||||
}
|
||||
|
||||
// FAQEntryFieldsBatchRequest updates multiple fields for FAQ entries in bulk.
|
||||
// Supports two modes:
|
||||
// 1. By entry ID: use ByID field
|
||||
// 2. By Tag: use ByTag field to apply the same update to all entries under a tag
|
||||
type FAQEntryFieldsBatchRequest struct {
|
||||
// ByID updates by entry ID (seq_id), key is entry seq_id
|
||||
ByID map[int64]FAQEntryFieldsUpdate `json:"by_id,omitempty"`
|
||||
// ByTag updates all entries under a tag, key is tag seq_id (0 for uncategorized)
|
||||
ByTag map[int64]FAQEntryFieldsUpdate `json:"by_tag,omitempty"`
|
||||
// ExcludeIDs IDs (seq_id) to exclude from the ByTag update
|
||||
ExcludeIDs []int64 `json:"exclude_ids,omitempty"`
|
||||
}
|
||||
|
||||
// FAQEntryTagBatchRequest updates tags in bulk.
|
||||
// key: entry seq_id, value: tag seq_id (nil to remove tag)
|
||||
type FAQEntryTagBatchRequest struct {
|
||||
Updates map[int64]*int64 `json:"updates"`
|
||||
}
|
||||
|
||||
// FAQDeleteRequest deletes entries in bulk.
|
||||
type FAQDeleteRequest struct {
|
||||
IDs []int64 `json:"ids"`
|
||||
}
|
||||
|
||||
// FAQSearchRequest represents the hybrid FAQ search request.
|
||||
type FAQSearchRequest struct {
|
||||
QueryText string `json:"query_text"`
|
||||
VectorThreshold float64 `json:"vector_threshold"`
|
||||
MatchCount int `json:"match_count"`
|
||||
FirstPriorityTagIDs []int64 `json:"first_priority_tag_ids"` // First priority tag seq_ids, highest priority
|
||||
SecondPriorityTagIDs []int64 `json:"second_priority_tag_ids"` // Second priority tag seq_ids, lower than first
|
||||
OnlyRecommended bool `json:"only_recommended"` // Only return recommended entries
|
||||
}
|
||||
|
||||
// FAQEntriesPage contains paginated FAQ results.
|
||||
type FAQEntriesPage struct {
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Entries []FAQEntry `json:"data"`
|
||||
}
|
||||
|
||||
// FAQEntriesResponse wraps the paginated FAQ response.
|
||||
type FAQEntriesResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data *FAQEntriesPage `json:"data"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Code string `json:"code,omitempty"`
|
||||
}
|
||||
|
||||
// FAQUpsertResponse wraps the asynchronous import response.
|
||||
type FAQUpsertResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data *FAQTaskPayload `json:"data"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Code string `json:"code,omitempty"`
|
||||
}
|
||||
|
||||
// FAQTaskPayload carries the task identifier for async imports.
|
||||
type FAQTaskPayload struct {
|
||||
TaskID string `json:"task_id"`
|
||||
}
|
||||
|
||||
// FAQSearchResponse wraps the hybrid FAQ search results.
|
||||
type FAQSearchResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data []FAQEntry `json:"data"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Code string `json:"code,omitempty"`
|
||||
}
|
||||
|
||||
// FAQEntryResponse wraps the single FAQ entry creation response.
|
||||
type FAQEntryResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data *FAQEntry `json:"data"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Code string `json:"code,omitempty"`
|
||||
}
|
||||
|
||||
type faqSimpleResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Code string `json:"code,omitempty"`
|
||||
}
|
||||
|
||||
// ListFAQEntries returns paginated FAQ entries under a knowledge base.
|
||||
// tagSeqID: filter by tag seq_id (0 means no filter)
|
||||
// searchField: specifies which field to search in ("standard_question", "similar_questions", "answers", "" for all)
|
||||
// sortOrder: "asc" for time ascending (updated_at ASC), default is time descending (updated_at DESC)
|
||||
func (c *Client) ListFAQEntries(ctx context.Context,
|
||||
knowledgeBaseID string, page, pageSize int, tagSeqID int64, keyword string, searchField string, sortOrder string,
|
||||
) (*FAQEntriesPage, error) {
|
||||
path := fmt.Sprintf("/api/v1/knowledge-bases/%s/faq/entries", knowledgeBaseID)
|
||||
query := url.Values{}
|
||||
if page > 0 {
|
||||
query.Add("page", strconv.Itoa(page))
|
||||
}
|
||||
if pageSize > 0 {
|
||||
query.Add("page_size", strconv.Itoa(pageSize))
|
||||
}
|
||||
if tagSeqID != 0 {
|
||||
query.Add("tag_id", strconv.FormatInt(tagSeqID, 10))
|
||||
}
|
||||
if keyword != "" {
|
||||
query.Add("keyword", keyword)
|
||||
}
|
||||
if searchField != "" {
|
||||
query.Add("search_field", searchField)
|
||||
}
|
||||
if sortOrder != "" {
|
||||
query.Add("sort_order", sortOrder)
|
||||
}
|
||||
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, path, nil, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response FAQEntriesResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if response.Data == nil {
|
||||
return &FAQEntriesPage{}, nil
|
||||
}
|
||||
return response.Data, nil
|
||||
}
|
||||
|
||||
// UpsertFAQEntries imports or appends FAQ entries asynchronously and returns the task ID.
|
||||
func (c *Client) UpsertFAQEntries(ctx context.Context,
|
||||
knowledgeBaseID string, payload *FAQBatchUpsertPayload,
|
||||
) (string, error) {
|
||||
path := fmt.Sprintf("/api/v1/knowledge-bases/%s/faq/entries", knowledgeBaseID)
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, path, payload, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var response FAQUpsertResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if response.Data == nil {
|
||||
return "", fmt.Errorf("missing task information in response")
|
||||
}
|
||||
return response.Data.TaskID, nil
|
||||
}
|
||||
|
||||
// CreateFAQEntry creates a single FAQ entry synchronously.
|
||||
func (c *Client) CreateFAQEntry(ctx context.Context,
|
||||
knowledgeBaseID string, payload *FAQEntryPayload,
|
||||
) (*FAQEntry, error) {
|
||||
path := fmt.Sprintf("/api/v1/knowledge-bases/%s/faq/entry", knowledgeBaseID)
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, path, payload, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response FAQEntryResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response.Data, nil
|
||||
}
|
||||
|
||||
// GetFAQEntry retrieves a single FAQ entry by seq_id.
|
||||
func (c *Client) GetFAQEntry(ctx context.Context,
|
||||
knowledgeBaseID string, entrySeqID int64,
|
||||
) (*FAQEntry, error) {
|
||||
path := fmt.Sprintf("/api/v1/knowledge-bases/%s/faq/entries/%d", knowledgeBaseID, entrySeqID)
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, path, nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response FAQEntryResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response.Data, nil
|
||||
}
|
||||
|
||||
// UpdateFAQEntry updates a single FAQ entry.
|
||||
func (c *Client) UpdateFAQEntry(ctx context.Context,
|
||||
knowledgeBaseID string, entrySeqID int64, payload *FAQEntryPayload,
|
||||
) (*FAQEntry, error) {
|
||||
path := fmt.Sprintf("/api/v1/knowledge-bases/%s/faq/entries/%d", knowledgeBaseID, entrySeqID)
|
||||
resp, err := c.doRequest(ctx, http.MethodPut, path, payload, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response FAQEntryResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response.Data, nil
|
||||
}
|
||||
|
||||
// AddSimilarQuestionsPayload is used to add similar questions to a FAQ entry.
|
||||
type AddSimilarQuestionsPayload struct {
|
||||
SimilarQuestions []string `json:"similar_questions"`
|
||||
}
|
||||
|
||||
// AddSimilarQuestions adds similar questions to a FAQ entry.
|
||||
// This will append the new questions to the existing similar questions list.
|
||||
func (c *Client) AddSimilarQuestions(ctx context.Context,
|
||||
knowledgeBaseID string, entrySeqID int64, payload *AddSimilarQuestionsPayload,
|
||||
) (*FAQEntry, error) {
|
||||
path := fmt.Sprintf("/api/v1/knowledge-bases/%s/faq/entries/%d/similar-questions", knowledgeBaseID, entrySeqID)
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, path, payload, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response FAQEntryResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response.Data, nil
|
||||
}
|
||||
|
||||
// UpdateFAQEntryFieldsBatch updates multiple fields for FAQ entries in bulk.
|
||||
// Supports updating is_enabled, is_recommended, tag_id in a single call.
|
||||
// Supports two modes:
|
||||
// - byID: update by entry seq_id, key is entry seq_id
|
||||
// - byTag: update all entries under a tag, key is tag seq_id (0 for uncategorized)
|
||||
func (c *Client) UpdateFAQEntryFieldsBatch(ctx context.Context,
|
||||
knowledgeBaseID string, byID map[int64]FAQEntryFieldsUpdate, byTag map[int64]FAQEntryFieldsUpdate, excludeIDs []int64,
|
||||
) error {
|
||||
path := fmt.Sprintf("/api/v1/knowledge-bases/%s/faq/entries/fields", knowledgeBaseID)
|
||||
resp, err := c.doRequest(ctx, http.MethodPut, path, &FAQEntryFieldsBatchRequest{ByID: byID, ByTag: byTag, ExcludeIDs: excludeIDs}, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var response faqSimpleResponse
|
||||
return parseResponse(resp, &response)
|
||||
}
|
||||
|
||||
// UpdateFAQEntryTagBatch updates FAQ entry tags in bulk.
|
||||
// key: entry seq_id, value: tag seq_id (nil to remove tag)
|
||||
func (c *Client) UpdateFAQEntryTagBatch(ctx context.Context,
|
||||
knowledgeBaseID string, updates map[int64]*int64,
|
||||
) error {
|
||||
path := fmt.Sprintf("/api/v1/knowledge-bases/%s/faq/entries/tags", knowledgeBaseID)
|
||||
resp, err := c.doRequest(ctx, http.MethodPut, path, &FAQEntryTagBatchRequest{Updates: updates}, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var response faqSimpleResponse
|
||||
return parseResponse(resp, &response)
|
||||
}
|
||||
|
||||
// DeleteFAQEntries deletes FAQ entries in bulk by seq_id.
|
||||
func (c *Client) DeleteFAQEntries(ctx context.Context,
|
||||
knowledgeBaseID string, ids []int64,
|
||||
) error {
|
||||
path := fmt.Sprintf("/api/v1/knowledge-bases/%s/faq/entries", knowledgeBaseID)
|
||||
resp, err := c.doRequest(ctx, http.MethodDelete, path, &FAQDeleteRequest{IDs: ids}, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var response faqSimpleResponse
|
||||
return parseResponse(resp, &response)
|
||||
}
|
||||
|
||||
// SearchFAQEntries performs hybrid FAQ search inside a knowledge base.
|
||||
func (c *Client) SearchFAQEntries(ctx context.Context,
|
||||
knowledgeBaseID string, payload *FAQSearchRequest,
|
||||
) ([]FAQEntry, error) {
|
||||
path := fmt.Sprintf("/api/v1/knowledge-bases/%s/faq/search", knowledgeBaseID)
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, path, payload, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response FAQSearchResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return response.Data, nil
|
||||
}
|
||||
|
||||
// ExportFAQEntries exports all FAQ entries from a knowledge base as CSV data.
|
||||
// The CSV format matches the import example format with 8 columns:
|
||||
// 分类(必填), 问题(必填), 相似问题(选填-多个用##分隔), 反例问题(选填-多个用##分隔),
|
||||
// 机器人回答(必填-多个用##分隔), 是否全部回复(选填-默认FALSE), 是否停用(选填-默认FALSE),
|
||||
// 是否禁止被推荐(选填-默认False 可被推荐)
|
||||
func (c *Client) ExportFAQEntries(ctx context.Context, knowledgeBaseID string) ([]byte, error) {
|
||||
path := fmt.Sprintf("/api/v1/knowledge-bases/%s/faq/entries/export", knowledgeBaseID)
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, path, nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read the raw CSV data from response body
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read export response: %w", err)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// FAQFailedEntry represents a failed entry during FAQ import/validation.
|
||||
type FAQFailedEntry struct {
|
||||
Index int `json:"index"`
|
||||
Reason string `json:"reason"`
|
||||
TagName string `json:"tag_name,omitempty"`
|
||||
StandardQuestion string `json:"standard_question"`
|
||||
SimilarQuestions []string `json:"similar_questions,omitempty"`
|
||||
NegativeQuestions []string `json:"negative_questions,omitempty"`
|
||||
Answers []string `json:"answers,omitempty"`
|
||||
AnswerAll bool `json:"answer_all,omitempty"`
|
||||
IsDisabled bool `json:"is_disabled,omitempty"`
|
||||
}
|
||||
|
||||
// FAQSuccessEntry represents a successfully imported FAQ entry.
|
||||
type FAQSuccessEntry struct {
|
||||
Index int `json:"index"` // Entry index in the batch (0-based)
|
||||
SeqID int64 `json:"seq_id"` // Entry sequence ID after import
|
||||
TagID int64 `json:"tag_id,omitempty"` // Tag ID (seq_id)
|
||||
TagName string `json:"tag_name,omitempty"` // Tag name
|
||||
StandardQuestion string `json:"standard_question"` // Standard question
|
||||
}
|
||||
|
||||
// FAQImportProgress represents the progress of an async FAQ import task.
|
||||
// When Status is "completed", the result fields (SkippedCount, ImportMode, ImportedAt, DisplayStatus, ProcessingTime) are populated.
|
||||
type FAQImportProgress struct {
|
||||
TaskID string `json:"task_id"`
|
||||
KBID string `json:"kb_id"`
|
||||
KnowledgeID string `json:"knowledge_id"`
|
||||
Status string `json:"status"`
|
||||
Progress int `json:"progress"`
|
||||
Total int `json:"total"`
|
||||
Processed int `json:"processed"`
|
||||
SuccessCount int `json:"success_count"`
|
||||
FailedCount int `json:"failed_count"`
|
||||
SkippedCount int `json:"skipped_count,omitempty"`
|
||||
FailedEntries []FAQFailedEntry `json:"failed_entries,omitempty"`
|
||||
SuccessEntries []FAQSuccessEntry `json:"success_entries,omitempty"` // Successfully imported entries (when count is small)
|
||||
FailedEntriesURL string `json:"failed_entries_url,omitempty"` // CSV download URL when too many failures
|
||||
Message string `json:"message"`
|
||||
Error string `json:"error,omitempty"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
DryRun bool `json:"dry_run,omitempty"` // Whether this is a dry run validation
|
||||
|
||||
// Result fields (populated when Status == "completed")
|
||||
ImportMode string `json:"import_mode,omitempty"`
|
||||
ImportedAt time.Time `json:"imported_at,omitempty"`
|
||||
DisplayStatus string `json:"display_status,omitempty"`
|
||||
ProcessingTime int64 `json:"processing_time,omitempty"`
|
||||
}
|
||||
|
||||
// FAQImportProgressResponse wraps the FAQ import progress response.
|
||||
type FAQImportProgressResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data *FAQImportProgress `json:"data"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Code string `json:"code,omitempty"`
|
||||
}
|
||||
|
||||
// GetFAQImportProgress retrieves the progress of an async FAQ import task.
|
||||
// This works for both regular imports and dry run validations.
|
||||
func (c *Client) GetFAQImportProgress(ctx context.Context, taskID string) (*FAQImportProgress, error) {
|
||||
path := fmt.Sprintf("/api/v1/faq/import/progress/%s", taskID)
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, path, nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response FAQImportProgressResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response.Data, nil
|
||||
}
|
||||
|
||||
type updateLastFAQImportResultDisplayStatusRequest struct {
|
||||
DisplayStatus string `json:"display_status"`
|
||||
}
|
||||
|
||||
// UpdateLastFAQImportResultDisplayStatus updates the display status (open/close) of the last FAQ import result.
|
||||
func (c *Client) UpdateLastFAQImportResultDisplayStatus(ctx context.Context, knowledgeBaseID string, displayStatus string) error {
|
||||
path := fmt.Sprintf("/api/v1/knowledge-bases/%s/faq/import/last-result/display", knowledgeBaseID)
|
||||
resp, err := c.doRequest(ctx, http.MethodPut, path, &updateLastFAQImportResultDisplayStatusRequest{DisplayStatus: displayStatus}, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var response faqSimpleResponse
|
||||
return parseResponse(resp, &response)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module github.com/Tencent/WeKnora/client
|
||||
|
||||
go 1.24.2
|
||||
@@ -0,0 +1,346 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// InitializationConfig is the WRITE payload for InitializeByKB / UpdateKBConfig
|
||||
// (the server's write endpoint accepts these flat model ids). It is NOT the
|
||||
// shape the read endpoint returns — see KBModelConfigView / GetInitializationConfig.
|
||||
type InitializationConfig struct {
|
||||
ChatModelID string `json:"chat_model_id,omitempty"`
|
||||
EmbeddingModelID string `json:"embedding_model_id,omitempty"`
|
||||
RerankModelID string `json:"rerank_model_id,omitempty"`
|
||||
MultimodalID string `json:"multimodal_id,omitempty"`
|
||||
}
|
||||
|
||||
// KBModelConfigView is the secret-free, read-only model configuration of a
|
||||
// knowledge base, returned by GetInitializationConfig. The server's read
|
||||
// response nests config under embedding/llm/rerank/multimodal and INCLUDES
|
||||
// provider apiKey/baseUrl (for the web config form); this view intentionally
|
||||
// parses only the non-secret fields, so credentials can never leak through the
|
||||
// CLI. Field tags are snake_case (the CLI envelope convention), remapped from
|
||||
// the server's camelCase.
|
||||
type KBModelConfigView struct {
|
||||
RetrievalReady bool `json:"retrieval_ready"` // embedding model bound → KB can embed/retrieve
|
||||
Embedding ModelSlotView `json:"embedding"`
|
||||
LLM ModelSlotView `json:"llm"`
|
||||
Rerank RerankSlotView `json:"rerank"`
|
||||
Multimodal MultimodalSlotView `json:"multimodal"`
|
||||
}
|
||||
|
||||
// ModelSlotView is one non-secret model slot (embedding / llm).
|
||||
type ModelSlotView struct {
|
||||
Configured bool `json:"configured"`
|
||||
ModelName string `json:"model_name,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Dimension int `json:"dimension,omitempty"`
|
||||
}
|
||||
|
||||
// RerankSlotView is the rerank slot (may be disabled).
|
||||
type RerankSlotView struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
ModelName string `json:"model_name,omitempty"`
|
||||
}
|
||||
|
||||
// MultimodalSlotView reports whether multimodal processing is enabled.
|
||||
type MultimodalSlotView struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// OllamaModelInfo represents info about an Ollama model
|
||||
type OllamaModelInfo struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
ModifiedAt string `json:"modified_at"`
|
||||
}
|
||||
|
||||
// DownloadTask represents an Ollama model download task
|
||||
type DownloadTask struct {
|
||||
ID string `json:"id"`
|
||||
ModelName string `json:"modelName"`
|
||||
Status string `json:"status"`
|
||||
Progress float64 `json:"progress"`
|
||||
Message string `json:"message"`
|
||||
StartTime time.Time `json:"startTime"`
|
||||
EndTime *time.Time `json:"endTime,omitempty"`
|
||||
}
|
||||
|
||||
// ModelCheckResult represents the result of checking a remote model
|
||||
type ModelCheckResult struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// GetInitializationConfig returns a knowledge base's model configuration as a
|
||||
// secret-free KBModelConfigView. The server response nests config under
|
||||
// embedding/llm/rerank/multimodal and includes provider apiKey/baseUrl; this
|
||||
// parses ONLY the non-secret fields (apiKey/baseUrl are never read into the
|
||||
// struct, so they cannot leak through the CLI) and remaps to snake_case.
|
||||
func (c *Client) GetInitializationConfig(ctx context.Context, kbID string) (*KBModelConfigView, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, fmt.Sprintf("/api/v1/initialization/config/%s", kbID), nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Deliberately model only non-secret fields; apiKey / baseUrl in the server
|
||||
// payload are ignored by omission.
|
||||
var result struct {
|
||||
Data struct {
|
||||
Embedding struct {
|
||||
Source string `json:"source"`
|
||||
ModelName string `json:"modelName"`
|
||||
Dimension int `json:"dimension"`
|
||||
} `json:"embedding"`
|
||||
LLM struct {
|
||||
Source string `json:"source"`
|
||||
ModelName string `json:"modelName"`
|
||||
} `json:"llm"`
|
||||
Rerank struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
ModelName string `json:"modelName"`
|
||||
} `json:"rerank"`
|
||||
Multimodal struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
} `json:"multimodal"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := parseResponse(resp, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d := result.Data
|
||||
view := &KBModelConfigView{
|
||||
RetrievalReady: d.Embedding.ModelName != "",
|
||||
Embedding: ModelSlotView{Configured: d.Embedding.ModelName != "", ModelName: d.Embedding.ModelName, Source: d.Embedding.Source, Dimension: d.Embedding.Dimension},
|
||||
LLM: ModelSlotView{Configured: d.LLM.ModelName != "", ModelName: d.LLM.ModelName, Source: d.LLM.Source},
|
||||
Rerank: RerankSlotView{Enabled: d.Rerank.Enabled, ModelName: d.Rerank.ModelName},
|
||||
Multimodal: MultimodalSlotView{Enabled: d.Multimodal.Enabled},
|
||||
}
|
||||
return view, nil
|
||||
}
|
||||
|
||||
// InitializeByKB initializes a knowledge base with model configuration
|
||||
func (c *Client) InitializeByKB(ctx context.Context, kbID string, config *InitializationConfig) error {
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, fmt.Sprintf("/api/v1/initialization/initialize/%s", kbID), config, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return parseResponse(resp, nil)
|
||||
}
|
||||
|
||||
// UpdateKBConfig updates the model configuration for a knowledge base.
|
||||
//
|
||||
// Deprecated: the PUT /initialization/config endpoint binds KBModelConfigRequest
|
||||
// (fields llmModelId / embeddingModelId), not InitializationConfig, so this
|
||||
// method sends a shape the server rejects. Use SetKBModelConfig instead.
|
||||
func (c *Client) UpdateKBConfig(ctx context.Context, kbID string, config *InitializationConfig) error {
|
||||
resp, err := c.doRequest(ctx, http.MethodPut, fmt.Sprintf("/api/v1/initialization/config/%s", kbID), config, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return parseResponse(resp, nil)
|
||||
}
|
||||
|
||||
// KBModelConfig points a knowledge base at already-registered models. Field
|
||||
// names match the server's KBModelConfigRequest (PUT
|
||||
// /initialization/config/:kbId). LLMModelID is required server-side;
|
||||
// EmbeddingModelID is optional (omitted when RAG indexing is disabled).
|
||||
type KBModelConfig struct {
|
||||
LLMModelID string `json:"llmModelId"`
|
||||
EmbeddingModelID string `json:"embeddingModelId,omitempty"`
|
||||
}
|
||||
|
||||
// SetKBModelConfig binds a knowledge base to already-registered models via PUT
|
||||
// /initialization/config/:kbId. Register models first with CreateModel; the
|
||||
// server rejects unknown model ids and refuses to change the embedding model of
|
||||
// a KB that already has documents.
|
||||
func (c *Client) SetKBModelConfig(ctx context.Context, kbID string, cfg *KBModelConfig) error {
|
||||
resp, err := c.doRequest(ctx, http.MethodPut, fmt.Sprintf("/api/v1/initialization/config/%s", kbID), cfg, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return parseResponse(resp, nil)
|
||||
}
|
||||
|
||||
// CheckOllamaStatus checks if Ollama is running and accessible
|
||||
func (c *Client) CheckOllamaStatus(ctx context.Context) (bool, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, "/api/v1/initialization/ollama/status", nil, nil)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
var result struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Available bool `json:"available"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := parseResponse(resp, &result); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return result.Data.Available, nil
|
||||
}
|
||||
|
||||
// ListOllamaModels lists all locally available Ollama models
|
||||
func (c *Client) ListOllamaModels(ctx context.Context) ([]OllamaModelInfo, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, "/api/v1/initialization/ollama/models", nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result struct {
|
||||
Success bool `json:"success"`
|
||||
Data []OllamaModelInfo `json:"data"`
|
||||
}
|
||||
if err := parseResponse(resp, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.Data, nil
|
||||
}
|
||||
|
||||
// CheckOllamaModels checks if specific Ollama models are available
|
||||
func (c *Client) CheckOllamaModels(ctx context.Context, models []string) (map[string]bool, error) {
|
||||
req := map[string][]string{"models": models}
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, "/api/v1/initialization/ollama/models/check", req, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result struct {
|
||||
Success bool `json:"success"`
|
||||
Data map[string]bool `json:"data"`
|
||||
}
|
||||
if err := parseResponse(resp, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.Data, nil
|
||||
}
|
||||
|
||||
// DownloadOllamaModel starts downloading an Ollama model
|
||||
func (c *Client) DownloadOllamaModel(ctx context.Context, modelName string) (*DownloadTask, error) {
|
||||
req := map[string]string{"model": modelName}
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, "/api/v1/initialization/ollama/models/download", req, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result struct {
|
||||
Success bool `json:"success"`
|
||||
Data *DownloadTask `json:"data"`
|
||||
}
|
||||
if err := parseResponse(resp, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.Data, nil
|
||||
}
|
||||
|
||||
// GetOllamaDownloadProgress gets the download progress of an Ollama model
|
||||
func (c *Client) GetOllamaDownloadProgress(ctx context.Context, taskID string) (*DownloadTask, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, fmt.Sprintf("/api/v1/initialization/ollama/download/progress/%s", taskID), nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result struct {
|
||||
Success bool `json:"success"`
|
||||
Data *DownloadTask `json:"data"`
|
||||
}
|
||||
if err := parseResponse(resp, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.Data, nil
|
||||
}
|
||||
|
||||
// ListOllamaDownloadTasks lists all Ollama download tasks
|
||||
func (c *Client) ListOllamaDownloadTasks(ctx context.Context) ([]*DownloadTask, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, "/api/v1/initialization/ollama/download/tasks", nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result struct {
|
||||
Success bool `json:"success"`
|
||||
Data []*DownloadTask `json:"data"`
|
||||
}
|
||||
if err := parseResponse(resp, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.Data, nil
|
||||
}
|
||||
|
||||
// CheckRemoteModel checks if a remote model API is accessible
|
||||
func (c *Client) CheckRemoteModel(ctx context.Context, params map[string]string) (*ModelCheckResult, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, "/api/v1/initialization/remote/check", params, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result struct {
|
||||
Success bool `json:"success"`
|
||||
Data *ModelCheckResult `json:"data"`
|
||||
}
|
||||
if err := parseResponse(resp, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.Data, nil
|
||||
}
|
||||
|
||||
// TestEmbeddingModel tests an embedding model
|
||||
func (c *Client) TestEmbeddingModel(ctx context.Context, params map[string]string) (*ModelCheckResult, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, "/api/v1/initialization/embedding/test", params, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result struct {
|
||||
Success bool `json:"success"`
|
||||
Data *ModelCheckResult `json:"data"`
|
||||
}
|
||||
if err := parseResponse(resp, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.Data, nil
|
||||
}
|
||||
|
||||
// CheckRerankModel checks if a rerank model is accessible
|
||||
func (c *Client) CheckRerankModel(ctx context.Context, params map[string]string) (*ModelCheckResult, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, "/api/v1/initialization/rerank/check", params, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result struct {
|
||||
Success bool `json:"success"`
|
||||
Data *ModelCheckResult `json:"data"`
|
||||
}
|
||||
if err := parseResponse(resp, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.Data, nil
|
||||
}
|
||||
|
||||
// TestMultimodalFunction tests multimodal model functionality
|
||||
func (c *Client) TestMultimodalFunction(ctx context.Context, params map[string]string) (*ModelCheckResult, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, "/api/v1/initialization/multimodal/test", params, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result struct {
|
||||
Success bool `json:"success"`
|
||||
Data *ModelCheckResult `json:"data"`
|
||||
}
|
||||
if err := parseResponse(resp, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.Data, nil
|
||||
}
|
||||
|
||||
// ExtractTextRelations extracts text relations for knowledge graph
|
||||
func (c *Client) ExtractTextRelations(ctx context.Context, params any) (json.RawMessage, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, "/api/v1/initialization/extract/text-relation", params, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result struct {
|
||||
Success bool `json:"success"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
if err := parseResponse(resp, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.Data, nil
|
||||
}
|
||||
@@ -0,0 +1,977 @@
|
||||
// Package client provides the implementation for interacting with the WeKnora API
|
||||
// The Knowledge related interfaces are used to manage knowledge entries in the knowledge base
|
||||
// Knowledge entries can be created from local files, web URLs, or directly from text content
|
||||
// They can also be retrieved, deleted, and downloaded as files
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Knowledge represents knowledge information
|
||||
type Knowledge struct {
|
||||
ID string `json:"id"`
|
||||
TenantID uint64 `json:"tenant_id"`
|
||||
KnowledgeBaseID string `json:"knowledge_base_id"`
|
||||
TagID string `json:"tag_id"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Source string `json:"source"`
|
||||
Channel string `json:"channel"`
|
||||
ParseStatus string `json:"parse_status"`
|
||||
SummaryStatus string `json:"summary_status"`
|
||||
EnableStatus string `json:"enable_status"`
|
||||
EmbeddingModelID string `json:"embedding_model_id"`
|
||||
FileName string `json:"file_name"`
|
||||
FolderPath string `json:"folder_path"`
|
||||
FileType string `json:"file_type"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
FileHash string `json:"file_hash"`
|
||||
FilePath string `json:"file_path"`
|
||||
StorageSize int64 `json:"storage_size"`
|
||||
Metadata json.RawMessage `json:"metadata"` // Extensible metadata for storing machine information, paths, etc.
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ProcessedAt *time.Time `json:"processed_at"`
|
||||
ErrorMessage string `json:"error_message"`
|
||||
}
|
||||
|
||||
// KnowledgeResponse represents the API response containing a single knowledge entry
|
||||
type KnowledgeResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data Knowledge `json:"data"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// KnowledgeListResponse represents the API response containing a list of knowledge entries with pagination
|
||||
type KnowledgeListResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data []Knowledge `json:"data"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
|
||||
// KnowledgeBatchResponse represents the API response for batch knowledge retrieval
|
||||
type KnowledgeBatchResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data []Knowledge `json:"data"`
|
||||
}
|
||||
|
||||
// UpdateImageInfoRequest represents the request structure for updating a chunk
|
||||
// Used for requesting chunk information updates
|
||||
type UpdateImageInfoRequest struct {
|
||||
ImageInfo string `json:"image_info"` // Image information in JSON format
|
||||
}
|
||||
|
||||
// ErrDuplicateFile is returned when attempting to create a knowledge entry with a file that already exists
|
||||
var ErrDuplicateFile = errors.New("file already exists")
|
||||
|
||||
// ErrDuplicateURL is returned when attempting to create a knowledge entry with a URL that already exists
|
||||
var ErrDuplicateURL = errors.New("URL already exists")
|
||||
|
||||
// KnowledgeProcessOverrides stores per-upload parse config overrides sent as process_config.
|
||||
// When nil, the server uses the knowledge base defaults only.
|
||||
type KnowledgeProcessOverrides struct {
|
||||
ParserEngineRules []ParserEngineRule `json:"parser_engine_rules,omitempty"`
|
||||
ChunkingConfig *ChunkingConfig `json:"chunking_config,omitempty"`
|
||||
EnableMultimodel *bool `json:"enable_multimodel,omitempty"`
|
||||
VLMConfig *VLMConfig `json:"vlm_config,omitempty"`
|
||||
ASRConfig *ASRConfig `json:"asr_config,omitempty"`
|
||||
QuestionGenerationConfig *QuestionGenerationConfig `json:"question_generation_config,omitempty"`
|
||||
GraphEnabled *bool `json:"graph_enabled,omitempty"`
|
||||
ExtractConfig *ExtractConfig `json:"extract_config,omitempty"`
|
||||
}
|
||||
|
||||
// CreateKnowledgeFromFile creates a knowledge entry from a local file path
|
||||
// Parameters:
|
||||
// - knowledgeBaseID: The ID of the knowledge base
|
||||
// - filePath: The local file path
|
||||
// - metadata: Optional metadata for the knowledge entry
|
||||
// - enableMultimodel: Optional flag to enable multimodal processing
|
||||
// - customFileName: Optional custom file name (useful for folder uploads with path)
|
||||
// - channel: Optional ingestion channel (e.g. "web", "api", "wechat"); empty defaults to "web"
|
||||
// - processConfig: Optional parse config overrides (serialized as process_config form field)
|
||||
func (c *Client) CreateKnowledgeFromFile(ctx context.Context,
|
||||
knowledgeBaseID string, filePath string, metadata map[string]string, enableMultimodel *bool, customFileName string, channel string,
|
||||
processConfig *KnowledgeProcessOverrides,
|
||||
) (*Knowledge, error) {
|
||||
// Open the local file
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Get file information
|
||||
fileInfo, err := file.Stat()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get file information: %w", err)
|
||||
}
|
||||
|
||||
// Create the HTTP request
|
||||
path := fmt.Sprintf("/api/v1/knowledge-bases/%s/knowledge/file", knowledgeBaseID)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
// Create a multipart form writer
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
part, err := writer.CreateFormFile("file", fileInfo.Name())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create form file: %w", err)
|
||||
}
|
||||
|
||||
// Copy file contents
|
||||
_, err = io.Copy(part, file)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to copy file content: %w", err)
|
||||
}
|
||||
|
||||
// Add enable_multimodel field
|
||||
if enableMultimodel != nil {
|
||||
if err := writer.WriteField("enable_multimodel", strconv.FormatBool(*enableMultimodel)); err != nil {
|
||||
return nil, fmt.Errorf("failed to write enable_multimodel field: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Add metadata to the request if provided
|
||||
if metadata != nil {
|
||||
metadataBytes, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to serialize metadata: %w", err)
|
||||
}
|
||||
if err := writer.WriteField("metadata", string(metadataBytes)); err != nil {
|
||||
return nil, fmt.Errorf("failed to write metadata field: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Add custom file name if provided
|
||||
if customFileName != "" {
|
||||
if err := writer.WriteField("fileName", customFileName); err != nil {
|
||||
return nil, fmt.Errorf("failed to write fileName field: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if channel != "" {
|
||||
if err := writer.WriteField("channel", channel); err != nil {
|
||||
return nil, fmt.Errorf("failed to write channel field: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if processConfig != nil {
|
||||
processConfigBytes, err := json.Marshal(processConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to serialize process_config: %w", err)
|
||||
}
|
||||
if err := writer.WriteField("process_config", string(processConfigBytes)); err != nil {
|
||||
return nil, fmt.Errorf("failed to write process_config field: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Close the multipart writer
|
||||
err = writer.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to close writer: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
c.applyAuthHeaders(ctx, req)
|
||||
|
||||
// Set the request body
|
||||
req.Body = io.NopCloser(body)
|
||||
|
||||
// Send the request
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to send request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Parse the response
|
||||
var response KnowledgeResponse
|
||||
if resp.StatusCode == http.StatusConflict {
|
||||
if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
return &response.Data, ErrDuplicateFile
|
||||
} else if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &response.Data, nil
|
||||
}
|
||||
|
||||
// CreateKnowledgeFromURLRequest contains the parameters for creating a knowledge entry from a URL.
|
||||
// When FileName or FileType is provided (or the URL path has a known file extension such as .pdf/.docx/.doc/.txt/.md),
|
||||
// the server automatically switches to file-download mode instead of web-page crawling.
|
||||
type CreateKnowledgeFromURLRequest struct {
|
||||
// URL is the target URL (required)
|
||||
URL string `json:"url"`
|
||||
// FileName is the optional file name; used to hint file-download mode when URL has no extension
|
||||
FileName string `json:"file_name,omitempty"`
|
||||
// FileType is the optional file type (e.g. "pdf"); used to hint file-download mode
|
||||
FileType string `json:"file_type,omitempty"`
|
||||
// EnableMultimodel is the optional flag to enable multimodal processing
|
||||
EnableMultimodel *bool `json:"enable_multimodel,omitempty"`
|
||||
// Title is the optional title for the knowledge entry
|
||||
Title string `json:"title,omitempty"`
|
||||
// TagID is the optional tag ID to associate with the knowledge entry
|
||||
TagID string `json:"tag_id,omitempty"`
|
||||
// Channel identifies the ingestion channel (e.g. "web", "browser_extension", "api")
|
||||
Channel string `json:"channel,omitempty"`
|
||||
// ProcessConfig is optional per-upload parse config overrides (KnowledgeProcessOverrides).
|
||||
ProcessConfig *KnowledgeProcessOverrides `json:"process_config,omitempty"`
|
||||
}
|
||||
|
||||
// CreateKnowledgeFromURL creates a knowledge entry from a URL.
|
||||
// When req.FileName or req.FileType is provided (or the URL path has a known file extension),
|
||||
// the server automatically switches to file-download mode instead of web-page crawling.
|
||||
func (c *Client) CreateKnowledgeFromURL(
|
||||
ctx context.Context,
|
||||
knowledgeBaseID string,
|
||||
req CreateKnowledgeFromURLRequest,
|
||||
) (*Knowledge, error) {
|
||||
path := fmt.Sprintf("/api/v1/knowledge-bases/%s/knowledge/url", knowledgeBaseID)
|
||||
|
||||
reqBody := req
|
||||
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, path, reqBody, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response KnowledgeResponse
|
||||
if resp.StatusCode == http.StatusConflict {
|
||||
if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
return &response.Data, ErrDuplicateURL
|
||||
} else if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &response.Data, nil
|
||||
}
|
||||
|
||||
// GetKnowledge retrieves a knowledge entry by its ID
|
||||
func (c *Client) GetKnowledge(ctx context.Context, knowledgeID string) (*Knowledge, error) {
|
||||
path := fmt.Sprintf("/api/v1/knowledge/%s", knowledgeID)
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, path, nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response KnowledgeResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &response.Data, nil
|
||||
}
|
||||
|
||||
// GetKnowledgeBatch retrieves multiple knowledge entries by their IDs
|
||||
func (c *Client) GetKnowledgeBatch(ctx context.Context, knowledgeIDs []string) ([]Knowledge, error) {
|
||||
path := "/api/v1/knowledge/batch"
|
||||
|
||||
queryParams := url.Values{}
|
||||
for _, id := range knowledgeIDs {
|
||||
queryParams.Add("ids", id)
|
||||
}
|
||||
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, path, nil, queryParams)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response KnowledgeBatchResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return response.Data, nil
|
||||
}
|
||||
|
||||
// ListKnowledge lists knowledge entries in a knowledge base with pagination.
|
||||
// For richer filtering (keyword, file type, parse status, source, time range)
|
||||
// use ListKnowledgeWithFilter.
|
||||
func (c *Client) ListKnowledge(ctx context.Context,
|
||||
knowledgeBaseID string,
|
||||
page int,
|
||||
pageSize int,
|
||||
tagID string,
|
||||
) ([]Knowledge, int64, error) {
|
||||
return c.ListKnowledgeWithFilter(ctx, knowledgeBaseID, page, pageSize, KnowledgeListFilter{TagID: tagID})
|
||||
}
|
||||
|
||||
// KnowledgeListFilter mirrors the server-side filters accepted by GET
|
||||
// /api/v1/knowledge-bases/{id}/knowledge. Empty / zero fields are omitted from
|
||||
// the request. FolderPath, when non-nil, selects a folder scope; the empty
|
||||
// string means the knowledge base root. Omit FolderPath entirely for a flat
|
||||
// listing across every folder.
|
||||
type KnowledgeListFilter struct {
|
||||
TagID string
|
||||
Keyword string
|
||||
FileType string
|
||||
ParseStatus string
|
||||
Source string
|
||||
// StartTime / EndTime filter on knowledge updated_at. Zero values are skipped.
|
||||
// They are serialized in RFC3339 format.
|
||||
StartTime time.Time
|
||||
EndTime time.Time
|
||||
FolderPath *string
|
||||
FolderRecursive bool
|
||||
}
|
||||
|
||||
// ListKnowledgeWithFilter lists knowledge entries with the full filter surface.
|
||||
func (c *Client) ListKnowledgeWithFilter(ctx context.Context,
|
||||
knowledgeBaseID string,
|
||||
page int,
|
||||
pageSize int,
|
||||
filter KnowledgeListFilter,
|
||||
) ([]Knowledge, int64, error) {
|
||||
path := fmt.Sprintf("/api/v1/knowledge-bases/%s/knowledge", knowledgeBaseID)
|
||||
|
||||
queryParams := url.Values{}
|
||||
queryParams.Add("page", strconv.Itoa(page))
|
||||
queryParams.Add("page_size", strconv.Itoa(pageSize))
|
||||
if filter.TagID != "" {
|
||||
queryParams.Add("tag_id", filter.TagID)
|
||||
}
|
||||
if filter.Keyword != "" {
|
||||
queryParams.Add("keyword", filter.Keyword)
|
||||
}
|
||||
if filter.FileType != "" {
|
||||
queryParams.Add("file_type", filter.FileType)
|
||||
}
|
||||
if filter.ParseStatus != "" {
|
||||
queryParams.Add("parse_status", filter.ParseStatus)
|
||||
}
|
||||
if filter.Source != "" {
|
||||
queryParams.Add("source", filter.Source)
|
||||
}
|
||||
if !filter.StartTime.IsZero() {
|
||||
queryParams.Add("start_time", filter.StartTime.Format(time.RFC3339))
|
||||
}
|
||||
if !filter.EndTime.IsZero() {
|
||||
queryParams.Add("end_time", filter.EndTime.Format(time.RFC3339))
|
||||
}
|
||||
if filter.FolderPath != nil {
|
||||
queryParams.Add("folder_path", *filter.FolderPath)
|
||||
if filter.FolderRecursive {
|
||||
queryParams.Add("folder_recursive", "true")
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, path, nil, queryParams)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
var response KnowledgeListResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return response.Data, response.Total, nil
|
||||
}
|
||||
|
||||
// KnowledgeFolderNode is one node of the knowledge base folder tree.
|
||||
type KnowledgeFolderNode struct {
|
||||
Path string `json:"path"`
|
||||
Name string `json:"name"`
|
||||
DocumentCount int64 `json:"document_count"`
|
||||
TotalCount int64 `json:"total_count"`
|
||||
Children []*KnowledgeFolderNode `json:"children,omitempty"`
|
||||
}
|
||||
|
||||
// KnowledgeFolderTree is returned by ListKnowledgeFolders.
|
||||
type KnowledgeFolderTree struct {
|
||||
RootDocumentCount int64 `json:"root_document_count"`
|
||||
TotalDocumentCount int64 `json:"total_document_count"`
|
||||
Folders []*KnowledgeFolderNode `json:"folders"`
|
||||
}
|
||||
|
||||
// ListKnowledgeFolders returns the folder hierarchy of a knowledge base.
|
||||
func (c *Client) ListKnowledgeFolders(ctx context.Context, knowledgeBaseID string) (*KnowledgeFolderTree, error) {
|
||||
path := fmt.Sprintf("/api/v1/knowledge-bases/%s/knowledge/folders", knowledgeBaseID)
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, path, nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Success bool `json:"success"`
|
||||
Data *KnowledgeFolderTree `json:"data"`
|
||||
}
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response.Data, nil
|
||||
}
|
||||
|
||||
// MoveKnowledgeToFolderRequest re-files knowledge entries under a folder path.
|
||||
type MoveKnowledgeToFolderRequest struct {
|
||||
KBID string `json:"kb_id"`
|
||||
IDs []string `json:"knowledge_ids"`
|
||||
FolderPath string `json:"folder_path"`
|
||||
}
|
||||
|
||||
// MoveKnowledgeToFolderResponse is the payload of POST /knowledge/folder.
|
||||
type MoveKnowledgeToFolderResponse struct {
|
||||
MovedCount int64 `json:"moved_count"`
|
||||
FolderPath string `json:"folder_path"`
|
||||
}
|
||||
|
||||
// MoveKnowledgeToFolder updates folder_path for the given knowledge entries.
|
||||
// An empty FolderPath moves documents back to the knowledge base root.
|
||||
func (c *Client) MoveKnowledgeToFolder(ctx context.Context, req *MoveKnowledgeToFolderRequest) (*MoveKnowledgeToFolderResponse, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, "/api/v1/knowledge/folder", req, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Success bool `json:"success"`
|
||||
Data *MoveKnowledgeToFolderResponse `json:"data"`
|
||||
}
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response.Data, nil
|
||||
}
|
||||
|
||||
// RenameKnowledgeFolderRequest moves a folder and its subtree to a new path.
|
||||
type RenameKnowledgeFolderRequest struct {
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
}
|
||||
|
||||
// RenameKnowledgeFolderResponse is the payload of PUT .../knowledge/folders.
|
||||
type RenameKnowledgeFolderResponse struct {
|
||||
MovedCount int64 `json:"moved_count"`
|
||||
FolderPath string `json:"folder_path"`
|
||||
}
|
||||
|
||||
// RenameKnowledgeFolder rewrites folder_path for a folder and every descendant.
|
||||
func (c *Client) RenameKnowledgeFolder(
|
||||
ctx context.Context,
|
||||
knowledgeBaseID string,
|
||||
req *RenameKnowledgeFolderRequest,
|
||||
) (*RenameKnowledgeFolderResponse, error) {
|
||||
path := fmt.Sprintf("/api/v1/knowledge-bases/%s/knowledge/folders", knowledgeBaseID)
|
||||
resp, err := c.doRequest(ctx, http.MethodPut, path, req, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Success bool `json:"success"`
|
||||
Data *RenameKnowledgeFolderResponse `json:"data"`
|
||||
}
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response.Data, nil
|
||||
}
|
||||
|
||||
// DeleteKnowledge enqueues an asynchronous delete for the given knowledge entry.
|
||||
// The server returns 200 once the task has been submitted; the actual deletion is
|
||||
// performed by a background worker (same pipeline as BatchDeleteKnowledge).
|
||||
func (c *Client) DeleteKnowledge(ctx context.Context, knowledgeID string) error {
|
||||
path := fmt.Sprintf("/api/v1/knowledge/%s", knowledgeID)
|
||||
resp, err := c.doRequest(ctx, http.MethodDelete, path, nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
return parseResponse(resp, &response)
|
||||
}
|
||||
|
||||
// DownloadKnowledgeFile downloads a knowledge file to the specified local path.
|
||||
// On any error after the file is opened, the partial file is removed so a
|
||||
// failed download doesn't leave a corrupt artifact at destPath.
|
||||
//
|
||||
// Callers wanting more control (stream to stdout, validate filename before
|
||||
// touching disk) should use OpenKnowledgeFile and io.Copy directly.
|
||||
func (c *Client) DownloadKnowledgeFile(ctx context.Context, knowledgeID string, destPath string) error {
|
||||
_, body, err := c.OpenKnowledgeFile(ctx, knowledgeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer body.Close()
|
||||
|
||||
out, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create file: %w", err)
|
||||
}
|
||||
if _, err := io.Copy(out, body); err != nil {
|
||||
_ = out.Close()
|
||||
_ = os.Remove(destPath)
|
||||
return fmt.Errorf("failed to copy response body: %w", err)
|
||||
}
|
||||
return out.Close()
|
||||
}
|
||||
|
||||
// OpenKnowledgeFile starts a download for the given knowledge entry and
|
||||
// returns the server-suggested filename (parsed from Content-Disposition;
|
||||
// empty when the server didn't send one) and a streaming reader for the
|
||||
// body. Callers MUST Close the returned reader.
|
||||
//
|
||||
// Used by `weknora doc download` so the CLI can inspect the filename
|
||||
// before opening the destination file — avoids streaming the full body
|
||||
// to a temp file just to discover the request would have been rejected
|
||||
// (overwrite without --force, missing --out for unnamed downloads, etc.).
|
||||
func (c *Client) OpenKnowledgeFile(ctx context.Context, knowledgeID string) (string, io.ReadCloser, error) {
|
||||
path := fmt.Sprintf("/api/v1/knowledge/%s/download", knowledgeID)
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, path, nil, nil)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
return "", nil, newAPIError(resp.StatusCode, body)
|
||||
}
|
||||
filename := filenameFromContentDisposition(resp.Header.Get("Content-Disposition"))
|
||||
return filename, resp.Body, nil
|
||||
}
|
||||
|
||||
// filenameFromContentDisposition extracts the filename parameter from a
|
||||
// Content-Disposition header. Returns "" on any parse failure or missing
|
||||
// parameter — callers fall back to their own default in that case.
|
||||
func filenameFromContentDisposition(h string) string {
|
||||
if h == "" {
|
||||
return ""
|
||||
}
|
||||
_, params, err := mime.ParseMediaType(h)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return params["filename"]
|
||||
}
|
||||
|
||||
func (c *Client) UpdateKnowledge(ctx context.Context, knowledge *Knowledge) error {
|
||||
path := fmt.Sprintf("/api/v1/knowledge/%s", knowledge.ID)
|
||||
|
||||
resp, err := c.doRequest(ctx, http.MethodPut, path, knowledge, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
return parseResponse(resp, &response)
|
||||
}
|
||||
|
||||
// ReparseKnowledge triggers re-parsing of a knowledge entry
|
||||
// This method deletes existing document content and re-parses the knowledge asynchronously.
|
||||
// It's useful when you want to refresh the knowledge content with updated parsing configurations
|
||||
// or when the original parsing failed and you want to retry.
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: Context for the request
|
||||
// - knowledgeID: The ID of the knowledge entry to reparse
|
||||
//
|
||||
// Returns:
|
||||
// - *Knowledge: The updated knowledge entry with status set to "pending"
|
||||
// - error: Error information if the request fails
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// knowledge, err := client.ReparseKnowledge(ctx, "knowledge-id-123")
|
||||
// if err != nil {
|
||||
// log.Fatalf("Failed to reparse knowledge: %v", err)
|
||||
// }
|
||||
// fmt.Printf("Knowledge reparse task submitted, status: %s\n", knowledge.ParseStatus)
|
||||
func (c *Client) ReparseKnowledge(ctx context.Context, knowledgeID string) (*Knowledge, error) {
|
||||
if knowledgeID == "" {
|
||||
return nil, fmt.Errorf("knowledge ID cannot be empty")
|
||||
}
|
||||
|
||||
path := fmt.Sprintf("/api/v1/knowledge/%s/reparse", knowledgeID)
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, path, nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response KnowledgeResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &response.Data, nil
|
||||
}
|
||||
|
||||
// CancelKnowledgeParse cancels an in-progress knowledge parse.
|
||||
// The server marks the knowledge as cancelled and best-effort dequeues
|
||||
// any pending downstream tasks (multimodal, post-process, summary,
|
||||
// question generation, graph extract) for the same knowledge ID. Any
|
||||
// chunks/index already written are preserved; the user can re-trigger
|
||||
// parsing later via ReparseKnowledge.
|
||||
//
|
||||
// Cancellable parse_status values:
|
||||
// - pending — task has not started
|
||||
// - processing — DocReader / chunking / embedding stage
|
||||
// - finalizing — primary parse done, enrichment subtasks (summary,
|
||||
// question generation, graph extract) still running
|
||||
//
|
||||
// Returns an error when the knowledge is in a terminal state
|
||||
// (completed, failed) or already being deleted.
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: Context for the request
|
||||
// - knowledgeID: The ID of the knowledge entry whose parse should be cancelled
|
||||
//
|
||||
// Returns:
|
||||
// - *Knowledge: The updated knowledge entry with status set to "cancelled"
|
||||
// - error: Error information if the request fails
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// knowledge, err := client.CancelKnowledgeParse(ctx, "knowledge-id-123")
|
||||
// if err != nil {
|
||||
// log.Fatalf("Failed to cancel parse: %v", err)
|
||||
// }
|
||||
// fmt.Printf("Knowledge parse cancelled, status: %s\n", knowledge.ParseStatus)
|
||||
func (c *Client) CancelKnowledgeParse(ctx context.Context, knowledgeID string) (*Knowledge, error) {
|
||||
if knowledgeID == "" {
|
||||
return nil, fmt.Errorf("knowledge ID cannot be empty")
|
||||
}
|
||||
|
||||
path := fmt.Sprintf("/api/v1/knowledge/%s/cancel-parse", knowledgeID)
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, path, nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response KnowledgeResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &response.Data, nil
|
||||
}
|
||||
|
||||
// KnowledgeSpanNode mirrors one node of the server's document-parsing trace
|
||||
// tree (root → stage → subspan). Children carries nested subspans such as
|
||||
// per-image multimodal calls or LLM generations under a stage.
|
||||
type KnowledgeSpanNode struct {
|
||||
KnowledgeID string `json:"knowledge_id"`
|
||||
Attempt int `json:"attempt"`
|
||||
SpanID string `json:"span_id"`
|
||||
ParentSpanID string `json:"parent_span_id,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"` // root / stage / subspan / generation
|
||||
Status string `json:"status"` // pending / running / done / failed / skipped / cancelled
|
||||
Input map[string]interface{} `json:"input,omitempty"`
|
||||
Output map[string]interface{} `json:"output,omitempty"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
ErrorCode string `json:"error_code,omitempty"`
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
StartedAt *time.Time `json:"started_at,omitempty"`
|
||||
FinishedAt *time.Time `json:"finished_at,omitempty"`
|
||||
DurationMs int64 `json:"duration_ms,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Children []*KnowledgeSpanNode `json:"children,omitempty"`
|
||||
}
|
||||
|
||||
// KnowledgeSpanError describes the most recent failed span in a trace.
|
||||
type KnowledgeSpanError struct {
|
||||
Stage string `json:"stage"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
FinishedAt *time.Time `json:"finished_at,omitempty"`
|
||||
}
|
||||
|
||||
// KnowledgeProcessingTrace is the document-parsing trace for one parse
|
||||
// attempt: a five-segment stage timeline (docreader, chunking, embedding,
|
||||
// multimodal, postprocess) plus any subspans, with the current stage and
|
||||
// last error surfaced for convenience.
|
||||
type KnowledgeProcessingTrace struct {
|
||||
KnowledgeID string `json:"knowledge_id"`
|
||||
ParseStatus string `json:"parse_status"`
|
||||
CurrentAttempt int `json:"current_attempt"`
|
||||
CurrentStage string `json:"current_stage"`
|
||||
Trace *KnowledgeSpanNode `json:"trace"`
|
||||
LastError *KnowledgeSpanError `json:"last_error,omitempty"`
|
||||
}
|
||||
|
||||
type knowledgeProcessingTraceResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data KnowledgeProcessingTrace `json:"data"`
|
||||
}
|
||||
|
||||
// GetKnowledgeProcessingSpans fetches the document-parsing trace tree for a
|
||||
// knowledge entry. The response always contains the five canonical stages;
|
||||
// stages that have not produced rows yet are synthesized as "pending"
|
||||
// placeholders so the timeline is stable to render.
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: Context for the request
|
||||
// - knowledgeID: The ID of the knowledge entry
|
||||
// - attempt: A specific parse attempt number; pass 0 for the latest attempt
|
||||
//
|
||||
// Returns:
|
||||
// - *KnowledgeProcessingTrace: The trace for the selected attempt
|
||||
// - error: Error information if the request fails
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// trace, err := client.GetKnowledgeProcessingSpans(ctx, "knowledge-id-123", 0)
|
||||
// if err != nil {
|
||||
// log.Fatalf("Failed to get parsing trace: %v", err)
|
||||
// }
|
||||
// fmt.Printf("parse_status=%s current_stage=%s\n", trace.ParseStatus, trace.CurrentStage)
|
||||
func (c *Client) GetKnowledgeProcessingSpans(
|
||||
ctx context.Context, knowledgeID string, attempt int,
|
||||
) (*KnowledgeProcessingTrace, error) {
|
||||
if knowledgeID == "" {
|
||||
return nil, fmt.Errorf("knowledge ID cannot be empty")
|
||||
}
|
||||
|
||||
queryParams := url.Values{}
|
||||
if attempt > 0 {
|
||||
queryParams.Add("attempt", strconv.Itoa(attempt))
|
||||
}
|
||||
|
||||
path := fmt.Sprintf("/api/v1/knowledge/%s/spans", knowledgeID)
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, path, nil, queryParams)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response knowledgeProcessingTraceResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &response.Data, nil
|
||||
}
|
||||
|
||||
// UpdateChunk updates a chunk's information
|
||||
// Updates information for a specific chunk under a knowledge document
|
||||
// Parameters:
|
||||
// - ctx: Context
|
||||
// - knowledgeID: Knowledge ID
|
||||
// - chunkID: Chunk ID
|
||||
// - request: Update request
|
||||
//
|
||||
// Returns:
|
||||
// - *Chunk: Updated chunk
|
||||
// - error: Error information
|
||||
func (c *Client) UpdateImageInfo(ctx context.Context,
|
||||
knowledgeID string, chunkID string, request *UpdateImageInfoRequest,
|
||||
) error {
|
||||
path := fmt.Sprintf("/api/v1/knowledge/image/%s/%s", knowledgeID, chunkID)
|
||||
resp, err := c.doRequest(ctx, http.MethodPut, path, request, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
return parseResponse(resp, &response)
|
||||
}
|
||||
|
||||
// CreateManualKnowledgeRequest contains the parameters for creating a manual Markdown knowledge entry.
|
||||
type CreateManualKnowledgeRequest struct {
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
TagID string `json:"tag_id,omitempty"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateManualKnowledgeRequest contains the parameters for updating a manual Markdown knowledge entry.
|
||||
type UpdateManualKnowledgeRequest struct {
|
||||
Title string `json:"title,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
}
|
||||
|
||||
// BatchUpdateKnowledgeTagsRequest contains the mapping of knowledge IDs to tag IDs.
|
||||
type BatchUpdateKnowledgeTagsRequest struct {
|
||||
Updates map[string]*string `json:"updates"` // knowledge_id -> tag_id (nil to clear)
|
||||
}
|
||||
|
||||
// CreateManualKnowledge creates a knowledge entry from manual Markdown content.
|
||||
func (c *Client) CreateManualKnowledge(ctx context.Context, knowledgeBaseID string, request *CreateManualKnowledgeRequest) (*Knowledge, error) {
|
||||
path := fmt.Sprintf("/api/v1/knowledge-bases/%s/knowledge/manual", knowledgeBaseID)
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, path, request, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response KnowledgeResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &response.Data, nil
|
||||
}
|
||||
|
||||
// UpdateManualKnowledge updates a manual Markdown knowledge entry.
|
||||
func (c *Client) UpdateManualKnowledge(ctx context.Context, knowledgeID string, request *UpdateManualKnowledgeRequest) (*Knowledge, error) {
|
||||
path := fmt.Sprintf("/api/v1/knowledge/manual/%s", knowledgeID)
|
||||
resp, err := c.doRequest(ctx, http.MethodPut, path, request, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response KnowledgeResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &response.Data, nil
|
||||
}
|
||||
|
||||
// FilterKnowledgeResponse represents the response from filter knowledge API
|
||||
type FilterKnowledgeResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data []Knowledge `json:"data"`
|
||||
HasMore bool `json:"has_more"`
|
||||
}
|
||||
|
||||
// FilterKnowledge searches/filters knowledge entries across knowledge bases
|
||||
func (c *Client) FilterKnowledge(ctx context.Context, keyword string, offset, limit int, fileTypes []string, agentID string) ([]Knowledge, bool, error) {
|
||||
queryParams := url.Values{}
|
||||
if keyword != "" {
|
||||
queryParams.Set("keyword", keyword)
|
||||
}
|
||||
queryParams.Set("offset", strconv.Itoa(offset))
|
||||
queryParams.Set("limit", strconv.Itoa(limit))
|
||||
if len(fileTypes) > 0 {
|
||||
for _, ft := range fileTypes {
|
||||
queryParams.Add("file_types", ft)
|
||||
}
|
||||
}
|
||||
if agentID != "" {
|
||||
queryParams.Set("agent_id", agentID)
|
||||
}
|
||||
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, "/api/v1/knowledge/search", nil, queryParams)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
var response FilterKnowledgeResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
return response.Data, response.HasMore, nil
|
||||
}
|
||||
|
||||
// MoveKnowledgeRequest contains the parameters for moving knowledge between KBs
|
||||
type MoveKnowledgeRequest struct {
|
||||
KnowledgeIDs []string `json:"knowledge_ids"`
|
||||
SourceKBID string `json:"source_kb_id"`
|
||||
TargetKBID string `json:"target_kb_id"`
|
||||
Mode string `json:"mode"` // "reuse_vectors" or "reparse"
|
||||
}
|
||||
|
||||
// MoveKnowledgeResponse represents the response from move knowledge API
|
||||
type MoveKnowledgeResponse struct {
|
||||
TaskID string `json:"task_id"`
|
||||
SourceKBID string `json:"source_kb_id"`
|
||||
TargetKBID string `json:"target_kb_id"`
|
||||
KnowledgeCount int `json:"knowledge_count"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// MoveKnowledge moves knowledge items from one knowledge base to another (async task)
|
||||
func (c *Client) MoveKnowledge(ctx context.Context, req *MoveKnowledgeRequest) (*MoveKnowledgeResponse, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, "/api/v1/knowledge/move", req, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Success bool `json:"success"`
|
||||
Data *MoveKnowledgeResponse `json:"data"`
|
||||
}
|
||||
if err := parseResponse(resp, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.Data, nil
|
||||
}
|
||||
|
||||
// KnowledgeMoveProgress represents the progress of a knowledge move task
|
||||
type KnowledgeMoveProgress struct {
|
||||
TaskID string `json:"task_id"`
|
||||
Status string `json:"status"`
|
||||
Progress int `json:"progress"`
|
||||
Total int `json:"total"`
|
||||
Processed int `json:"processed"`
|
||||
Message string `json:"message"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// GetKnowledgeMoveProgress gets the progress of a knowledge move task
|
||||
func (c *Client) GetKnowledgeMoveProgress(ctx context.Context, taskID string) (*KnowledgeMoveProgress, error) {
|
||||
path := fmt.Sprintf("/api/v1/knowledge/move/progress/%s", taskID)
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, path, nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Success bool `json:"success"`
|
||||
Data *KnowledgeMoveProgress `json:"data"`
|
||||
}
|
||||
if err := parseResponse(resp, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.Data, nil
|
||||
}
|
||||
|
||||
// PreviewKnowledgeFile returns the file content for inline preview.
|
||||
// The caller is responsible for reading and closing the response body.
|
||||
func (c *Client) PreviewKnowledgeFile(ctx context.Context, knowledgeID string) (*http.Response, error) {
|
||||
path := fmt.Sprintf("/api/v1/knowledge/%s/preview", knowledgeID)
|
||||
return c.doRequest(ctx, http.MethodGet, path, nil, nil)
|
||||
}
|
||||
|
||||
// BatchUpdateKnowledgeTags batch updates knowledge tags.
|
||||
// The updates map contains knowledge_id -> tag_id mappings. Set tag_id to nil to clear the tag.
|
||||
func (c *Client) BatchUpdateKnowledgeTags(ctx context.Context, updates map[string]*string) error {
|
||||
request := &BatchUpdateKnowledgeTagsRequest{Updates: updates}
|
||||
resp, err := c.doRequest(ctx, http.MethodPut, "/api/v1/knowledge/tags", request, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var batchResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
return parseResponse(resp, &batchResponse)
|
||||
}
|
||||
@@ -0,0 +1,545 @@
|
||||
// Package client provides the implementation for interacting with the WeKnora API
|
||||
// The KnowledgeBase related interfaces are used to manage knowledge bases
|
||||
// Knowledge bases are collections of knowledge entries that can be used for question-answering
|
||||
// They can also be searched and queried using hybrid search
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
// KnowledgeBase represents a knowledge base
|
||||
type KnowledgeBase struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"` // Name must be unique within the same tenant
|
||||
Type string `json:"type"`
|
||||
IsTemporary bool `json:"is_temporary"`
|
||||
IsPinned bool `json:"is_pinned"`
|
||||
Description string `json:"description"`
|
||||
TenantID uint64 `json:"tenant_id"`
|
||||
ChunkingConfig ChunkingConfig `json:"chunking_config"`
|
||||
ImageProcessingConfig ImageProcessingConfig `json:"image_processing_config"`
|
||||
FAQConfig *FAQConfig `json:"faq_config"`
|
||||
EmbeddingModelID string `json:"embedding_model_id"`
|
||||
SummaryModelID string `json:"summary_model_id"`
|
||||
VLMConfig VLMConfig `json:"vlm_config"`
|
||||
StorageProviderConfig *StorageProviderConfig `json:"storage_provider_config"`
|
||||
StorageConfig StorageConfig `json:"storage_config"`
|
||||
ExtractConfig *ExtractConfig `json:"extract_config"`
|
||||
AutoTagConfig *AutoTagConfig `json:"auto_tag_config"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
// Computed fields (not stored in database)
|
||||
KnowledgeCount int64 `json:"knowledge_count"`
|
||||
ChunkCount int64 `json:"chunk_count"`
|
||||
IsProcessing bool `json:"is_processing"`
|
||||
ProcessingCount int64 `json:"processing_count"`
|
||||
}
|
||||
|
||||
// KnowledgeBaseConfig represents knowledge base configuration
|
||||
type KnowledgeBaseConfig struct {
|
||||
ChunkingConfig ChunkingConfig `json:"chunking_config"`
|
||||
ImageProcessingConfig ImageProcessingConfig `json:"image_processing_config"`
|
||||
FAQConfig *FAQConfig `json:"faq_config"`
|
||||
AutoTagConfig *AutoTagConfig `json:"auto_tag_config,omitempty"`
|
||||
}
|
||||
|
||||
// ChunkingConfig represents document chunking configuration
|
||||
type ChunkingConfig struct {
|
||||
ChunkSize int `json:"chunk_size"` // Chunk size
|
||||
ChunkOverlap int `json:"chunk_overlap"` // Overlap size
|
||||
Separators []string `json:"separators"` // Separators
|
||||
}
|
||||
|
||||
// FAQConfig represents faq-specific configuration
|
||||
type FAQConfig struct {
|
||||
IndexMode string `json:"index_mode"`
|
||||
QuestionIndexMode string `json:"question_index_mode"`
|
||||
}
|
||||
|
||||
// ImageProcessingConfig represents image processing configuration
|
||||
type ImageProcessingConfig struct {
|
||||
ModelID string `json:"model_id"` // Multimodal model ID
|
||||
}
|
||||
|
||||
// VLMConfig represents the VLM configuration
|
||||
type VLMConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
ModelID string `json:"model_id"`
|
||||
}
|
||||
|
||||
// StorageProviderConfig stores the KB-level storage provider selection.
|
||||
type StorageProviderConfig struct {
|
||||
Provider string `json:"provider"`
|
||||
}
|
||||
|
||||
// StorageConfig represents the legacy storage configuration (cos_config).
|
||||
// Deprecated: use StorageProviderConfig for provider selection.
|
||||
type StorageConfig struct {
|
||||
SecretID string `json:"secret_id"`
|
||||
SecretKey string `json:"secret_key"`
|
||||
Region string `json:"region"`
|
||||
BucketName string `json:"bucket_name"`
|
||||
AppID string `json:"app_id"`
|
||||
PathPrefix string `json:"path_prefix"`
|
||||
Provider string `json:"provider"`
|
||||
}
|
||||
|
||||
// ExtractConfig represents the extract configuration for a knowledge base
|
||||
type ExtractConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Nodes []*GraphNode `json:"nodes,omitempty"`
|
||||
Relations []*GraphRelation `json:"relations,omitempty"`
|
||||
}
|
||||
|
||||
// GraphNode represents a node in the graph extraction configuration
|
||||
type GraphNode struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// GraphRelation represents a relation in the graph extraction configuration
|
||||
type GraphRelation struct {
|
||||
Node1 string `json:"node1"`
|
||||
Node2 string `json:"node2"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// ParserEngineRule maps a set of file types to a specific parser engine.
|
||||
type ParserEngineRule struct {
|
||||
FileTypes []string `json:"file_types"`
|
||||
Engine string `json:"engine"`
|
||||
}
|
||||
|
||||
// QuestionGenerationConfig controls LLM-generated questions per chunk during parsing.
|
||||
type QuestionGenerationConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
QuestionCount int `json:"question_count"`
|
||||
}
|
||||
|
||||
// AutoTagConfig controls optional automatic association of existing knowledge
|
||||
// base tags after a document finishes parsing. Only applies to document-type
|
||||
// knowledge bases; disabled by default.
|
||||
type AutoTagConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
// ModelID selects the chat model used for classification. Empty falls
|
||||
// back to the knowledge base's summary model.
|
||||
ModelID string `json:"model_id,omitempty"`
|
||||
// MaxTags caps how many existing tags one document may auto-acquire
|
||||
// (1-10, defaults to 3).
|
||||
MaxTags int `json:"max_tags,omitempty"`
|
||||
// SkipIfTagged leaves documents that already carry tags untouched.
|
||||
// Defaults to true when omitted.
|
||||
SkipIfTagged *bool `json:"skip_if_tagged,omitempty"`
|
||||
}
|
||||
|
||||
// ASRConfig represents automatic speech recognition settings for audio files.
|
||||
type ASRConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
ModelID string `json:"model_id"`
|
||||
Language string `json:"language,omitempty"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON keeps backward compatibility for legacy responses that still
|
||||
// use `cos_config` instead of `storage_config`.
|
||||
func (kb *KnowledgeBase) UnmarshalJSON(data []byte) error {
|
||||
type alias KnowledgeBase
|
||||
aux := struct {
|
||||
*alias
|
||||
LegacyStorageConfig *StorageConfig `json:"cos_config"`
|
||||
}{
|
||||
alias: (*alias)(kb),
|
||||
}
|
||||
if err := json.Unmarshal(data, &aux); err != nil {
|
||||
return err
|
||||
}
|
||||
if aux.LegacyStorageConfig != nil && kb.StorageConfig == (StorageConfig{}) {
|
||||
kb.StorageConfig = *aux.LegacyStorageConfig
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// KnowledgeBaseResponse knowledge base response
|
||||
type KnowledgeBaseResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data KnowledgeBase `json:"data"`
|
||||
}
|
||||
|
||||
// KnowledgeBaseListResponse knowledge base list response
|
||||
type KnowledgeBaseListResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data []KnowledgeBase `json:"data"`
|
||||
}
|
||||
|
||||
// MatchType records which retrieval channel produced a SearchResult.
|
||||
// Numeric values are the wire contract; they mirror the iota order in
|
||||
// server-side internal/types/embedding.go — do not reorder without
|
||||
// coordinating a server bump. Server-side names are preserved as
|
||||
// trailing comments so cross-repo grep works in both directions.
|
||||
//
|
||||
// Channel grouping: 0-1 primary text channels (vector + keyword);
|
||||
// 2-5 enrichment chunks (added in addition to primary matches, score=0);
|
||||
// 6-9 alternate sources (graph DB, web search, raw load, data analysis).
|
||||
type MatchType int
|
||||
|
||||
const (
|
||||
MatchTypeVector MatchType = 0 // server: MatchTypeEmbedding
|
||||
MatchTypeKeyword MatchType = 1 // server: MatchTypeKeywords
|
||||
MatchTypeNearby MatchType = 2 // server: MatchTypeNearByChunk
|
||||
MatchTypeHistory MatchType = 3 // server: MatchTypeHistory
|
||||
MatchTypeParent MatchType = 4 // server: MatchTypeParentChunk
|
||||
MatchTypeRelation MatchType = 5 // server: MatchTypeRelationChunk
|
||||
MatchTypeGraph MatchType = 6 // server: MatchTypeGraph
|
||||
MatchTypeWeb MatchType = 7 // server: MatchTypeWebSearch
|
||||
MatchTypeDirect MatchType = 8 // server: MatchTypeDirectLoad — chunk loaded by ID without scoring
|
||||
MatchTypeData MatchType = 9 // server: MatchTypeDataAnalysis — produced by analytical pipeline, not retrieval
|
||||
)
|
||||
|
||||
// SearchResult represents search result.
|
||||
//
|
||||
// Score is the RRF (reciprocal-rank-fusion) score combining vector and
|
||||
// keyword channels — typically in the [0, ~0.03] range when both channels
|
||||
// hit, NOT the raw vector similarity. Use MatchType to tell which channel
|
||||
// produced each result. Per-channel thresholds (vector_threshold,
|
||||
// keyword_threshold) filter pre-fusion at retrieval time, before RRF runs.
|
||||
type SearchResult struct {
|
||||
ID string `json:"id"`
|
||||
Content string `json:"content"`
|
||||
KnowledgeID string `json:"knowledge_id"`
|
||||
ChunkIndex int `json:"chunk_index"`
|
||||
KnowledgeTitle string `json:"knowledge_title"`
|
||||
StartAt int `json:"start_at"`
|
||||
EndAt int `json:"end_at"`
|
||||
Seq int `json:"seq"`
|
||||
Score float64 `json:"score"`
|
||||
MatchType MatchType `json:"match_type"`
|
||||
ChunkType string `json:"chunk_type"`
|
||||
ImageInfo string `json:"image_info"`
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
KnowledgeFilename string `json:"knowledge_filename"`
|
||||
KnowledgeSource string `json:"knowledge_source"`
|
||||
KnowledgeChannel string `json:"knowledge_channel"`
|
||||
// MatchedContent is the actual content that was matched in vector search
|
||||
// For FAQ: this is the matched question text (standard or similar question)
|
||||
MatchedContent string `json:"matched_content,omitempty"`
|
||||
// KnowledgeBaseID identifies the KB that produced this hit. It is required
|
||||
// for agent runs that search more than one knowledge base.
|
||||
KnowledgeBaseID string `json:"knowledge_base_id,omitempty"`
|
||||
// ParentChunkID / SubChunkID describe the chunk hierarchy: a retrieval
|
||||
// "hit" id may be a sub-chunk, while the parent holds the self-contained
|
||||
// passage an agent fetches via `chunk view <parent_chunk_id>`. The server
|
||||
// ships these in the references event; without fields here Go silently
|
||||
// drops them during unmarshal.
|
||||
ParentChunkID string `json:"parent_chunk_id,omitempty"`
|
||||
SubChunkID []string `json:"sub_chunk_id,omitempty"`
|
||||
}
|
||||
|
||||
// HybridSearchResponse hybrid search response
|
||||
type HybridSearchResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data []*SearchResult `json:"data"`
|
||||
}
|
||||
|
||||
type CopyKnowledgeBaseRequest struct {
|
||||
TaskID string `json:"task_id,omitempty"`
|
||||
SourceID string `json:"source_id"`
|
||||
TargetID string `json:"target_id"`
|
||||
}
|
||||
|
||||
// CopyKnowledgeBaseResponse represents the response from copy knowledge base API
|
||||
type CopyKnowledgeBaseResponse struct {
|
||||
TaskID string `json:"task_id"`
|
||||
SourceID string `json:"source_id"`
|
||||
TargetID string `json:"target_id"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type DuplicateKnowledgeBaseResponse struct {
|
||||
SourceID string `json:"source_id"`
|
||||
TargetID string `json:"target_id"`
|
||||
Message string `json:"message"`
|
||||
KnowledgeBase KnowledgeBase `json:"knowledge_base"`
|
||||
}
|
||||
|
||||
// KBCloneProgress represents the progress of a knowledge base clone task
|
||||
type KBCloneProgress struct {
|
||||
TaskID string `json:"task_id"`
|
||||
SourceID string `json:"source_id"`
|
||||
TargetID string `json:"target_id"`
|
||||
Status string `json:"status"` // pending, processing, completed, failed
|
||||
Progress int `json:"progress"` // 0-100
|
||||
Total int `json:"total"` // Total operations count
|
||||
Processed int `json:"processed"` // Processed operations count
|
||||
Message string `json:"message"`
|
||||
Error string `json:"error,omitempty"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
// CreateKnowledgeBase creates a knowledge base
|
||||
func (c *Client) CreateKnowledgeBase(ctx context.Context, knowledgeBase *KnowledgeBase) (*KnowledgeBase, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, "/api/v1/knowledge-bases", knowledgeBase, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response KnowledgeBaseResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &response.Data, nil
|
||||
}
|
||||
|
||||
// GetKnowledgeBase gets a knowledge base
|
||||
func (c *Client) GetKnowledgeBase(ctx context.Context, knowledgeBaseID string) (*KnowledgeBase, error) {
|
||||
path := fmt.Sprintf("/api/v1/knowledge-bases/%s", knowledgeBaseID)
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, path, nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response KnowledgeBaseResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &response.Data, nil
|
||||
}
|
||||
|
||||
// ListKnowledgeBases lists knowledge bases
|
||||
func (c *Client) ListKnowledgeBases(ctx context.Context) ([]KnowledgeBase, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, "/api/v1/knowledge-bases", nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response KnowledgeBaseListResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return response.Data, nil
|
||||
}
|
||||
|
||||
// UpdateKnowledgeBaseRequest update knowledge base request
|
||||
type UpdateKnowledgeBaseRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Config *KnowledgeBaseConfig `json:"config"`
|
||||
}
|
||||
|
||||
// UpdateKnowledgeBase updates a knowledge base
|
||||
func (c *Client) UpdateKnowledgeBase(ctx context.Context,
|
||||
knowledgeBaseID string,
|
||||
request *UpdateKnowledgeBaseRequest,
|
||||
) (*KnowledgeBase, error) {
|
||||
path := fmt.Sprintf("/api/v1/knowledge-bases/%s", knowledgeBaseID)
|
||||
resp, err := c.doRequest(ctx, http.MethodPut, path, request, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response KnowledgeBaseResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &response.Data, nil
|
||||
}
|
||||
|
||||
// DeleteKnowledgeBase deletes a knowledge base
|
||||
func (c *Client) DeleteKnowledgeBase(ctx context.Context, knowledgeBaseID string) error {
|
||||
path := fmt.Sprintf("/api/v1/knowledge-bases/%s", knowledgeBaseID)
|
||||
resp, err := c.doRequest(ctx, http.MethodDelete, path, nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
return parseResponse(resp, &response)
|
||||
}
|
||||
|
||||
// ClearKnowledgeBaseContentsResponse represents the response from clear knowledge base contents API
|
||||
type ClearKnowledgeBaseContentsResponse struct {
|
||||
DeletedCount int `json:"deleted_count"`
|
||||
}
|
||||
|
||||
// ClearKnowledgeBaseContents deletes all knowledge entries in a knowledge base (async).
|
||||
// The knowledge base itself is preserved; only its contents are removed.
|
||||
func (c *Client) ClearKnowledgeBaseContents(ctx context.Context, knowledgeBaseID string) (*ClearKnowledgeBaseContentsResponse, error) {
|
||||
path := fmt.Sprintf("/api/v1/knowledge-bases/%s/knowledge", knowledgeBaseID)
|
||||
resp, err := c.doRequest(ctx, http.MethodDelete, path, nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
Data ClearKnowledgeBaseContentsResponse `json:"data"`
|
||||
}
|
||||
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &response.Data, nil
|
||||
}
|
||||
|
||||
// SearchParams represents the search parameters for hybrid search
|
||||
type SearchParams struct {
|
||||
QueryText string `json:"query_text"`
|
||||
VectorThreshold float64 `json:"vector_threshold"`
|
||||
KeywordThreshold float64 `json:"keyword_threshold"`
|
||||
MatchCount int `json:"match_count"`
|
||||
DisableKeywordsMatch bool `json:"disable_keywords_match"`
|
||||
DisableVectorMatch bool `json:"disable_vector_match"`
|
||||
}
|
||||
|
||||
// HybridSearch performs hybrid search.
|
||||
// Pass ResourceURLOptions to receive public HTTP(S) file URLs in results.
|
||||
func (c *Client) HybridSearch(
|
||||
ctx context.Context,
|
||||
knowledgeBaseID string,
|
||||
params *SearchParams,
|
||||
opts ...ResourceURLOptions,
|
||||
) ([]*SearchResult, error) {
|
||||
path := fmt.Sprintf("/api/v1/knowledge-bases/%s/hybrid-search", knowledgeBaseID)
|
||||
|
||||
queryParams := url.Values{}
|
||||
if len(opts) > 0 {
|
||||
applyResourceURLQuery(queryParams, &opts[0])
|
||||
}
|
||||
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, path, params, queryParams)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response HybridSearchResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return response.Data, nil
|
||||
}
|
||||
|
||||
// TogglePinKnowledgeBase toggles the pin status of a knowledge base.
|
||||
// Server route is PUT (see internal/router/router.go); using POST silently
|
||||
// 404s — the router treats unknown method on a known path as not-found,
|
||||
// not 405.
|
||||
func (c *Client) TogglePinKnowledgeBase(ctx context.Context, knowledgeBaseID string) (*KnowledgeBase, error) {
|
||||
path := fmt.Sprintf("/api/v1/knowledge-bases/%s/pin", knowledgeBaseID)
|
||||
resp, err := c.doRequest(ctx, http.MethodPut, path, nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response KnowledgeBaseResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &response.Data, nil
|
||||
}
|
||||
|
||||
// MoveTarget represents a knowledge base that can receive moved knowledge
|
||||
type MoveTarget struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// ListMoveTargets lists knowledge bases eligible as move targets for the given source KB
|
||||
func (c *Client) ListMoveTargets(ctx context.Context, knowledgeBaseID string) ([]KnowledgeBase, error) {
|
||||
path := fmt.Sprintf("/api/v1/knowledge-bases/%s/move-targets", knowledgeBaseID)
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, path, nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response KnowledgeBaseListResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return response.Data, nil
|
||||
}
|
||||
|
||||
// CopyKnowledgeBase copies a knowledge base asynchronously and returns task info
|
||||
func (c *Client) CopyKnowledgeBase(ctx context.Context, request *CopyKnowledgeBaseRequest) (*CopyKnowledgeBaseResponse, error) {
|
||||
path := "/api/v1/knowledge-bases/copy"
|
||||
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, path, request, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Success bool `json:"success"`
|
||||
Data CopyKnowledgeBaseResponse `json:"data"`
|
||||
}
|
||||
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &response.Data, nil
|
||||
}
|
||||
|
||||
// DuplicateKnowledgeBase creates a settings-only duplicate and returns the new knowledge base info.
|
||||
func (c *Client) DuplicateKnowledgeBase(
|
||||
ctx context.Context,
|
||||
knowledgeBaseID string,
|
||||
) (*DuplicateKnowledgeBaseResponse, error) {
|
||||
path := fmt.Sprintf("/api/v1/knowledge-bases/%s/duplicate", knowledgeBaseID)
|
||||
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, path, nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Success bool `json:"success"`
|
||||
Data DuplicateKnowledgeBaseResponse `json:"data"`
|
||||
}
|
||||
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &response.Data, nil
|
||||
}
|
||||
|
||||
// GetKBCloneProgress gets the progress of a knowledge base clone task
|
||||
func (c *Client) GetKBCloneProgress(ctx context.Context, taskID string) (*KBCloneProgress, error) {
|
||||
path := fmt.Sprintf("/api/v1/knowledge-bases/copy/progress/%s", taskID)
|
||||
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, path, nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Success bool `json:"success"`
|
||||
Data KBCloneProgress `json:"data"`
|
||||
}
|
||||
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &response.Data, nil
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDuplicateKnowledgeBase(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Fatalf("method = %s, want POST", r.Method)
|
||||
}
|
||||
if r.URL.Path != "/api/v1/knowledge-bases/kb-1/duplicate" {
|
||||
t.Fatalf("path = %s, want /api/v1/knowledge-bases/kb-1/duplicate", r.URL.Path)
|
||||
}
|
||||
if r.ContentLength > 0 {
|
||||
t.Fatalf("expected empty request body, got content-length=%d", r.ContentLength)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": map[string]interface{}{
|
||||
"source_id": "kb-1",
|
||||
"target_id": "kb-2",
|
||||
"message": "Knowledge base duplicate created",
|
||||
"knowledge_base": map[string]interface{}{
|
||||
"id": "kb-2",
|
||||
"name": "Source Copy",
|
||||
"type": "document",
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL, WithAPIKey("sk-test"))
|
||||
resp, err := c.DuplicateKnowledgeBase(context.Background(), "kb-1")
|
||||
if err != nil {
|
||||
t.Fatalf("DuplicateKnowledgeBase() error = %v", err)
|
||||
}
|
||||
if resp.SourceID != "kb-1" {
|
||||
t.Fatalf("SourceID = %q, want kb-1", resp.SourceID)
|
||||
}
|
||||
if resp.TargetID != "kb-2" {
|
||||
t.Fatalf("TargetID = %q, want kb-2", resp.TargetID)
|
||||
}
|
||||
if resp.KnowledgeBase.ID != "kb-2" {
|
||||
t.Fatalf("KnowledgeBase.ID = %q, want kb-2", resp.KnowledgeBase.ID)
|
||||
}
|
||||
if resp.KnowledgeBase.Name != "Source Copy" {
|
||||
t.Fatalf("KnowledgeBase.Name = %q, want Source Copy", resp.KnowledgeBase.Name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Package client uses an opt-in slog logger for SDK-internal trace output.
|
||||
// Default behavior writes to io.Discard so SDK consumers (CLI, server) never
|
||||
// see SDK trace output on stdout/stderr. Embedders enable debug output by
|
||||
// calling SetDebugLevel("debug") at startup.
|
||||
package client
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var debugLogger = slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
|
||||
// SetDebugLevel replaces the SDK's internal debug logger so callers can
|
||||
// programmatically control SDK trace output (e.g., the CLI's --log-level
|
||||
// flag). Accepted levels: "debug" / "info" / "warn" / "error" (case-
|
||||
// insensitive); any other value (including "") disables output entirely
|
||||
// (writes to io.Discard).
|
||||
//
|
||||
// Not safe for concurrent use while SDK calls are in flight — call once
|
||||
// at startup before any client method is invoked.
|
||||
func SetDebugLevel(level string) {
|
||||
switch strings.ToLower(level) {
|
||||
case "debug":
|
||||
debugLogger = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
case "info":
|
||||
debugLogger = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
case "warn":
|
||||
debugLogger = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn}))
|
||||
default:
|
||||
// "error" or unrecognized → silent. SDK only emits Debug calls today,
|
||||
// so error-level discards everything in practice.
|
||||
debugLogger = slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log/slog"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSetDebugLevel(t *testing.T) {
|
||||
// Save and restore
|
||||
saved := debugLogger
|
||||
defer func() { debugLogger = saved }()
|
||||
|
||||
cases := []string{"debug", "info", "warn", "error", "DEBUG", "", "invalid"}
|
||||
for _, lvl := range cases {
|
||||
SetDebugLevel(lvl)
|
||||
if debugLogger == nil {
|
||||
t.Errorf("SetDebugLevel(%q): debugLogger nil", lvl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDebugLevel_DebugRoutesEmissions(t *testing.T) {
|
||||
saved := debugLogger
|
||||
defer func() { debugLogger = saved }()
|
||||
|
||||
var buf bytes.Buffer
|
||||
debugLogger = slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
debugLogger.Debug("test_event", "k", "v")
|
||||
if buf.Len() == 0 {
|
||||
t.Error("expected debug emission to land in buffer")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// MCPTransportType represents the transport type for MCP service
|
||||
type MCPTransportType string
|
||||
|
||||
const (
|
||||
MCPTransportSSE MCPTransportType = "sse"
|
||||
MCPTransportHTTPStreamable MCPTransportType = "http-streamable"
|
||||
MCPTransportStdio MCPTransportType = "stdio"
|
||||
)
|
||||
|
||||
// MCPService represents an MCP service configuration
|
||||
type MCPService struct {
|
||||
ID string `json:"id"`
|
||||
TenantID uint64 `json:"tenant_id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Enabled bool `json:"enabled"`
|
||||
TransportType MCPTransportType `json:"transport_type"`
|
||||
URL *string `json:"url,omitempty"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
AuthConfig *MCPAuthConfig `json:"auth_config"`
|
||||
AdvancedConfig *MCPAdvancedConfig `json:"advanced_config"`
|
||||
StdioConfig *MCPStdioConfig `json:"stdio_config,omitempty"`
|
||||
EnvVars map[string]string `json:"env_vars,omitempty"`
|
||||
IsBuiltin bool `json:"is_builtin"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// MCPAuthConfig represents authentication configuration for MCP service.
|
||||
//
|
||||
// Secret fields (APIKey, Token) are accepted on create but are never returned
|
||||
// by the server. To mutate credentials on an existing service, use the
|
||||
// dedicated /credentials subresource — see the MCP credentials API for the
|
||||
// PUT / DELETE shape. Sending secret fields in a main PUT body is silently
|
||||
// ignored server-side.
|
||||
type MCPAuthConfig struct {
|
||||
APIKey string `json:"api_key,omitempty"`
|
||||
Token string `json:"token,omitempty"`
|
||||
CustomHeaders map[string]string `json:"custom_headers,omitempty"`
|
||||
}
|
||||
|
||||
// MCPAdvancedConfig represents advanced configuration for MCP service
|
||||
type MCPAdvancedConfig struct {
|
||||
Timeout int `json:"timeout"`
|
||||
RetryCount int `json:"retry_count"`
|
||||
RetryDelay int `json:"retry_delay"`
|
||||
}
|
||||
|
||||
// MCPStdioConfig represents stdio transport configuration
|
||||
type MCPStdioConfig struct {
|
||||
Command string `json:"command"`
|
||||
Args []string `json:"args"`
|
||||
}
|
||||
|
||||
// MCPTool represents a tool exposed by an MCP service
|
||||
type MCPTool struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
InputSchema json.RawMessage `json:"inputSchema"`
|
||||
}
|
||||
|
||||
// MCPResource represents a resource exposed by an MCP service
|
||||
type MCPResource struct {
|
||||
URI string `json:"uri"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
MimeType string `json:"mimeType,omitempty"`
|
||||
}
|
||||
|
||||
// MCPTestResult represents the result of testing an MCP service connection
|
||||
type MCPTestResult struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Tools []*MCPTool `json:"tools,omitempty"`
|
||||
Resources []*MCPResource `json:"resources,omitempty"`
|
||||
}
|
||||
|
||||
// CreateMCPService creates a new MCP service
|
||||
func (c *Client) CreateMCPService(ctx context.Context, service *MCPService) (*MCPService, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, "/api/v1/mcp-services", service, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Success bool `json:"success"`
|
||||
Data *MCPService `json:"data"`
|
||||
}
|
||||
if err := parseResponse(resp, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.Data, nil
|
||||
}
|
||||
|
||||
// ListMCPServices lists all MCP services for the current tenant
|
||||
func (c *Client) ListMCPServices(ctx context.Context) ([]*MCPService, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, "/api/v1/mcp-services", nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Success bool `json:"success"`
|
||||
Data []*MCPService `json:"data"`
|
||||
}
|
||||
if err := parseResponse(resp, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.Data, nil
|
||||
}
|
||||
|
||||
// GetMCPService gets an MCP service by ID
|
||||
func (c *Client) GetMCPService(ctx context.Context, serviceID string) (*MCPService, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, fmt.Sprintf("/api/v1/mcp-services/%s", serviceID), nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Success bool `json:"success"`
|
||||
Data *MCPService `json:"data"`
|
||||
}
|
||||
if err := parseResponse(resp, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.Data, nil
|
||||
}
|
||||
|
||||
// UpdateMCPService updates an MCP service
|
||||
func (c *Client) UpdateMCPService(ctx context.Context, serviceID string, updates map[string]interface{}) (*MCPService, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodPut, fmt.Sprintf("/api/v1/mcp-services/%s", serviceID), updates, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Success bool `json:"success"`
|
||||
Data *MCPService `json:"data"`
|
||||
}
|
||||
if err := parseResponse(resp, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.Data, nil
|
||||
}
|
||||
|
||||
// DeleteMCPService deletes an MCP service
|
||||
func (c *Client) DeleteMCPService(ctx context.Context, serviceID string) error {
|
||||
resp, err := c.doRequest(ctx, http.MethodDelete, fmt.Sprintf("/api/v1/mcp-services/%s", serviceID), nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return parseResponse(resp, nil)
|
||||
}
|
||||
|
||||
// TestMCPService tests an MCP service connection
|
||||
func (c *Client) TestMCPService(ctx context.Context, serviceID string) (*MCPTestResult, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, fmt.Sprintf("/api/v1/mcp-services/%s/test", serviceID), nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Success bool `json:"success"`
|
||||
Data *MCPTestResult `json:"data"`
|
||||
}
|
||||
if err := parseResponse(resp, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.Data, nil
|
||||
}
|
||||
|
||||
// GetMCPServiceTools gets the tools provided by an MCP service
|
||||
func (c *Client) GetMCPServiceTools(ctx context.Context, serviceID string) ([]*MCPTool, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, fmt.Sprintf("/api/v1/mcp-services/%s/tools", serviceID), nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Success bool `json:"success"`
|
||||
Data []*MCPTool `json:"data"`
|
||||
}
|
||||
if err := parseResponse(resp, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.Data, nil
|
||||
}
|
||||
|
||||
// GetMCPServiceResources gets the resources provided by an MCP service
|
||||
func (c *Client) GetMCPServiceResources(ctx context.Context, serviceID string) ([]*MCPResource, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, fmt.Sprintf("/api/v1/mcp-services/%s/resources", serviceID), nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Success bool `json:"success"`
|
||||
Data []*MCPResource `json:"data"`
|
||||
}
|
||||
if err := parseResponse(resp, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.Data, nil
|
||||
}
|
||||
|
||||
// ResolveToolApprovalRequest is the body for resolving a pending tool-approval
|
||||
// raised during an agent run (session ask). Decision is "approve" or "reject".
|
||||
// ModifiedArgs optionally replaces the tool call arguments on approve; it must
|
||||
// be a JSON object when present.
|
||||
type ResolveToolApprovalRequest struct {
|
||||
Decision string `json:"decision"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
ModifiedArgs json.RawMessage `json:"modified_args,omitempty"`
|
||||
}
|
||||
|
||||
// ResolveToolApproval resolves a pending tool approval by id.
|
||||
// Server route: POST /api/v1/agent/tool-approvals/{pending_id}.
|
||||
func (c *Client) ResolveToolApproval(ctx context.Context, pendingID string, req *ResolveToolApprovalRequest) error {
|
||||
path := fmt.Sprintf("/api/v1/agent/tool-approvals/%s", pendingID)
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, path, req, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var response struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
return parseResponse(resp, &response)
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Memory settings, items, topics and document affinity all operate on the
|
||||
// caller's own memory space. There is no subject id on the wire: the server
|
||||
// derives identity from the credentials, so a scoped API key cannot inherit
|
||||
// another person's memories. Full-access API keys or a Bearer session are
|
||||
// required.
|
||||
|
||||
// MemorySettings is the merged workspace + personal memory switch.
|
||||
type MemorySettings struct {
|
||||
WorkspaceEnabled bool `json:"workspace_enabled"`
|
||||
UserEnabled bool `json:"user_enabled"`
|
||||
Effective bool `json:"effective"`
|
||||
WriteMode string `json:"write_mode"`
|
||||
ItemCount int `json:"item_count"`
|
||||
MaxItems int `json:"max_items"`
|
||||
}
|
||||
|
||||
// MemoryItem is one long-term memory row as returned by the API.
|
||||
type MemoryItem struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Content string `json:"content"`
|
||||
Topic string `json:"topic,omitempty"`
|
||||
Importance int `json:"importance"`
|
||||
Origin string `json:"origin"`
|
||||
Status string `json:"status"`
|
||||
SourceSessionID string `json:"source_session_id,omitempty"`
|
||||
SourceMessageID string `json:"source_message_id,omitempty"`
|
||||
ValidFrom time.Time `json:"valid_from"`
|
||||
InvalidAt *time.Time `json:"invalid_at,omitempty"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
SupersededBy string `json:"superseded_by,omitempty"`
|
||||
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
||||
UseCount int `json:"use_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// MemoryTopic is a subject the extractor is still counting before promoting
|
||||
// it to a long-term interest.
|
||||
type MemoryTopic struct {
|
||||
ID string `json:"id"`
|
||||
Topic string `json:"topic"`
|
||||
Aliases []string `json:"aliases"`
|
||||
Hits int `json:"hits"`
|
||||
Threshold int `json:"threshold"`
|
||||
LastSeenAt time.Time `json:"last_seen_at"`
|
||||
}
|
||||
|
||||
// MemoryDocument is a knowledge entry this person keeps drawing answers from.
|
||||
type MemoryDocument struct {
|
||||
ID string `json:"id"`
|
||||
KnowledgeID string `json:"knowledge_id"`
|
||||
KnowledgeBaseID string `json:"knowledge_base_id"`
|
||||
Title string `json:"title"`
|
||||
Hits int `json:"hits"`
|
||||
LastUsedAt time.Time `json:"last_used_at"`
|
||||
}
|
||||
|
||||
// MemoryConsolidationResult is what POST /memory/consolidate returns.
|
||||
type MemoryConsolidationResult struct {
|
||||
Merged int `json:"merged"`
|
||||
Demoted int `json:"demoted"`
|
||||
Expired int `json:"expired"`
|
||||
Reviewed int `json:"reviewed"`
|
||||
Candidates int `json:"candidates"`
|
||||
Skipped string `json:"skipped,omitempty"`
|
||||
}
|
||||
|
||||
// MemoryExport is the JSON snapshot from GET /memory/export.
|
||||
type MemoryExport struct {
|
||||
Total int64 `json:"total"`
|
||||
Truncated bool `json:"truncated"`
|
||||
Items []*MemoryItem `json:"data"`
|
||||
}
|
||||
|
||||
type memorySettingsResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data *MemorySettings `json:"data"`
|
||||
}
|
||||
|
||||
type memoryItemResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data *MemoryItem `json:"data"`
|
||||
}
|
||||
|
||||
type memoryListResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data []*MemoryItem `json:"data"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
type memoryTopicListResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data []*MemoryTopic `json:"data"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
type memoryDocumentListResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data []*MemoryDocument `json:"data"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
type memoryClearResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Removed int64 `json:"removed"`
|
||||
}
|
||||
|
||||
type memoryConsolidateResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data *MemoryConsolidationResult `json:"data"`
|
||||
}
|
||||
|
||||
func memoryListQuery(status string, limit, offset int) url.Values {
|
||||
q := url.Values{}
|
||||
if status != "" {
|
||||
q.Set("status", status)
|
||||
}
|
||||
if limit > 0 {
|
||||
q.Set("limit", strconv.Itoa(limit))
|
||||
}
|
||||
if offset > 0 {
|
||||
q.Set("offset", strconv.Itoa(offset))
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
// GetMemorySettings returns the merged workspace + personal memory switch.
|
||||
func (c *Client) GetMemorySettings(ctx context.Context) (*MemorySettings, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, "/api/v1/memory/settings", nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out memorySettingsResponse
|
||||
if err := parseResponse(resp, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out.Data, nil
|
||||
}
|
||||
|
||||
// UpdateMemorySettings turns the caller's own long-term memory on or off.
|
||||
func (c *Client) UpdateMemorySettings(ctx context.Context, enabled bool) (*MemorySettings, error) {
|
||||
body := map[string]bool{"enabled": enabled}
|
||||
resp, err := c.doRequest(ctx, http.MethodPut, "/api/v1/memory/settings", body, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out memorySettingsResponse
|
||||
if err := parseResponse(resp, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out.Data, nil
|
||||
}
|
||||
|
||||
// ListMemoryItems pages through the caller's memories. status may be empty
|
||||
// (all) or one of active / superseded / archived / pending.
|
||||
func (c *Client) ListMemoryItems(ctx context.Context, status string, limit, offset int) ([]*MemoryItem, int64, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, "/api/v1/memory/items", nil, memoryListQuery(status, limit, offset))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var out memoryListResponse
|
||||
if err := parseResponse(resp, &out); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return out.Data, out.Total, nil
|
||||
}
|
||||
|
||||
// CreateMemoryItem manually adds a long-term memory. kind is one of
|
||||
// profile / preference / fact / task / interest.
|
||||
func (c *Client) CreateMemoryItem(ctx context.Context, kind, content string, importance int) (*MemoryItem, error) {
|
||||
body := map[string]interface{}{
|
||||
"kind": kind,
|
||||
"content": content,
|
||||
"importance": importance,
|
||||
}
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, "/api/v1/memory/items", body, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out memoryItemResponse
|
||||
if err := parseResponse(resp, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out.Data, nil
|
||||
}
|
||||
|
||||
// UpdateMemoryItem changes content and importance. After an edit the extractor
|
||||
// will not overwrite the row.
|
||||
func (c *Client) UpdateMemoryItem(ctx context.Context, id, content string, importance int) (*MemoryItem, error) {
|
||||
if id == "" {
|
||||
return nil, fmt.Errorf("memory id is required")
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"content": content,
|
||||
"importance": importance,
|
||||
}
|
||||
path := "/api/v1/memory/items/" + url.PathEscape(id)
|
||||
resp, err := c.doRequest(ctx, http.MethodPut, path, body, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out memoryItemResponse
|
||||
if err := parseResponse(resp, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out.Data, nil
|
||||
}
|
||||
|
||||
// DeleteMemoryItem permanently removes one memory.
|
||||
func (c *Client) DeleteMemoryItem(ctx context.Context, id string) error {
|
||||
if id == "" {
|
||||
return fmt.Errorf("memory id is required")
|
||||
}
|
||||
path := "/api/v1/memory/items/" + url.PathEscape(id)
|
||||
resp, err := c.doRequest(ctx, http.MethodDelete, path, nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return parseResponse(resp, nil)
|
||||
}
|
||||
|
||||
// ConfirmMemoryItem accepts an inferred (pending) memory so it starts taking
|
||||
// effect.
|
||||
func (c *Client) ConfirmMemoryItem(ctx context.Context, id string) (*MemoryItem, error) {
|
||||
if id == "" {
|
||||
return nil, fmt.Errorf("memory id is required")
|
||||
}
|
||||
path := "/api/v1/memory/items/" + url.PathEscape(id) + "/confirm"
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, path, nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out memoryItemResponse
|
||||
if err := parseResponse(resp, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out.Data, nil
|
||||
}
|
||||
|
||||
// RejectMemoryItem declines an inferred memory and records the rejection so
|
||||
// the extractor does not silently re-add it.
|
||||
func (c *Client) RejectMemoryItem(ctx context.Context, id string) error {
|
||||
if id == "" {
|
||||
return fmt.Errorf("memory id is required")
|
||||
}
|
||||
path := "/api/v1/memory/items/" + url.PathEscape(id) + "/reject"
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, path, nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return parseResponse(resp, nil)
|
||||
}
|
||||
|
||||
// ClearMemoryItems permanently deletes every memory belonging to the caller.
|
||||
func (c *Client) ClearMemoryItems(ctx context.Context) (int64, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodDelete, "/api/v1/memory/items", nil, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var out memoryClearResponse
|
||||
if err := parseResponse(resp, &out); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return out.Removed, nil
|
||||
}
|
||||
|
||||
// ListMemoryTopics pages through subjects the extractor is still counting.
|
||||
func (c *Client) ListMemoryTopics(ctx context.Context, limit, offset int) ([]*MemoryTopic, int64, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, "/api/v1/memory/topics", nil, memoryListQuery("", limit, offset))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var out memoryTopicListResponse
|
||||
if err := parseResponse(resp, &out); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return out.Data, out.Total, nil
|
||||
}
|
||||
|
||||
// PromoteMemoryTopic turns a counted topic into a long-term interest without
|
||||
// waiting for the remaining hits.
|
||||
func (c *Client) PromoteMemoryTopic(ctx context.Context, id string) (*MemoryItem, error) {
|
||||
if id == "" {
|
||||
return nil, fmt.Errorf("topic id is required")
|
||||
}
|
||||
path := "/api/v1/memory/topics/" + url.PathEscape(id) + "/promote"
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, path, nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out memoryItemResponse
|
||||
if err := parseResponse(resp, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out.Data, nil
|
||||
}
|
||||
|
||||
// DeleteMemoryTopic stops tracking a topic that has not been promoted yet.
|
||||
func (c *Client) DeleteMemoryTopic(ctx context.Context, id string) error {
|
||||
if id == "" {
|
||||
return fmt.Errorf("topic id is required")
|
||||
}
|
||||
path := "/api/v1/memory/topics/" + url.PathEscape(id)
|
||||
resp, err := c.doRequest(ctx, http.MethodDelete, path, nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return parseResponse(resp, nil)
|
||||
}
|
||||
|
||||
// ListMemoryDocuments pages through documents this person keeps citing.
|
||||
func (c *Client) ListMemoryDocuments(ctx context.Context, limit, offset int) ([]*MemoryDocument, int64, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, "/api/v1/memory/documents", nil, memoryListQuery("", limit, offset))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var out memoryDocumentListResponse
|
||||
if err := parseResponse(resp, &out); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return out.Data, out.Total, nil
|
||||
}
|
||||
|
||||
// DeleteMemoryDocument stops using one document for personalized retrieval.
|
||||
func (c *Client) DeleteMemoryDocument(ctx context.Context, id string) error {
|
||||
if id == "" {
|
||||
return fmt.Errorf("document affinity id is required")
|
||||
}
|
||||
path := "/api/v1/memory/documents/" + url.PathEscape(id)
|
||||
resp, err := c.doRequest(ctx, http.MethodDelete, path, nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return parseResponse(resp, nil)
|
||||
}
|
||||
|
||||
// ExportMemory downloads a JSON snapshot of every memory belonging to the
|
||||
// caller. Truncated is true only if the safety ceiling clipped the file.
|
||||
func (c *Client) ExportMemory(ctx context.Context) (*MemoryExport, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, "/api/v1/memory/export", nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out MemoryExport
|
||||
out.Items = nil
|
||||
if err := parseResponse(resp, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// ConsolidateMemory merges near-duplicate items and archives expired ones
|
||||
// without waiting for the daily background pass.
|
||||
func (c *Client) ConsolidateMemory(ctx context.Context) (*MemoryConsolidationResult, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, "/api/v1/memory/consolidate", nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out memoryConsolidateResponse
|
||||
if err := parseResponse(resp, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out.Data, nil
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
// Package client provides the implementation for interacting with the WeKnora API
|
||||
// The Message related interfaces are used to manage messages in a session
|
||||
// Messages can be created, retrieved, deleted, and queried
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ToolResult represents the result of a tool execution
|
||||
type ToolResult struct {
|
||||
Success bool `json:"success"` // Whether the tool executed successfully
|
||||
Output string `json:"output"` // Human-readable output
|
||||
Data map[string]interface{} `json:"data,omitempty"` // Structured data for programmatic use
|
||||
Error string `json:"error,omitempty"` // Error message if execution failed
|
||||
Images []string `json:"images,omitempty"` // Base64 data URIs from tool (e.g. MCP image content)
|
||||
}
|
||||
|
||||
// ToolCall represents a single tool invocation within an agent step
|
||||
type ToolCall struct {
|
||||
ID string `json:"id"` // Function call ID from LLM
|
||||
Name string `json:"name"` // Tool name
|
||||
Args map[string]interface{} `json:"args"` // Tool arguments
|
||||
Result *ToolResult `json:"result"` // Execution result
|
||||
Reflection string `json:"reflection,omitempty"` // Agent's reflection on this tool call result
|
||||
Duration int64 `json:"duration"` // Execution time in milliseconds
|
||||
}
|
||||
|
||||
// AgentStep represents one iteration of the ReAct loop
|
||||
type AgentStep struct {
|
||||
Iteration int `json:"iteration"` // Iteration number (0-indexed)
|
||||
Thought string `json:"thought"` // LLM's reasoning/thinking (Think phase)
|
||||
ToolCalls []ToolCall `json:"tool_calls"` // Tools called in this step (Act phase)
|
||||
Timestamp time.Time `json:"timestamp"` // When this step occurred
|
||||
}
|
||||
|
||||
// Message message information
|
||||
type Message struct {
|
||||
ID string `json:"id"`
|
||||
SessionID string `json:"session_id"`
|
||||
RequestID string `json:"request_id"`
|
||||
Content string `json:"content"`
|
||||
Role string `json:"role"`
|
||||
KnowledgeReferences []*SearchResult `json:"knowledge_references"`
|
||||
AgentSteps []AgentStep `json:"agent_steps,omitempty"` // Agent execution steps (only for assistant messages)
|
||||
IsCompleted bool `json:"is_completed"`
|
||||
Channel string `json:"channel,omitempty"` // Source channel: "web", "api", "im", etc.
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// MessageListResponse message list response
|
||||
type MessageListResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data []Message `json:"data"`
|
||||
}
|
||||
|
||||
// LoadMessages loads session messages, supports pagination and time filtering.
|
||||
// Pass ResourceURLOptions to request public HTTP(S) file URLs instead of
|
||||
// resource:// handles.
|
||||
func (c *Client) LoadMessages(
|
||||
ctx context.Context,
|
||||
sessionID string,
|
||||
limit int,
|
||||
beforeTime *time.Time,
|
||||
opts ...ResourceURLOptions,
|
||||
) ([]Message, error) {
|
||||
path := fmt.Sprintf("/api/v1/messages/%s/load", sessionID)
|
||||
|
||||
queryParams := url.Values{}
|
||||
queryParams.Add("limit", strconv.Itoa(limit))
|
||||
|
||||
if beforeTime != nil {
|
||||
queryParams.Add("before_time", beforeTime.Format(time.RFC3339Nano))
|
||||
}
|
||||
if len(opts) > 0 {
|
||||
applyResourceURLQuery(queryParams, &opts[0])
|
||||
}
|
||||
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, path, nil, queryParams)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response MessageListResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return response.Data, nil
|
||||
}
|
||||
|
||||
// GetRecentMessages gets recent messages from a session
|
||||
func (c *Client) GetRecentMessages(ctx context.Context, sessionID string, limit int) ([]Message, error) {
|
||||
return c.LoadMessages(ctx, sessionID, limit, nil)
|
||||
}
|
||||
|
||||
// GetMessagesBefore gets messages before a specified time
|
||||
func (c *Client) GetMessagesBefore(
|
||||
ctx context.Context,
|
||||
sessionID string,
|
||||
beforeTime time.Time,
|
||||
limit int,
|
||||
) ([]Message, error) {
|
||||
return c.LoadMessages(ctx, sessionID, limit, &beforeTime)
|
||||
}
|
||||
|
||||
// MessageSearchMode is the search strategy for SearchMessages. It mirrors the
|
||||
// server enum in internal/types/message.go.
|
||||
type MessageSearchMode string
|
||||
|
||||
const (
|
||||
// MessageSearchModeKeyword searches by keyword only.
|
||||
MessageSearchModeKeyword MessageSearchMode = "keyword"
|
||||
// MessageSearchModeVector searches by vector similarity only.
|
||||
MessageSearchModeVector MessageSearchMode = "vector"
|
||||
// MessageSearchModeHybrid combines keyword and vector search (server default).
|
||||
MessageSearchModeHybrid MessageSearchMode = "hybrid"
|
||||
)
|
||||
|
||||
// AllMessageSearchModes returns every search mode the server recognises, in a
|
||||
// stable order. Callers (CLI flag validation, docs) should use this instead of
|
||||
// re-typing the set so they can't drift from the SDK.
|
||||
func AllMessageSearchModes() []MessageSearchMode {
|
||||
return []MessageSearchMode{
|
||||
MessageSearchModeKeyword, MessageSearchModeVector, MessageSearchModeHybrid,
|
||||
}
|
||||
}
|
||||
|
||||
// SearchMessagesRequest defines the request structure for searching messages.
|
||||
// Mode is a plain string mirroring the server's request DTO; pass a
|
||||
// MessageSearchMode constant cast to string, or leave empty for the server
|
||||
// default (hybrid).
|
||||
type SearchMessagesRequest struct {
|
||||
Query string `json:"query"`
|
||||
Mode string `json:"mode"`
|
||||
Limit int `json:"limit"`
|
||||
SessionIDs []string `json:"session_ids,omitempty"`
|
||||
}
|
||||
|
||||
// MessageSearchGroupItem represents a grouped search result item
|
||||
type MessageSearchGroupItem struct {
|
||||
RequestID string `json:"request_id"`
|
||||
SessionID string `json:"session_id"`
|
||||
SessionTitle string `json:"session_title"`
|
||||
QueryContent string `json:"query_content"`
|
||||
AnswerContent string `json:"answer_content"`
|
||||
Score float64 `json:"score"`
|
||||
MatchType string `json:"match_type"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// MessageSearchResult represents the result of a message search
|
||||
type MessageSearchResult struct {
|
||||
Items []*MessageSearchGroupItem `json:"items"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// ChatHistoryKBStats represents statistics about the chat history knowledge base
|
||||
type ChatHistoryKBStats struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
EmbeddingModelID string `json:"embedding_model_id,omitempty"`
|
||||
KnowledgeBaseID string `json:"knowledge_base_id,omitempty"`
|
||||
KnowledgeBaseName string `json:"knowledge_base_name,omitempty"`
|
||||
IndexedMessageCount int64 `json:"indexed_message_count"`
|
||||
HasIndexedMessages bool `json:"has_indexed_messages"`
|
||||
}
|
||||
|
||||
// SearchMessages searches chat history messages
|
||||
func (c *Client) SearchMessages(ctx context.Context, req *SearchMessagesRequest) (*MessageSearchResult, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, "/api/v1/messages/search", req, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Success bool `json:"success"`
|
||||
Data *MessageSearchResult `json:"data"`
|
||||
}
|
||||
if err := parseResponse(resp, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.Data, nil
|
||||
}
|
||||
|
||||
// GetChatHistoryKBStats gets chat history knowledge base statistics
|
||||
func (c *Client) GetChatHistoryKBStats(ctx context.Context) (*ChatHistoryKBStats, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, "/api/v1/messages/chat-history-stats", nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Success bool `json:"success"`
|
||||
Data *ChatHistoryKBStats `json:"data"`
|
||||
}
|
||||
if err := parseResponse(resp, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.Data, nil
|
||||
}
|
||||
|
||||
// DeleteMessage deletes a message
|
||||
func (c *Client) DeleteMessage(ctx context.Context, sessionID string, messageID string) error {
|
||||
path := fmt.Sprintf("/api/v1/messages/%s/%s", sessionID, messageID)
|
||||
resp, err := c.doRequest(ctx, http.MethodDelete, path, nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
return parseResponse(resp, &response)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
type MessageSuggestionItem struct {
|
||||
ID string `json:"id"`
|
||||
Text string `json:"text"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Source string `json:"source"`
|
||||
KnowledgeBaseIDs []string `json:"knowledge_base_ids,omitempty"`
|
||||
}
|
||||
|
||||
type MessageSuggestionSet struct {
|
||||
ID string `json:"id"`
|
||||
SessionID string `json:"session_id"`
|
||||
AssistantMessageID string `json:"assistant_message_id"`
|
||||
Status string `json:"status"`
|
||||
AllowRegenerate bool `json:"allow_regenerate"`
|
||||
SuppressionReason string `json:"suppression_reason,omitempty"`
|
||||
Questions []MessageSuggestionItem `json:"questions"`
|
||||
}
|
||||
|
||||
type messageSuggestionResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data MessageSuggestionSet `json:"data"`
|
||||
}
|
||||
|
||||
func (c *Client) EnsureMessageSuggestions(
|
||||
ctx context.Context,
|
||||
sessionID string,
|
||||
messageID string,
|
||||
regenerate bool,
|
||||
) (*MessageSuggestionSet, error) {
|
||||
path := fmt.Sprintf("/api/v1/sessions/%s/messages/%s/suggestions", url.PathEscape(sessionID), url.PathEscape(messageID))
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, path, map[string]bool{"regenerate": regenerate}, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var response messageSuggestionResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &response.Data, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetMessageSuggestions(
|
||||
ctx context.Context,
|
||||
sessionID string,
|
||||
messageID string,
|
||||
) (*MessageSuggestionSet, error) {
|
||||
path := fmt.Sprintf("/api/v1/sessions/%s/messages/%s/suggestions", url.PathEscape(sessionID), url.PathEscape(messageID))
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, path, nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var response messageSuggestionResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &response.Data, nil
|
||||
}
|
||||
|
||||
func (c *Client) RecordMessageSuggestionEvent(
|
||||
ctx context.Context,
|
||||
sessionID string,
|
||||
setID string,
|
||||
questionID string,
|
||||
eventType string,
|
||||
) error {
|
||||
path := fmt.Sprintf("/api/v1/sessions/%s/suggestion-events", url.PathEscape(sessionID))
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, path, map[string]string{
|
||||
"suggestion_set_id": setID,
|
||||
"question_id": questionID,
|
||||
"event_type": eventType,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return parseResponse(resp, nil)
|
||||
}
|
||||
+237
@@ -0,0 +1,237 @@
|
||||
// Package client provides the implementation for interacting with the WeKnora API
|
||||
// The Model related interfaces are used to manage models for different tasks
|
||||
// Models can be created, retrieved, updated, deleted, and queried
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// ModelType represents the type of AI model
|
||||
type ModelType string
|
||||
|
||||
const (
|
||||
ModelTypeEmbedding ModelType = "Embedding" // Embedding model
|
||||
ModelTypeRerank ModelType = "Rerank" // Rerank model
|
||||
ModelTypeKnowledgeQA ModelType = "KnowledgeQA" // KnowledgeQA model
|
||||
ModelTypeVLLM ModelType = "VLLM" // VLLM model
|
||||
ModelTypeASR ModelType = "ASR" // ASR (Automatic Speech Recognition) model
|
||||
)
|
||||
|
||||
// AllModelTypes returns every model type the server recognises, in a stable
|
||||
// order. Callers (CLI flag validation, docs) should use this instead of
|
||||
// re-typing the string set, so they can't drift from the SDK.
|
||||
func AllModelTypes() []ModelType {
|
||||
return []ModelType{
|
||||
ModelTypeEmbedding, ModelTypeRerank, ModelTypeKnowledgeQA, ModelTypeVLLM, ModelTypeASR,
|
||||
}
|
||||
}
|
||||
|
||||
// ModelSource represents the source of the model
|
||||
type ModelSource string
|
||||
|
||||
const (
|
||||
ModelSourceLocal ModelSource = "local" // Local model
|
||||
ModelSourceRemote ModelSource = "remote" // Remote model
|
||||
ModelSourceAliyun ModelSource = "aliyun" // Aliyun DashScope model
|
||||
ModelSourceZhipu ModelSource = "zhipu" // Zhipu model
|
||||
ModelSourceVolcengine ModelSource = "volcengine" // Volcengine model
|
||||
ModelSourceDeepseek ModelSource = "deepseek" // Deepseek model
|
||||
ModelSourceHunyuan ModelSource = "hunyuan" // Hunyuan model
|
||||
ModelSourceMinimax ModelSource = "minimax" // Minimax mode
|
||||
ModelSourceOpenAI ModelSource = "openai" // OpenAI model
|
||||
ModelSourceGemini ModelSource = "gemini" // Gemini model
|
||||
ModelSourceMimo ModelSource = "mimo" // Mimo model
|
||||
ModelSourceSiliconFlow ModelSource = "siliconflow" // SiliconFlow model
|
||||
ModelSourceJina ModelSource = "jina" // Jina AI model
|
||||
ModelSourceOpenRouter ModelSource = "openrouter" // OpenRouter model
|
||||
ModelSourceLiteLLM ModelSource = "litellm" // LiteLLM proxy model
|
||||
ModelSourceRequesty ModelSource = "requesty" // Requesty model
|
||||
ModelSourceNvidia ModelSource = "nvidia" // NVIDIA model
|
||||
ModelSourceNovita ModelSource = "novita" // Novita AI model
|
||||
ModelSourceAzureOpenAI ModelSource = "azure_openai" // Azure OpenAI model
|
||||
)
|
||||
|
||||
// AllModelSources returns every model source the server recognises, in a stable
|
||||
// order. This is the broad set used for FILTERING existing records (model
|
||||
// list --source); creating a model only supports local/remote (the provider
|
||||
// identity goes in ModelParameters.provider). Use this instead of re-typing
|
||||
// the set so callers can't drift from the SDK.
|
||||
func AllModelSources() []ModelSource {
|
||||
return []ModelSource{
|
||||
ModelSourceLocal, ModelSourceRemote, ModelSourceAliyun, ModelSourceZhipu,
|
||||
ModelSourceVolcengine, ModelSourceDeepseek, ModelSourceHunyuan, ModelSourceMinimax,
|
||||
ModelSourceOpenAI, ModelSourceGemini, ModelSourceMimo, ModelSourceSiliconFlow,
|
||||
ModelSourceJina, ModelSourceOpenRouter, ModelSourceLiteLLM, ModelSourceRequesty,
|
||||
ModelSourceNvidia, ModelSourceNovita,
|
||||
ModelSourceAzureOpenAI,
|
||||
}
|
||||
}
|
||||
|
||||
// ModelParameters model parameters
|
||||
type ModelParameters map[string]interface{}
|
||||
|
||||
// Model model information
|
||||
type Model struct {
|
||||
ID string `json:"id"`
|
||||
TenantID uint `json:"tenant_id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Type ModelType `json:"type"`
|
||||
Source ModelSource `json:"source"`
|
||||
Description string `json:"description"`
|
||||
Parameters ModelParameters `json:"parameters"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// CreateModelRequest model creation request
|
||||
type CreateModelRequest struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Type ModelType `json:"type"`
|
||||
Source ModelSource `json:"source"`
|
||||
Description string `json:"description"`
|
||||
Parameters ModelParameters `json:"parameters"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
}
|
||||
|
||||
// UpdateModelRequest model update request
|
||||
type UpdateModelRequest struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description"`
|
||||
Parameters ModelParameters `json:"parameters"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
}
|
||||
|
||||
// ModelResponse model response
|
||||
type ModelResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data Model `json:"data"`
|
||||
}
|
||||
|
||||
// ModelListResponse model list response
|
||||
type ModelListResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data []Model `json:"data"`
|
||||
}
|
||||
|
||||
// CreateModel creates a model
|
||||
func (c *Client) CreateModel(ctx context.Context, request *CreateModelRequest) (*Model, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, "/api/v1/models", request, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response ModelResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &response.Data, nil
|
||||
}
|
||||
|
||||
// GetModel gets a model
|
||||
func (c *Client) GetModel(ctx context.Context, modelID string) (*Model, error) {
|
||||
path := fmt.Sprintf("/api/v1/models/%s", modelID)
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, path, nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response ModelResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &response.Data, nil
|
||||
}
|
||||
|
||||
// ListModels lists all models
|
||||
func (c *Client) ListModels(ctx context.Context) ([]Model, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, "/api/v1/models", nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response ModelListResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return response.Data, nil
|
||||
}
|
||||
|
||||
// UpdateModel updates a model
|
||||
func (c *Client) UpdateModel(ctx context.Context, modelID string, request *UpdateModelRequest) (*Model, error) {
|
||||
path := fmt.Sprintf("/api/v1/models/%s", modelID)
|
||||
resp, err := c.doRequest(ctx, http.MethodPut, path, request, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response ModelResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &response.Data, nil
|
||||
}
|
||||
|
||||
// DeleteModel deletes a model
|
||||
func (c *Client) DeleteModel(ctx context.Context, modelID string) error {
|
||||
path := fmt.Sprintf("/api/v1/models/%s", modelID)
|
||||
resp, err := c.doRequest(ctx, http.MethodDelete, path, nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
return parseResponse(resp, &response)
|
||||
}
|
||||
|
||||
// ModelProvider represents a model provider with its supported types and default URLs
|
||||
type ModelProvider struct {
|
||||
Value string `json:"value"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description"`
|
||||
DefaultURLs map[string]string `json:"defaultUrls"`
|
||||
ModelTypes []string `json:"modelTypes"`
|
||||
}
|
||||
|
||||
// ModelProviderListResponse represents the API response for listing model providers
|
||||
type ModelProviderListResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data []ModelProvider `json:"data"`
|
||||
}
|
||||
|
||||
// ListModelProviders retrieves the list of supported model providers.
|
||||
// modelType is optional and can be used to filter by type: "chat", "embedding", "rerank", "vllm".
|
||||
func (c *Client) ListModelProviders(ctx context.Context, modelType string) ([]ModelProvider, error) {
|
||||
var queryParams url.Values
|
||||
if modelType != "" {
|
||||
queryParams = url.Values{}
|
||||
queryParams.Add("model_type", modelType)
|
||||
}
|
||||
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, "/api/v1/models/providers", nil, queryParams)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response ModelProviderListResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return response.Data, nil
|
||||
}
|
||||
@@ -0,0 +1,643 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Organization represents a collaboration organization
|
||||
type Organization struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Avatar string `json:"avatar,omitempty"`
|
||||
OwnerID string `json:"owner_id"`
|
||||
InviteCode string `json:"invite_code,omitempty"`
|
||||
InviteCodeExpiresAt *time.Time `json:"invite_code_expires_at,omitempty"`
|
||||
InviteCodeValidityDays int `json:"invite_code_validity_days"`
|
||||
RequireApproval bool `json:"require_approval"`
|
||||
Searchable bool `json:"searchable"`
|
||||
MemberLimit int `json:"member_limit"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// OrganizationResponse represents an organization in API responses (with counts)
|
||||
type OrganizationResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Avatar string `json:"avatar,omitempty"`
|
||||
OwnerID string `json:"owner_id"`
|
||||
InviteCode string `json:"invite_code,omitempty"`
|
||||
InviteCodeExpiresAt *time.Time `json:"invite_code_expires_at,omitempty"`
|
||||
InviteCodeValidityDays int `json:"invite_code_validity_days"`
|
||||
RequireApproval bool `json:"require_approval"`
|
||||
Searchable bool `json:"searchable"`
|
||||
MemberLimit int `json:"member_limit"`
|
||||
MemberCount int `json:"member_count"`
|
||||
ShareCount int `json:"share_count"`
|
||||
AgentShareCount int `json:"agent_share_count"`
|
||||
PendingJoinRequestCount int `json:"pending_join_request_count"`
|
||||
IsOwner bool `json:"is_owner"`
|
||||
MyRole string `json:"my_role,omitempty"`
|
||||
HasPendingUpgrade bool `json:"has_pending_upgrade"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// CreateOrganizationRequest represents a request to create an organization
|
||||
type CreateOrganizationRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Avatar string `json:"avatar,omitempty"`
|
||||
InviteCodeValidityDays *int `json:"invite_code_validity_days,omitempty"`
|
||||
MemberLimit *int `json:"member_limit,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateOrganizationRequest represents a request to update an organization
|
||||
type UpdateOrganizationRequest struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Avatar *string `json:"avatar,omitempty"`
|
||||
RequireApproval *bool `json:"require_approval,omitempty"`
|
||||
Searchable *bool `json:"searchable,omitempty"`
|
||||
InviteCodeValidityDays *int `json:"invite_code_validity_days,omitempty"`
|
||||
MemberLimit *int `json:"member_limit,omitempty"`
|
||||
}
|
||||
|
||||
// OrganizationMemberResponse represents a member in API responses
|
||||
type OrganizationMemberResponse struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Avatar string `json:"avatar"`
|
||||
Role string `json:"role"`
|
||||
TenantID uint64 `json:"tenant_id"`
|
||||
JoinedAt time.Time `json:"joined_at"`
|
||||
}
|
||||
|
||||
// KnowledgeBaseShareResponse represents a KB share record in API responses
|
||||
type KnowledgeBaseShareResponse struct {
|
||||
ID string `json:"id"`
|
||||
KnowledgeBaseID string `json:"knowledge_base_id"`
|
||||
KnowledgeBaseName string `json:"knowledge_base_name"`
|
||||
OrganizationID string `json:"organization_id"`
|
||||
OrganizationName string `json:"organization_name"`
|
||||
SharedByUserID string `json:"shared_by_user_id"`
|
||||
SharedByUsername string `json:"shared_by_username"`
|
||||
SourceTenantID uint64 `json:"source_tenant_id"`
|
||||
Permission string `json:"permission"`
|
||||
MyRoleInOrg string `json:"my_role_in_org"`
|
||||
MyPermission string `json:"my_permission"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// AgentShareResponse represents an agent share record in API responses
|
||||
type AgentShareResponse struct {
|
||||
ID string `json:"id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
AgentName string `json:"agent_name"`
|
||||
OrganizationID string `json:"organization_id"`
|
||||
OrganizationName string `json:"organization_name"`
|
||||
SharedByUserID string `json:"shared_by_user_id"`
|
||||
SharedByUsername string `json:"shared_by_username"`
|
||||
SourceTenantID uint64 `json:"source_tenant_id"`
|
||||
Permission string `json:"permission"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// JoinRequestResponse represents a join request in API responses
|
||||
type JoinRequestResponse struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Message string `json:"message"`
|
||||
RequestType string `json:"request_type"`
|
||||
PrevRole string `json:"prev_role"`
|
||||
RequestedRole string `json:"requested_role"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ReviewedAt *time.Time `json:"reviewed_at,omitempty"`
|
||||
}
|
||||
|
||||
// SharedKnowledgeBaseInfo represents a shared knowledge base
|
||||
type SharedKnowledgeBaseInfo struct {
|
||||
ShareID string `json:"share_id"`
|
||||
OrganizationID string `json:"organization_id"`
|
||||
OrgName string `json:"org_name"`
|
||||
Permission string `json:"permission"`
|
||||
SourceTenantID uint64 `json:"source_tenant_id"`
|
||||
SharedAt time.Time `json:"shared_at"`
|
||||
}
|
||||
|
||||
// SharedAgentInfo represents a shared agent
|
||||
type SharedAgentInfo struct {
|
||||
ShareID string `json:"share_id"`
|
||||
OrganizationID string `json:"organization_id"`
|
||||
OrgName string `json:"org_name"`
|
||||
Permission string `json:"permission"`
|
||||
SourceTenantID uint64 `json:"source_tenant_id"`
|
||||
SharedAt time.Time `json:"shared_at"`
|
||||
}
|
||||
|
||||
// UserInfo represents user information for API responses
|
||||
type UserInfo struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Avatar string `json:"avatar"`
|
||||
TenantID uint64 `json:"tenant_id"`
|
||||
IsActive bool `json:"is_active"`
|
||||
CanAccessAllTenants bool `json:"can_access_all_tenants"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// --- Organization CRUD ---
|
||||
|
||||
// CreateOrganization creates a new organization
|
||||
func (c *Client) CreateOrganization(ctx context.Context, req *CreateOrganizationRequest) (*OrganizationResponse, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, "/api/v1/organizations", req, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result struct {
|
||||
Success bool `json:"success"`
|
||||
Data *OrganizationResponse `json:"data"`
|
||||
}
|
||||
if err := parseResponse(resp, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.Data, nil
|
||||
}
|
||||
|
||||
// ListMyOrganizations lists organizations the current user belongs to
|
||||
func (c *Client) ListMyOrganizations(ctx context.Context) ([]OrganizationResponse, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, "/api/v1/organizations", nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Organizations []OrganizationResponse `json:"organizations"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := parseResponse(resp, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.Data.Organizations, nil
|
||||
}
|
||||
|
||||
// GetOrganization gets an organization by ID
|
||||
func (c *Client) GetOrganization(ctx context.Context, orgID string) (*OrganizationResponse, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, fmt.Sprintf("/api/v1/organizations/%s", orgID), nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result struct {
|
||||
Success bool `json:"success"`
|
||||
Data *OrganizationResponse `json:"data"`
|
||||
}
|
||||
if err := parseResponse(resp, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.Data, nil
|
||||
}
|
||||
|
||||
// UpdateOrganization updates an organization
|
||||
func (c *Client) UpdateOrganization(ctx context.Context, orgID string, req *UpdateOrganizationRequest) (*OrganizationResponse, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodPut, fmt.Sprintf("/api/v1/organizations/%s", orgID), req, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result struct {
|
||||
Success bool `json:"success"`
|
||||
Data *OrganizationResponse `json:"data"`
|
||||
}
|
||||
if err := parseResponse(resp, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.Data, nil
|
||||
}
|
||||
|
||||
// DeleteOrganization deletes an organization
|
||||
func (c *Client) DeleteOrganization(ctx context.Context, orgID string) error {
|
||||
resp, err := c.doRequest(ctx, http.MethodDelete, fmt.Sprintf("/api/v1/organizations/%s", orgID), nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return parseResponse(resp, nil)
|
||||
}
|
||||
|
||||
// --- Organization membership ---
|
||||
|
||||
// JoinOrganizationByInviteCode joins an organization using an invite code
|
||||
func (c *Client) JoinOrganizationByInviteCode(ctx context.Context, inviteCode string) error {
|
||||
req := map[string]string{"invite_code": inviteCode}
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, "/api/v1/organizations/join", req, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return parseResponse(resp, nil)
|
||||
}
|
||||
|
||||
// SubmitJoinRequest submits a join request for organizations that require approval
|
||||
func (c *Client) SubmitJoinRequest(ctx context.Context, inviteCode, message, role string) error {
|
||||
req := map[string]string{
|
||||
"invite_code": inviteCode,
|
||||
"message": message,
|
||||
"role": role,
|
||||
}
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, "/api/v1/organizations/join-request", req, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return parseResponse(resp, nil)
|
||||
}
|
||||
|
||||
// SearchOrganizations searches for discoverable organizations
|
||||
func (c *Client) SearchOrganizations(ctx context.Context, keyword string, page, pageSize int) ([]OrganizationResponse, error) {
|
||||
q := url.Values{}
|
||||
if keyword != "" {
|
||||
q.Set("keyword", keyword)
|
||||
}
|
||||
if page > 0 {
|
||||
q.Set("page", fmt.Sprintf("%d", page))
|
||||
}
|
||||
if pageSize > 0 {
|
||||
q.Set("page_size", fmt.Sprintf("%d", pageSize))
|
||||
}
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, "/api/v1/organizations/search", nil, q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Organizations []OrganizationResponse `json:"organizations"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := parseResponse(resp, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.Data.Organizations, nil
|
||||
}
|
||||
|
||||
// JoinByOrganizationID joins a searchable organization by its ID
|
||||
func (c *Client) JoinByOrganizationID(ctx context.Context, orgID, message, role string) error {
|
||||
req := map[string]string{
|
||||
"organization_id": orgID,
|
||||
"message": message,
|
||||
"role": role,
|
||||
}
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, "/api/v1/organizations/join-by-id", req, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return parseResponse(resp, nil)
|
||||
}
|
||||
|
||||
// PreviewOrganizationByInviteCode previews an organization before joining
|
||||
func (c *Client) PreviewOrganizationByInviteCode(ctx context.Context, code string) (*OrganizationResponse, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, fmt.Sprintf("/api/v1/organizations/preview/%s", code), nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result struct {
|
||||
Success bool `json:"success"`
|
||||
Data *OrganizationResponse `json:"data"`
|
||||
}
|
||||
if err := parseResponse(resp, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.Data, nil
|
||||
}
|
||||
|
||||
// LeaveOrganization leaves an organization
|
||||
func (c *Client) LeaveOrganization(ctx context.Context, orgID string) error {
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, fmt.Sprintf("/api/v1/organizations/%s/leave", orgID), nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return parseResponse(resp, nil)
|
||||
}
|
||||
|
||||
// RequestRoleUpgrade requests a role upgrade in an organization
|
||||
func (c *Client) RequestRoleUpgrade(ctx context.Context, orgID, requestedRole, message string) error {
|
||||
req := map[string]string{
|
||||
"requested_role": requestedRole,
|
||||
"message": message,
|
||||
}
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, fmt.Sprintf("/api/v1/organizations/%s/request-upgrade", orgID), req, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return parseResponse(resp, nil)
|
||||
}
|
||||
|
||||
// GenerateInviteCode generates a new invite code for an organization
|
||||
func (c *Client) GenerateInviteCode(ctx context.Context, orgID string) (string, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, fmt.Sprintf("/api/v1/organizations/%s/invite-code", orgID), nil, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var result struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
InviteCode string `json:"invite_code"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := parseResponse(resp, &result); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return result.Data.InviteCode, nil
|
||||
}
|
||||
|
||||
// SearchUsersForInvite searches users to invite into an organization (admin only)
|
||||
func (c *Client) SearchUsersForInvite(ctx context.Context, orgID, keyword string) ([]UserInfo, error) {
|
||||
q := url.Values{}
|
||||
if keyword != "" {
|
||||
q.Set("keyword", keyword)
|
||||
}
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, fmt.Sprintf("/api/v1/organizations/%s/search-users", orgID), nil, q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result struct {
|
||||
Success bool `json:"success"`
|
||||
Data []UserInfo `json:"data"`
|
||||
}
|
||||
if err := parseResponse(resp, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.Data, nil
|
||||
}
|
||||
|
||||
// InviteMember directly invites a user to an organization (admin only)
|
||||
func (c *Client) InviteMember(ctx context.Context, orgID, userID, role string) error {
|
||||
req := map[string]string{
|
||||
"user_id": userID,
|
||||
"role": role,
|
||||
}
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, fmt.Sprintf("/api/v1/organizations/%s/invite", orgID), req, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return parseResponse(resp, nil)
|
||||
}
|
||||
|
||||
// ListMembers lists members of an organization
|
||||
func (c *Client) ListOrgMembers(ctx context.Context, orgID string) ([]OrganizationMemberResponse, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, fmt.Sprintf("/api/v1/organizations/%s/members", orgID), nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Members []OrganizationMemberResponse `json:"members"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := parseResponse(resp, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.Data.Members, nil
|
||||
}
|
||||
|
||||
// UpdateMemberRole updates a member's role in an organization
|
||||
func (c *Client) UpdateMemberRole(ctx context.Context, orgID, userID, role string) error {
|
||||
req := map[string]string{"role": role}
|
||||
resp, err := c.doRequest(ctx, http.MethodPut, fmt.Sprintf("/api/v1/organizations/%s/members/%s", orgID, userID), req, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return parseResponse(resp, nil)
|
||||
}
|
||||
|
||||
// RemoveMember removes a member from an organization
|
||||
func (c *Client) RemoveMember(ctx context.Context, orgID, userID string) error {
|
||||
resp, err := c.doRequest(ctx, http.MethodDelete, fmt.Sprintf("/api/v1/organizations/%s/members/%s", orgID, userID), nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return parseResponse(resp, nil)
|
||||
}
|
||||
|
||||
// --- Join request management ---
|
||||
|
||||
// ListJoinRequests lists pending join requests (admin only)
|
||||
func (c *Client) ListJoinRequests(ctx context.Context, orgID string) ([]JoinRequestResponse, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, fmt.Sprintf("/api/v1/organizations/%s/join-requests", orgID), nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Requests []JoinRequestResponse `json:"requests"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := parseResponse(resp, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.Data.Requests, nil
|
||||
}
|
||||
|
||||
// ReviewJoinRequest reviews a join request (approve/reject)
|
||||
func (c *Client) ReviewJoinRequest(ctx context.Context, orgID, requestID string, approved bool, message, role string) error {
|
||||
req := map[string]any{
|
||||
"approved": approved,
|
||||
"message": message,
|
||||
"role": role,
|
||||
}
|
||||
resp, err := c.doRequest(ctx, http.MethodPut, fmt.Sprintf("/api/v1/organizations/%s/join-requests/%s/review", orgID, requestID), req, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return parseResponse(resp, nil)
|
||||
}
|
||||
|
||||
// --- Knowledge base sharing ---
|
||||
|
||||
// ShareKnowledgeBase shares a knowledge base with an organization
|
||||
func (c *Client) ShareKnowledgeBase(ctx context.Context, kbID, orgID, permission string) (*KnowledgeBaseShareResponse, error) {
|
||||
req := map[string]string{
|
||||
"organization_id": orgID,
|
||||
"permission": permission,
|
||||
}
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, fmt.Sprintf("/api/v1/knowledge-bases/%s/shares", kbID), req, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result struct {
|
||||
Success bool `json:"success"`
|
||||
Data *KnowledgeBaseShareResponse `json:"data"`
|
||||
}
|
||||
if err := parseResponse(resp, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.Data, nil
|
||||
}
|
||||
|
||||
// ListKBShares lists shares of a knowledge base
|
||||
func (c *Client) ListKBShares(ctx context.Context, kbID string) ([]KnowledgeBaseShareResponse, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, fmt.Sprintf("/api/v1/knowledge-bases/%s/shares", kbID), nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Shares []KnowledgeBaseShareResponse `json:"shares"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := parseResponse(resp, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.Data.Shares, nil
|
||||
}
|
||||
|
||||
// UpdateSharePermission updates a KB share's permission
|
||||
func (c *Client) UpdateSharePermission(ctx context.Context, kbID, shareID, permission string) error {
|
||||
req := map[string]string{"permission": permission}
|
||||
resp, err := c.doRequest(ctx, http.MethodPut, fmt.Sprintf("/api/v1/knowledge-bases/%s/shares/%s", kbID, shareID), req, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return parseResponse(resp, nil)
|
||||
}
|
||||
|
||||
// RemoveKBShare removes a KB share
|
||||
func (c *Client) RemoveKBShare(ctx context.Context, kbID, shareID string) error {
|
||||
resp, err := c.doRequest(ctx, http.MethodDelete, fmt.Sprintf("/api/v1/knowledge-bases/%s/shares/%s", kbID, shareID), nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return parseResponse(resp, nil)
|
||||
}
|
||||
|
||||
// --- Agent sharing ---
|
||||
|
||||
// ShareAgent shares an agent with an organization
|
||||
func (c *Client) ShareAgent(ctx context.Context, agentID, orgID, permission string) (*AgentShareResponse, error) {
|
||||
req := map[string]string{
|
||||
"organization_id": orgID,
|
||||
"permission": permission,
|
||||
}
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, fmt.Sprintf("/api/v1/agents/%s/shares", agentID), req, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result struct {
|
||||
Success bool `json:"success"`
|
||||
Data *AgentShareResponse `json:"data"`
|
||||
}
|
||||
if err := parseResponse(resp, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.Data, nil
|
||||
}
|
||||
|
||||
// ListAgentShares lists shares of an agent
|
||||
func (c *Client) ListAgentShares(ctx context.Context, agentID string) ([]AgentShareResponse, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, fmt.Sprintf("/api/v1/agents/%s/shares", agentID), nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Shares []AgentShareResponse `json:"shares"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := parseResponse(resp, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.Data.Shares, nil
|
||||
}
|
||||
|
||||
// RemoveAgentShare removes an agent share
|
||||
func (c *Client) RemoveAgentShare(ctx context.Context, agentID, shareID string) error {
|
||||
resp, err := c.doRequest(ctx, http.MethodDelete, fmt.Sprintf("/api/v1/agents/%s/shares/%s", agentID, shareID), nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return parseResponse(resp, nil)
|
||||
}
|
||||
|
||||
// --- Organization shared resources ---
|
||||
|
||||
// ListOrgShares lists knowledge bases shared to an organization
|
||||
func (c *Client) ListOrgShares(ctx context.Context, orgID string) ([]KnowledgeBaseShareResponse, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, fmt.Sprintf("/api/v1/organizations/%s/shares", orgID), nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Shares []KnowledgeBaseShareResponse `json:"shares"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := parseResponse(resp, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.Data.Shares, nil
|
||||
}
|
||||
|
||||
// ListOrgAgentShares lists agents shared to an organization
|
||||
func (c *Client) ListOrgAgentShares(ctx context.Context, orgID string) ([]AgentShareResponse, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, fmt.Sprintf("/api/v1/organizations/%s/agent-shares", orgID), nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Shares []AgentShareResponse `json:"shares"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := parseResponse(resp, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.Data.Shares, nil
|
||||
}
|
||||
|
||||
// ListSharedKnowledgeBases lists all knowledge bases shared to the current user
|
||||
func (c *Client) ListSharedKnowledgeBases(ctx context.Context) ([]SharedKnowledgeBaseInfo, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, "/api/v1/shared-knowledge-bases", nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result struct {
|
||||
Success bool `json:"success"`
|
||||
Data []SharedKnowledgeBaseInfo `json:"data"`
|
||||
}
|
||||
if err := parseResponse(resp, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.Data, nil
|
||||
}
|
||||
|
||||
// ListSharedAgents lists all agents shared to the current user
|
||||
func (c *Client) ListSharedAgents(ctx context.Context) ([]SharedAgentInfo, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, "/api/v1/shared-agents", nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result struct {
|
||||
Success bool `json:"success"`
|
||||
Data []SharedAgentInfo `json:"data"`
|
||||
}
|
||||
if err := parseResponse(resp, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.Data, nil
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package client
|
||||
|
||||
import "net/url"
|
||||
|
||||
// ResourceURLMode selects how file references are returned in API responses.
|
||||
// Empty leaves the choice to the server (handle by default, or
|
||||
// RESOURCE_URL_MODE when set).
|
||||
type ResourceURLMode string
|
||||
|
||||
const (
|
||||
// ResourceURLModeHandle returns internal resource:// handles; clients fetch
|
||||
// bytes through the authenticated /files proxy.
|
||||
ResourceURLModeHandle ResourceURLMode = "handle"
|
||||
// ResourceURLModePublic returns time-limited HTTP(S) URLs loadable without
|
||||
// WeKnora credentials.
|
||||
ResourceURLModePublic ResourceURLMode = "public"
|
||||
)
|
||||
|
||||
// ResourceURLOptions carries the optional resource_urls query parameter shared
|
||||
// by chat, message-history, knowledge-search, and hybrid-search endpoints.
|
||||
type ResourceURLOptions struct {
|
||||
ResourceURLs ResourceURLMode
|
||||
}
|
||||
|
||||
func applyResourceURLQuery(q url.Values, opts *ResourceURLOptions) {
|
||||
if opts == nil || opts.ResourceURLs == "" || opts.ResourceURLs == ResourceURLModeHandle {
|
||||
return
|
||||
}
|
||||
q.Set("resource_urls", string(opts.ResourceURLs))
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestApplyResourceURLQuery(t *testing.T) {
|
||||
q := url.Values{}
|
||||
applyResourceURLQuery(q, nil)
|
||||
if len(q) != 0 {
|
||||
t.Fatalf("nil opts: got %v, want empty", q)
|
||||
}
|
||||
|
||||
q = url.Values{}
|
||||
applyResourceURLQuery(q, &ResourceURLOptions{ResourceURLs: ResourceURLModeHandle})
|
||||
if len(q) != 0 {
|
||||
t.Fatalf("handle mode: got %v, want empty", q)
|
||||
}
|
||||
|
||||
q = url.Values{}
|
||||
applyResourceURLQuery(q, &ResourceURLOptions{ResourceURLs: ResourceURLModePublic})
|
||||
if got := q.Get("resource_urls"); got != "public" {
|
||||
t.Fatalf("public mode: got %q, want public", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,518 @@
|
||||
// Package client provides the implementation for interacting with the WeKnora API
|
||||
// The Session related interfaces are used to manage sessions for question-answering
|
||||
// Sessions can be created, retrieved, updated, deleted, and queried
|
||||
// They can also be used to generate titles for sessions
|
||||
package client
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SummaryConfig defines summary configuration
|
||||
type SummaryConfig struct {
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
TopP float64 `json:"top_p"`
|
||||
TopK int `json:"top_k"`
|
||||
FrequencyPenalty float64 `json:"frequency_penalty"`
|
||||
PresencePenalty float64 `json:"presence_penalty"`
|
||||
RepeatPenalty float64 `json:"repeat_penalty"`
|
||||
Prompt string `json:"prompt"`
|
||||
ContextTemplate string `json:"context_template"`
|
||||
NoMatchPrefix string `json:"no_match_prefix"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
Seed int `json:"seed"`
|
||||
MaxCompletionTokens int `json:"max_completion_tokens"`
|
||||
Thinking *bool `json:"thinking"`
|
||||
}
|
||||
|
||||
// CreateSessionRequest session creation request
|
||||
// Sessions are now knowledge-base-independent and serve as conversation containers.
|
||||
// All configuration comes from custom agent at query time.
|
||||
type CreateSessionRequest struct {
|
||||
Title string `json:"title"` // Session title (optional)
|
||||
Description string `json:"description"` // Session description (optional)
|
||||
}
|
||||
|
||||
// Session session information
|
||||
type Session struct {
|
||||
ID string `json:"id"`
|
||||
TenantID uint64 `json:"tenant_id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// SessionResponse session response
|
||||
type SessionResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data Session `json:"data"`
|
||||
}
|
||||
|
||||
// SessionListResponse session list response
|
||||
type SessionListResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data []Session `json:"data"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
|
||||
// CreateSession creates a session
|
||||
func (c *Client) CreateSession(ctx context.Context, request *CreateSessionRequest) (*Session, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, "/api/v1/sessions", request, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response SessionResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &response.Data, nil
|
||||
}
|
||||
|
||||
// GetSession gets a session
|
||||
func (c *Client) GetSession(ctx context.Context, sessionID string) (*Session, error) {
|
||||
path := fmt.Sprintf("/api/v1/sessions/%s", sessionID)
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, path, nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response SessionResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &response.Data, nil
|
||||
}
|
||||
|
||||
// GetSessionsByTenant gets all sessions for a tenant
|
||||
func (c *Client) GetSessionsByTenant(ctx context.Context, page int, pageSize int) ([]Session, int, error) {
|
||||
queryParams := url.Values{}
|
||||
queryParams.Add("page", strconv.Itoa(page))
|
||||
queryParams.Add("page_size", strconv.Itoa(pageSize))
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, "/api/v1/sessions", nil, queryParams)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
var response SessionListResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return response.Data, response.Total, nil
|
||||
}
|
||||
|
||||
// UpdateSession updates a session
|
||||
func (c *Client) UpdateSession(ctx context.Context, sessionID string, request *CreateSessionRequest) (*Session, error) {
|
||||
path := fmt.Sprintf("/api/v1/sessions/%s", sessionID)
|
||||
resp, err := c.doRequest(ctx, http.MethodPut, path, request, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response SessionResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &response.Data, nil
|
||||
}
|
||||
|
||||
// DeleteSession deletes a session
|
||||
func (c *Client) DeleteSession(ctx context.Context, sessionID string) error {
|
||||
path := fmt.Sprintf("/api/v1/sessions/%s", sessionID)
|
||||
resp, err := c.doRequest(ctx, http.MethodDelete, path, nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
return parseResponse(resp, &response)
|
||||
}
|
||||
|
||||
// BatchDeleteSessions deletes multiple sessions by their IDs.
|
||||
func (c *Client) BatchDeleteSessions(ctx context.Context, sessionIDs []string) error {
|
||||
request := struct {
|
||||
IDs []string `json:"ids"`
|
||||
}{IDs: sessionIDs}
|
||||
|
||||
resp, err := c.doRequest(ctx, http.MethodDelete, "/api/v1/sessions/batch", request, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
return parseResponse(resp, &response)
|
||||
}
|
||||
|
||||
// GenerateTitleRequest title generation request
|
||||
type GenerateTitleRequest struct {
|
||||
Messages []Message `json:"messages"`
|
||||
}
|
||||
|
||||
// GenerateTitleResponse title generation response
|
||||
type GenerateTitleResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data string `json:"data"`
|
||||
}
|
||||
|
||||
// StopSessionRequest stop generation payload.
|
||||
type StopSessionRequest struct {
|
||||
MessageID string `json:"message_id"`
|
||||
}
|
||||
|
||||
// GenerateTitle generates a session title
|
||||
func (c *Client) GenerateTitle(ctx context.Context, sessionID string, request *GenerateTitleRequest) (string, error) {
|
||||
path := fmt.Sprintf("/api/v1/sessions/%s/generate_title", sessionID)
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, path, request, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var response GenerateTitleResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return response.Data, nil
|
||||
}
|
||||
|
||||
// ImageAttachment represents an image in a chat request.
|
||||
// Frontend sends base64 data in the Data field; the backend saves, runs VLM analysis,
|
||||
// and populates URL/Caption before proceeding with the chat pipeline.
|
||||
type ImageAttachment struct {
|
||||
Data string `json:"data,omitempty"` // base64 data URI (data:image/png;base64,...)
|
||||
URL string `json:"url,omitempty"` // serving URL after saving to storage
|
||||
Caption string `json:"caption,omitempty"` // VLM analysis result
|
||||
}
|
||||
|
||||
// KnowledgeQARequest knowledge Q&A request
|
||||
type KnowledgeQARequest struct {
|
||||
Query string `json:"query"` // Query text for knowledge base search
|
||||
KnowledgeBaseIDs []string `json:"knowledge_base_ids"` // Selected knowledge base IDs for this request
|
||||
KnowledgeIDs []string `json:"knowledge_ids"` // Selected knowledge IDs for this request
|
||||
AgentEnabled bool `json:"agent_enabled"` // Whether agent mode is enabled for this request
|
||||
AgentID string `json:"agent_id"` // Selected custom agent ID for this request
|
||||
WebSearchEnabled bool `json:"web_search_enabled"` // Whether web search is enabled for this request
|
||||
SummaryModelID string `json:"summary_model_id"` // Optional summary model ID (overrides session default)
|
||||
DisableTitle bool `json:"disable_title"` // Whether to disable auto title generation
|
||||
Images []ImageAttachment `json:"images,omitempty"` // Attached images for multimodal chat
|
||||
Channel string `json:"channel,omitempty"` // Source channel: "web", "api", "im", etc.
|
||||
}
|
||||
|
||||
// LLMToolCall represents a function/tool call from the LLM
|
||||
type LLMToolCall struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"` // "function"
|
||||
Function FunctionCall `json:"function"`
|
||||
}
|
||||
|
||||
// FunctionCall represents the function details
|
||||
type FunctionCall struct {
|
||||
Name string `json:"name"`
|
||||
Arguments string `json:"arguments"` // JSON string
|
||||
}
|
||||
|
||||
type ResponseType string
|
||||
|
||||
const (
|
||||
ResponseTypeAnswer ResponseType = "answer"
|
||||
ResponseTypeReferences ResponseType = "references"
|
||||
ResponseTypeThinking ResponseType = "thinking"
|
||||
ResponseTypeToolCall ResponseType = "tool_call"
|
||||
ResponseTypeToolResult ResponseType = "tool_result"
|
||||
ResponseTypeError ResponseType = "error"
|
||||
ResponseTypeReflection ResponseType = "reflection"
|
||||
ResponseTypeSessionTitle ResponseType = "session_title"
|
||||
ResponseTypeAgentQuery ResponseType = "agent_query"
|
||||
ResponseTypeComplete ResponseType = "complete"
|
||||
)
|
||||
|
||||
// StreamResponse streaming response
|
||||
type StreamResponse struct {
|
||||
ID string `json:"id"` // Unique identifier
|
||||
ResponseType ResponseType `json:"response_type"` // Response type
|
||||
Content string `json:"content"` // Current content fragment
|
||||
Done bool `json:"done"` // Whether completed
|
||||
KnowledgeReferences []*SearchResult `json:"knowledge_references,omitempty"` // Knowledge references
|
||||
SessionID string `json:"session_id,omitempty"` // Session ID (for agent_query event)
|
||||
AssistantMessageID string `json:"assistant_message_id,omitempty"` // Assistant Message ID (for agent_query event)
|
||||
ToolCalls []LLMToolCall `json:"tool_calls,omitempty"` // Tool calls for streaming (partial)
|
||||
Data map[string]interface{} `json:"data,omitempty"` // Additional metadata for enhanced display
|
||||
}
|
||||
|
||||
// KnowledgeQAStream knowledge Q&A streaming API.
|
||||
// Pass ResourceURLOptions to receive public HTTP(S) file URLs in the stream.
|
||||
func (c *Client) KnowledgeQAStream(
|
||||
ctx context.Context,
|
||||
sessionID string,
|
||||
request *KnowledgeQARequest,
|
||||
callback func(*StreamResponse) error,
|
||||
opts ...ResourceURLOptions,
|
||||
) error {
|
||||
path := fmt.Sprintf("/api/v1/knowledge-chat/%s", sessionID)
|
||||
debugLogger.Debug("knowledge_qa_stream_start", "session_id", sessionID, "query", request.Query)
|
||||
|
||||
queryParams := url.Values{}
|
||||
if len(opts) > 0 {
|
||||
applyResourceURLQuery(queryParams, &opts[0])
|
||||
}
|
||||
|
||||
resp, err := c.doRequestStream(ctx, http.MethodPost, path, request, queryParams)
|
||||
if err != nil {
|
||||
debugLogger.Debug("request_failed", "error", err)
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
err := newAPIError(resp.StatusCode, body)
|
||||
debugLogger.Debug("request_error_status", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
debugLogger.Debug("sse_connection_established")
|
||||
|
||||
// Use bufio to read SSE data line by line
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
// Default 64KiB per-line cap truncates large SSE data lines (the
|
||||
// references event bundles chunk contents that can reach hundreds of
|
||||
// KiB). Raise the cap so those lines parse instead of erroring with
|
||||
// "bufio.Scanner: token too long".
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
|
||||
var dataBuffer string
|
||||
var eventType string
|
||||
messageCount := 0
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
debugLogger.Debug("sse_line_received", "line", line)
|
||||
|
||||
// Empty line indicates the end of an event
|
||||
if line == "" {
|
||||
if dataBuffer != "" {
|
||||
debugLogger.Debug("sse_data_processing", "data", dataBuffer, "event_type", eventType)
|
||||
var streamResponse StreamResponse
|
||||
if err := json.Unmarshal([]byte(dataBuffer), &streamResponse); err != nil {
|
||||
debugLogger.Debug("sse_parse_failed", "error", err)
|
||||
return fmt.Errorf("failed to parse SSE data: %w", err)
|
||||
}
|
||||
|
||||
messageCount++
|
||||
debugLogger.Debug("sse_message_parsed", "count", messageCount, "done", streamResponse.Done)
|
||||
|
||||
if err := callback(&streamResponse); err != nil {
|
||||
debugLogger.Debug("sse_callback_failed", "error", err)
|
||||
return err
|
||||
}
|
||||
if streamResponse.ResponseType == ResponseTypeError && streamResponse.Done {
|
||||
return NewSSEStreamError(streamResponse.Content)
|
||||
}
|
||||
dataBuffer = ""
|
||||
eventType = ""
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Process lines with event: prefix
|
||||
if strings.HasPrefix(line, "event:") {
|
||||
eventType = line[6:] // Remove "event:" prefix
|
||||
debugLogger.Debug("sse_event_type_set", "event_type", eventType)
|
||||
}
|
||||
|
||||
// Process lines with data: prefix
|
||||
if strings.HasPrefix(line, "data:") {
|
||||
dataBuffer = line[5:] // Remove "data:" prefix
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
debugLogger.Debug("sse_read_failed", "error", err)
|
||||
return fmt.Errorf("failed to read SSE stream: %w", err)
|
||||
}
|
||||
|
||||
debugLogger.Debug("knowledge_qa_stream_completed", "message_count", messageCount)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ContinueStream continues to receive an active stream for a session.
|
||||
// Pass ResourceURLOptions to receive public HTTP(S) file URLs in the stream.
|
||||
func (c *Client) ContinueStream(
|
||||
ctx context.Context,
|
||||
sessionID string,
|
||||
messageID string,
|
||||
callback func(*StreamResponse) error,
|
||||
opts ...ResourceURLOptions,
|
||||
) error {
|
||||
path := fmt.Sprintf("/api/v1/sessions/continue-stream/%s", sessionID)
|
||||
|
||||
queryParams := url.Values{}
|
||||
queryParams.Add("message_id", messageID)
|
||||
if len(opts) > 0 {
|
||||
applyResourceURLQuery(queryParams, &opts[0])
|
||||
}
|
||||
|
||||
resp, err := c.doRequestStream(ctx, http.MethodGet, path, nil, queryParams)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return newAPIError(resp.StatusCode, body)
|
||||
}
|
||||
|
||||
// Use bufio to read SSE data line by line
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
// See KnowledgeQAStream: raise the per-line cap so large SSE data lines
|
||||
// (references event) parse instead of erroring with "token too long".
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
|
||||
var dataBuffer string
|
||||
var eventType string
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
|
||||
// Empty line indicates the end of an event
|
||||
if line == "" {
|
||||
if dataBuffer != "" && eventType == "message" {
|
||||
var streamResponse StreamResponse
|
||||
if err := json.Unmarshal([]byte(dataBuffer), &streamResponse); err != nil {
|
||||
return fmt.Errorf("failed to parse SSE data: %w", err)
|
||||
}
|
||||
|
||||
if err := callback(&streamResponse); err != nil {
|
||||
return err
|
||||
}
|
||||
if streamResponse.ResponseType == ResponseTypeError && streamResponse.Done {
|
||||
return NewSSEStreamError(streamResponse.Content)
|
||||
}
|
||||
dataBuffer = ""
|
||||
eventType = ""
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Process lines with event: prefix
|
||||
if strings.HasPrefix(line, "event:") {
|
||||
eventType = line[6:] // Remove "event:" prefix
|
||||
}
|
||||
|
||||
// Process lines with data: prefix
|
||||
if strings.HasPrefix(line, "data:") {
|
||||
dataBuffer = line[5:] // Remove "data:" prefix
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return fmt.Errorf("failed to read SSE stream: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// StopSession stops the generation for a specific assistant message under a session.
|
||||
func (c *Client) StopSession(ctx context.Context, sessionID string, messageID string) error {
|
||||
if strings.TrimSpace(sessionID) == "" {
|
||||
return fmt.Errorf("sessionID cannot be empty")
|
||||
}
|
||||
if strings.TrimSpace(messageID) == "" {
|
||||
return fmt.Errorf("messageID cannot be empty")
|
||||
}
|
||||
|
||||
path := fmt.Sprintf("/api/v1/sessions/%s/stop", sessionID)
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, path, &StopSessionRequest{
|
||||
MessageID: messageID,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
return parseResponse(resp, &response)
|
||||
}
|
||||
|
||||
// SearchKnowledgeRequest knowledge search request
|
||||
type SearchKnowledgeRequest struct {
|
||||
Query string `json:"query"` // Query content
|
||||
KnowledgeBaseID string `json:"knowledge_base_id,omitempty"` // Single knowledge base ID (for backward compatibility)
|
||||
KnowledgeBaseIDs []string `json:"knowledge_base_ids,omitempty"` // Knowledge base IDs (multi-KB support)
|
||||
KnowledgeIDs []string `json:"knowledge_ids,omitempty"` // Specific knowledge (file) IDs
|
||||
TagIDs []string `json:"tag_ids,omitempty"` // Tag IDs for filtering within a single KB
|
||||
MentionedItems []MentionedItem `json:"mentioned_items,omitempty"` // Optional scoped tag mentions
|
||||
}
|
||||
|
||||
// SearchKnowledgeResponse search results response
|
||||
type SearchKnowledgeResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data []*SearchResult `json:"data"`
|
||||
}
|
||||
|
||||
// SearchKnowledge performs knowledge base search without LLM summarization.
|
||||
// Pass ResourceURLOptions to receive public HTTP(S) file URLs in results.
|
||||
func (c *Client) SearchKnowledge(
|
||||
ctx context.Context,
|
||||
request *SearchKnowledgeRequest,
|
||||
opts ...ResourceURLOptions,
|
||||
) ([]*SearchResult, error) {
|
||||
debugLogger.Debug("search_knowledge_start",
|
||||
"knowledge_base_ids", request.KnowledgeBaseIDs,
|
||||
"knowledge_ids", request.KnowledgeIDs,
|
||||
"query", request.Query,
|
||||
)
|
||||
|
||||
queryParams := url.Values{}
|
||||
if len(opts) > 0 {
|
||||
applyResourceURLQuery(queryParams, &opts[0])
|
||||
}
|
||||
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, "/api/v1/knowledge-search", request, queryParams)
|
||||
if err != nil {
|
||||
debugLogger.Debug("request_failed", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
err := newAPIError(resp.StatusCode, body)
|
||||
debugLogger.Debug("request_error_status", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response SearchKnowledgeResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
debugLogger.Debug("response_parse_failed", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
debugLogger.Debug("search_knowledge_completed", "result_count", len(response.Data))
|
||||
return response.Data, nil
|
||||
}
|
||||
+344
@@ -0,0 +1,344 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// SkillInfo represents skill metadata
|
||||
type SkillInfo struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// SkillListResponse represents the response from listing skills
|
||||
type SkillListResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data []SkillInfo `json:"data"`
|
||||
SkillsAvailable bool `json:"skills_available"`
|
||||
}
|
||||
|
||||
// SandboxSkillInstallResponse is returned when a skill install is accepted.
|
||||
type SandboxSkillInstallResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
SkillID string `json:"skill_id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// ListSkills lists the installed skills a chat turn can invoke on one sandbox
|
||||
// config. An empty sandboxConfigID returns an empty list.
|
||||
func (c *Client) ListSkills(ctx context.Context, sandboxConfigID string) ([]SkillInfo, bool, error) {
|
||||
query := url.Values{}
|
||||
if sandboxConfigID != "" {
|
||||
query.Set("sandbox_config_id", sandboxConfigID)
|
||||
}
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, "/api/v1/skills", nil, query)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
var response SkillListResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
return response.Data, response.SkillsAvailable, nil
|
||||
}
|
||||
|
||||
// InstallSandboxSkillFromSource installs a skill onto a sandbox config from a
|
||||
// public locator. Use "@owner/slug" (ClawHub), a github.com / gitlab.com /
|
||||
// skills.sh / skillhub URL, a ClawHub skills-sh catalog page
|
||||
// (https://clawhub.ai/skills-sh/owner/repo/slug), a skills-sh:owner/repo/slug
|
||||
// locator, or a direct zip/SKILL.md URL. Bare "owner/slug" is rejected as
|
||||
// ambiguous. The call is accepted asynchronously; follow progress on the
|
||||
// skill ID.
|
||||
func (c *Client) InstallSandboxSkillFromSource(
|
||||
ctx context.Context, configID, source string,
|
||||
) (string, error) {
|
||||
if configID == "" {
|
||||
return "", fmt.Errorf("sandbox config ID is required")
|
||||
}
|
||||
body := map[string]string{"source": source}
|
||||
path := "/api/v1/sandbox-configs/" + url.PathEscape(configID) + "/skills"
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, path, body, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var response SandboxSkillInstallResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return response.Data.SkillID, nil
|
||||
}
|
||||
|
||||
// UploadSandboxSkill installs a skill onto a sandbox config from a zip archive.
|
||||
func (c *Client) UploadSandboxSkill(
|
||||
ctx context.Context, configID, filename string, archive []byte,
|
||||
) (string, error) {
|
||||
if configID == "" {
|
||||
return "", fmt.Errorf("sandbox config ID is required")
|
||||
}
|
||||
if filename == "" {
|
||||
filename = "skill.zip"
|
||||
}
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
part, err := writer.CreateFormFile("file", filename)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create skill upload part: %w", err)
|
||||
}
|
||||
if _, err := part.Write(archive); err != nil {
|
||||
return "", fmt.Errorf("write skill upload part: %w", err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
return "", fmt.Errorf("close skill upload form: %w", err)
|
||||
}
|
||||
|
||||
path := "/api/v1/sandbox-configs/" + url.PathEscape(configID) + "/skills"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
c.applyAuthHeaders(ctx, req)
|
||||
req.Body = io.NopCloser(bytes.NewReader(body.Bytes()))
|
||||
req.ContentLength = int64(body.Len())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var response SandboxSkillInstallResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return response.Data.SkillID, nil
|
||||
}
|
||||
|
||||
// ReinstallSandboxSkill runs the install of one skill again from the archive
|
||||
// the server already stores, so a failure that had nothing to do with the
|
||||
// bundle can be retried without re-uploading it. Like the install it is
|
||||
// accepted asynchronously. A skill already serving the current image is left
|
||||
// alone rather than rebuilt.
|
||||
func (c *Client) ReinstallSandboxSkill(
|
||||
ctx context.Context, configID, skillID string,
|
||||
) (string, error) {
|
||||
if configID == "" {
|
||||
return "", fmt.Errorf("sandbox config ID is required")
|
||||
}
|
||||
if skillID == "" {
|
||||
return "", fmt.Errorf("skill ID is required")
|
||||
}
|
||||
path := "/api/v1/sandbox-configs/" + url.PathEscape(configID) +
|
||||
"/skills/" + url.PathEscape(skillID) + "/reinstall"
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, path, nil, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var response SandboxSkillInstallResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return response.Data.SkillID, nil
|
||||
}
|
||||
|
||||
// StopSandboxSkill aborts an in-flight install so the operator can retry or
|
||||
// uninstall. After a process restart the row may still say installing with no
|
||||
// live process; this rewrites it immediately instead of waiting for the
|
||||
// stuck-run reaper. Removal is not stopped.
|
||||
func (c *Client) StopSandboxSkill(
|
||||
ctx context.Context, configID, skillID string,
|
||||
) (*SandboxSkill, error) {
|
||||
if configID == "" {
|
||||
return nil, fmt.Errorf("sandbox config ID is required")
|
||||
}
|
||||
if skillID == "" {
|
||||
return nil, fmt.Errorf("skill ID is required")
|
||||
}
|
||||
path := "/api/v1/sandbox-configs/" + url.PathEscape(configID) +
|
||||
"/skills/" + url.PathEscape(skillID) + "/stop"
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, path, nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var response sandboxSkillResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &response.Data, nil
|
||||
}
|
||||
|
||||
// SandboxSkillEnv is one environment variable an installed skill declared. It
|
||||
// reports whether a workspace-wide value exists and never what it is.
|
||||
type SandboxSkillEnv struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Required bool `json:"required,omitempty"`
|
||||
IsSet bool `json:"is_set"`
|
||||
}
|
||||
|
||||
// SandboxSkill is one installed skill of a sandbox config.
|
||||
type SandboxSkill struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
BundleSHA256 string `json:"bundle_sha256,omitempty"`
|
||||
InstalledSnapshotID string `json:"installed_snapshot_id,omitempty"`
|
||||
InstallSessionID string `json:"install_session_id,omitempty"`
|
||||
InstallMessageID string `json:"install_message_id,omitempty"`
|
||||
Envs []SandboxSkillEnv `json:"envs,omitempty"`
|
||||
}
|
||||
|
||||
type sandboxSkillResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data SandboxSkill `json:"data"`
|
||||
}
|
||||
|
||||
// SandboxSkillUpdate is what one PATCH may change. Both fields are optional,
|
||||
// but a request carrying neither is refused: a nil Enabled is not a request to
|
||||
// hide the skill, and a nil Envs must leave every stored value alone.
|
||||
type SandboxSkillUpdate struct {
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
Envs *map[string]string `json:"envs,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateSandboxSkill shows or hides an installed skill and sets the
|
||||
// workspace-wide values of the environment variables it declared. Visibility is
|
||||
// metadata only: the files stay in the image either way.
|
||||
//
|
||||
// Only declared names are written; a name outside the declaration is ignored
|
||||
// rather than refused, so a stale form cannot fail an otherwise valid save. An
|
||||
// empty string clears a value and keeps the declaration, because "nobody filled
|
||||
// this in" and "this is not needed" are different states.
|
||||
//
|
||||
// A stored value is never read back: the returned Envs report IsSet only.
|
||||
func (c *Client) UpdateSandboxSkill(
|
||||
ctx context.Context, configID, skillID string, update SandboxSkillUpdate,
|
||||
) (*SandboxSkill, error) {
|
||||
if configID == "" {
|
||||
return nil, fmt.Errorf("sandbox config ID is required")
|
||||
}
|
||||
if skillID == "" {
|
||||
return nil, fmt.Errorf("skill ID is required")
|
||||
}
|
||||
if update.Enabled == nil && update.Envs == nil {
|
||||
return nil, fmt.Errorf("enabled or envs is required")
|
||||
}
|
||||
path := "/api/v1/sandbox-configs/" + url.PathEscape(configID) +
|
||||
"/skills/" + url.PathEscape(skillID)
|
||||
resp, err := c.doRequest(ctx, http.MethodPatch, path, update, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var response sandboxSkillResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &response.Data, nil
|
||||
}
|
||||
|
||||
// SetSandboxSkillEnabled is the visibility half of UpdateSandboxSkill.
|
||||
func (c *Client) SetSandboxSkillEnabled(
|
||||
ctx context.Context, configID, skillID string, enabled bool,
|
||||
) (*SandboxSkill, error) {
|
||||
return c.UpdateSandboxSkill(ctx, configID, skillID,
|
||||
SandboxSkillUpdate{Enabled: &enabled})
|
||||
}
|
||||
|
||||
// SetSandboxSkillEnvValues is the credentials half of UpdateSandboxSkill: it
|
||||
// stores values that apply to everybody in the workspace. For a value that
|
||||
// applies to the calling identity alone, use SetMySkillEnvVar.
|
||||
func (c *Client) SetSandboxSkillEnvValues(
|
||||
ctx context.Context, configID, skillID string, values map[string]string,
|
||||
) (*SandboxSkill, error) {
|
||||
return c.UpdateSandboxSkill(ctx, configID, skillID,
|
||||
SandboxSkillUpdate{Envs: &values})
|
||||
}
|
||||
|
||||
// SandboxSkillFile is one path in an installed skill's stored archive.
|
||||
type SandboxSkillFile struct {
|
||||
Path string `json:"path"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// SandboxSkillFileContent is one file opened from an installed skill.
|
||||
type SandboxSkillFileContent struct {
|
||||
Path string `json:"path"`
|
||||
Size int64 `json:"size"`
|
||||
Encoding string `json:"encoding"`
|
||||
Content string `json:"content,omitempty"`
|
||||
MediaType string `json:"media_type,omitempty"`
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
Binary bool `json:"binary,omitempty"`
|
||||
}
|
||||
|
||||
type sandboxSkillFilesResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data []SandboxSkillFile `json:"data"`
|
||||
}
|
||||
|
||||
type sandboxSkillFileContentResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data SandboxSkillFileContent `json:"data"`
|
||||
}
|
||||
|
||||
// ListSandboxSkillFiles lists the stored archive of one installed skill.
|
||||
func (c *Client) ListSandboxSkillFiles(
|
||||
ctx context.Context, configID, skillID string,
|
||||
) ([]SandboxSkillFile, error) {
|
||||
if configID == "" {
|
||||
return nil, fmt.Errorf("sandbox config ID is required")
|
||||
}
|
||||
if skillID == "" {
|
||||
return nil, fmt.Errorf("skill ID is required")
|
||||
}
|
||||
path := "/api/v1/sandbox-configs/" + url.PathEscape(configID) +
|
||||
"/skills/" + url.PathEscape(skillID) + "/files"
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, path, nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var response sandboxSkillFilesResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response.Data, nil
|
||||
}
|
||||
|
||||
// GetSandboxSkillFile reads one skill-root-relative file from an installed skill.
|
||||
func (c *Client) GetSandboxSkillFile(
|
||||
ctx context.Context, configID, skillID, filePath string,
|
||||
) (*SandboxSkillFileContent, error) {
|
||||
if configID == "" {
|
||||
return nil, fmt.Errorf("sandbox config ID is required")
|
||||
}
|
||||
if skillID == "" {
|
||||
return nil, fmt.Errorf("skill ID is required")
|
||||
}
|
||||
if filePath == "" {
|
||||
return nil, fmt.Errorf("skill file path is required")
|
||||
}
|
||||
path := "/api/v1/sandbox-configs/" + url.PathEscape(configID) +
|
||||
"/skills/" + url.PathEscape(skillID) + "/files/content"
|
||||
query := url.Values{}
|
||||
query.Set("path", filePath)
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, path, nil, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var response sandboxSkillFileContentResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &response.Data, nil
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ErrSSEStreamTerminal marks a terminal response_type=error, done=true frame
|
||||
// from an SSE stream. The stream delivered the frame to the callback before
|
||||
// returning the error.
|
||||
var ErrSSEStreamTerminal = errors.New("SSE stream terminal error")
|
||||
|
||||
// SSEStreamError is returned when the server emits a terminal error frame on
|
||||
// an SSE stream.
|
||||
type SSEStreamError struct {
|
||||
Content string
|
||||
}
|
||||
|
||||
func (e *SSEStreamError) Error() string {
|
||||
return fmt.Sprintf("SSE stream error: %s", e.Content)
|
||||
}
|
||||
|
||||
func (e *SSEStreamError) Unwrap() error {
|
||||
return ErrSSEStreamTerminal
|
||||
}
|
||||
|
||||
// NewSSEStreamError builds the terminal SSE stream error returned by the
|
||||
// streaming readers after delivering the error frame to the callback.
|
||||
func NewSSEStreamError(content string) error {
|
||||
return &SSEStreamError{Content: content}
|
||||
}
|
||||
|
||||
// IsSSEStreamError reports whether err is a terminal SSE stream error from the
|
||||
// SDK readers, including wrapped *SSEStreamError values and legacy
|
||||
// fmt.Errorf("SSE stream error: ...") chains.
|
||||
func IsSSEStreamError(err error) bool {
|
||||
var sse *SSEStreamError
|
||||
if errors.As(err, &sse) {
|
||||
return true
|
||||
}
|
||||
for err != nil {
|
||||
if strings.HasPrefix(err.Error(), "SSE stream error: ") {
|
||||
return true
|
||||
}
|
||||
err = errors.Unwrap(err)
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsSSEStreamError(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{"typed", NewSSEStreamError("boom"), true},
|
||||
{"wrapped", fmt.Errorf("request failed: %w", NewSSEStreamError("boom")), true},
|
||||
{"legacy_string", fmt.Errorf("SSE stream error: boom"), true},
|
||||
{"http", fmt.Errorf("HTTP error 500: internal"), false},
|
||||
{"nil", nil, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := IsSSEStreamError(tc.err); got != tc.want {
|
||||
t.Errorf("IsSSEStreamError(%v) = %v, want %v", tc.err, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSEStreamError_Unwrap(t *testing.T) {
|
||||
err := NewSSEStreamError("boom")
|
||||
if !errors.Is(err, ErrSSEStreamTerminal) {
|
||||
t.Fatal("expected ErrSSEStreamTerminal in chain")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// agentSSEEvent serializes resp as one SSE frame — a `data:` line followed by
|
||||
// the empty line that dispatches it — matching the wire shape the SDK
|
||||
// streaming endpoints and processAgentSSEStream consume.
|
||||
func agentSSEEvent(t *testing.T, w io.Writer, resp AgentStreamResponse) {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal agent event: %v", err)
|
||||
}
|
||||
fmt.Fprintf(w, "data: %s\n\n", b)
|
||||
}
|
||||
|
||||
func knowledgeSSEEvent(t *testing.T, w io.Writer, resp StreamResponse, eventType string) {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal knowledge event: %v", err)
|
||||
}
|
||||
if eventType != "" {
|
||||
fmt.Fprintf(w, "event:%s\n", eventType)
|
||||
}
|
||||
fmt.Fprintf(w, "data:%s\n\n", b)
|
||||
}
|
||||
|
||||
// TestProcessAgentSSEStream_DataLineLimits locks in the 4 MiB bufio.Scanner
|
||||
// cap raised from the 64 KiB default: a `references` event bundling chunk
|
||||
// contents reaches hundreds of KiB and previously errored "token too long".
|
||||
func TestProcessAgentSSEStream_DataLineLimits(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
content int // bytes of Content placed in the single data line
|
||||
wantErr bool
|
||||
}{
|
||||
{"256KiB_above_old_default_parses", 256 * 1024, false},
|
||||
{"4MiB_minus_16KiB_just_under_cap_parses", 4*1024*1024 - 16*1024, false},
|
||||
{"5MiB_over_cap_errors", 5 * 1024 * 1024, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var buf strings.Builder
|
||||
agentSSEEvent(t, &buf, AgentStreamResponse{
|
||||
ResponseType: AgentResponseTypeAnswer,
|
||||
Content: strings.Repeat("a", tc.content),
|
||||
})
|
||||
frame := buf.String()
|
||||
|
||||
c := &Client{}
|
||||
var got int
|
||||
err := c.processAgentSSEStream(strings.NewReader(frame), func(*AgentStreamResponse) error {
|
||||
got++
|
||||
return nil
|
||||
})
|
||||
switch {
|
||||
case tc.wantErr && err == nil:
|
||||
t.Fatal("expected token-too-long error, got nil")
|
||||
case tc.wantErr && !strings.Contains(err.Error(), "token too long"):
|
||||
t.Errorf("err = %q, want it to contain 'token too long'", err)
|
||||
case !tc.wantErr && err != nil:
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
case !tc.wantErr && got != 1:
|
||||
t.Errorf("callbacks=%d, want 1", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestKnowledgeQAStream_LargeReferenceLineParses(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
knowledgeSSEEvent(t, w, StreamResponse{
|
||||
ResponseType: ResponseTypeReferences,
|
||||
KnowledgeReferences: []*SearchResult{{
|
||||
ID: "c1",
|
||||
Content: strings.Repeat("a", 256*1024),
|
||||
}},
|
||||
}, "")
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL)
|
||||
var contentLen int
|
||||
err := c.KnowledgeQAStream(context.Background(), "sess", &KnowledgeQARequest{Query: "q"},
|
||||
func(e *StreamResponse) error {
|
||||
contentLen = len(e.KnowledgeReferences[0].Content)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("large knowledge stream event failed: %v", err)
|
||||
}
|
||||
if contentLen != 256*1024 {
|
||||
t.Errorf("content bytes=%d, want %d", contentLen, 256*1024)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContinueStream_LargeReferenceLineParses(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
knowledgeSSEEvent(t, w, StreamResponse{
|
||||
ResponseType: ResponseTypeReferences,
|
||||
KnowledgeReferences: []*SearchResult{{
|
||||
ID: "c1",
|
||||
Content: strings.Repeat("a", 256*1024),
|
||||
}},
|
||||
}, "message")
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL)
|
||||
var contentLen int
|
||||
err := c.ContinueStream(context.Background(), "sess", "msg", func(e *StreamResponse) error {
|
||||
contentLen = len(e.KnowledgeReferences[0].Content)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("large continue-stream event failed: %v", err)
|
||||
}
|
||||
if contentLen != 256*1024 {
|
||||
t.Errorf("content bytes=%d, want %d", contentLen, 256*1024)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentQAStreamWithRequest_DefaultBlanketTimeoutDoesNotCutStream is the
|
||||
// regression test for doRequestStream: the client's ordinary default Timeout
|
||||
// must NOT apply to SSE streams. Shrink that default to 100ms in-package so the
|
||||
// test remains fast; the server completes at 300ms.
|
||||
func TestAgentQAStreamWithRequest_DefaultBlanketTimeoutDoesNotCutStream(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
agentSSEEvent(t, w, AgentStreamResponse{ResponseType: AgentResponseTypeComplete, Done: true})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL)
|
||||
c.httpClient.Timeout = 100 * time.Millisecond // simulate a short ordinary-request default
|
||||
var gotComplete bool
|
||||
err := c.AgentQAStreamWithRequest(context.Background(), "sess",
|
||||
&AgentQARequest{Query: "q", AgentEnabled: true},
|
||||
func(e *AgentStreamResponse) error {
|
||||
if e.ResponseType == AgentResponseTypeComplete {
|
||||
gotComplete = true
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("stream cut by blanket timeout: %v", err)
|
||||
}
|
||||
if !gotComplete {
|
||||
t.Error("complete event never arrived")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentQAStreamWithRequest_ExplicitTimeoutStillApplies preserves the
|
||||
// public WithTimeout contract: callers that explicitly request an upper bound
|
||||
// get it for streaming calls as well as ordinary requests.
|
||||
func TestAgentQAStreamWithRequest_ExplicitTimeoutStillApplies(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
agentSSEEvent(t, w, AgentStreamResponse{ResponseType: AgentResponseTypeComplete, Done: true})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL, WithTimeout(100*time.Millisecond))
|
||||
err := c.AgentQAStreamWithRequest(context.Background(), "sess",
|
||||
&AgentQARequest{Query: "q", AgentEnabled: true},
|
||||
func(*AgentStreamResponse) error { return nil })
|
||||
if err == nil {
|
||||
t.Fatal("expected explicit WithTimeout to stop the stream")
|
||||
}
|
||||
if !errors.Is(err, context.DeadlineExceeded) && !strings.Contains(err.Error(), "Client.Timeout") {
|
||||
t.Errorf("err = %v, want a client-timeout error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentQAStreamWithRequest_TerminalErrorEndsStream verifies that a
|
||||
// response_type=error, done=true frame terminates the SDK call even when the
|
||||
// server leaves the HTTP connection open and never sends `complete` or EOF.
|
||||
func TestAgentQAStreamWithRequest_TerminalErrorEndsStream(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
flusher, _ := w.(http.Flusher)
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
agentSSEEvent(t, w, AgentStreamResponse{ResponseType: AgentResponseTypeError, Content: "boom", Done: true})
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
<-r.Context().Done() // hold open until the client disconnects
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL)
|
||||
var got int
|
||||
start := time.Now()
|
||||
err := c.AgentQAStreamWithRequest(context.Background(), "sess",
|
||||
&AgentQARequest{Query: "q", AgentEnabled: true},
|
||||
func(*AgentStreamResponse) error {
|
||||
got++
|
||||
return nil
|
||||
})
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected terminal SSE error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "SSE stream error: boom") {
|
||||
t.Errorf("err = %v, want terminal SSE error", err)
|
||||
}
|
||||
if elapsed > time.Second {
|
||||
t.Errorf("stream hung: elapsed=%v", elapsed)
|
||||
}
|
||||
if got != 1 {
|
||||
t.Errorf("callbacks=%d, want 1 (error event delivered before return)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKnowledgeQAStream_TerminalErrorEndsStream(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
flusher, _ := w.(http.Flusher)
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
knowledgeSSEEvent(t, w, StreamResponse{
|
||||
ResponseType: ResponseTypeError,
|
||||
Content: "boom",
|
||||
Done: true,
|
||||
}, "")
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
<-r.Context().Done()
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL)
|
||||
var got int
|
||||
start := time.Now()
|
||||
err := c.KnowledgeQAStream(context.Background(), "sess", &KnowledgeQARequest{Query: "q"},
|
||||
func(*StreamResponse) error {
|
||||
got++
|
||||
return nil
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "SSE stream error: boom") {
|
||||
t.Fatalf("err = %v, want terminal SSE error", err)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > time.Second {
|
||||
t.Errorf("stream hung: elapsed=%v", elapsed)
|
||||
}
|
||||
if got != 1 {
|
||||
t.Errorf("callbacks=%d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchResult_DecodesReferenceIndexes guards the KB and chunk-hierarchy
|
||||
// fields used by the CLI's bounded reference projection.
|
||||
func TestSearchResult_DecodesReferenceIndexes(t *testing.T) {
|
||||
raw := `{"id":"c1","knowledge_base_id":"kb1","parent_chunk_id":"p1","sub_chunk_id":["s1","s2"],"content":"x"}`
|
||||
var r SearchResult
|
||||
if err := json.Unmarshal([]byte(raw), &r); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if r.ParentChunkID != "p1" {
|
||||
t.Errorf("ParentChunkID=%q, want p1", r.ParentChunkID)
|
||||
}
|
||||
if r.KnowledgeBaseID != "kb1" {
|
||||
t.Errorf("KnowledgeBaseID=%q, want kb1", r.KnowledgeBaseID)
|
||||
}
|
||||
if len(r.SubChunkID) != 2 || r.SubChunkID[0] != "s1" || r.SubChunkID[1] != "s2" {
|
||||
t.Errorf("SubChunkID=%v, want [s1 s2]", r.SubChunkID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// SystemInfo represents system version and configuration information
|
||||
type SystemInfo struct {
|
||||
Version string `json:"version"`
|
||||
Edition string `json:"edition"`
|
||||
CommitID string `json:"commit_id,omitempty"`
|
||||
BuildTime string `json:"build_time,omitempty"`
|
||||
GoVersion string `json:"go_version,omitempty"`
|
||||
KeywordIndexEngine string `json:"keyword_index_engine,omitempty"`
|
||||
VectorStoreEngine string `json:"vector_store_engine,omitempty"`
|
||||
GraphDatabaseEngine string `json:"graph_database_engine,omitempty"`
|
||||
MinioEnabled bool `json:"minio_enabled,omitempty"`
|
||||
DBVersion string `json:"db_version,omitempty"`
|
||||
DBMigrationError string `json:"db_migration_error,omitempty"`
|
||||
StartedAt string `json:"started_at,omitempty"`
|
||||
UptimeSeconds int64 `json:"uptime_seconds,omitempty"`
|
||||
}
|
||||
|
||||
// ParserEngine represents a document parser engine
|
||||
type ParserEngine struct {
|
||||
Name string `json:"name"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description"`
|
||||
Available bool `json:"available"`
|
||||
}
|
||||
|
||||
// StorageEngineStatusItem describes one storage engine's availability
|
||||
type StorageEngineStatusItem struct {
|
||||
Name string `json:"name"`
|
||||
Allowed bool `json:"allowed"`
|
||||
Available bool `json:"available"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// StorageEngineStatusResponse is the response for storage engine status
|
||||
type StorageEngineStatusResponse struct {
|
||||
Engines []StorageEngineStatusItem `json:"engines"`
|
||||
AllowedProviders []string `json:"allowed_providers"`
|
||||
MinioEnvAvailable bool `json:"minio_env_available"`
|
||||
}
|
||||
|
||||
// StorageCheckRequest is the body for storage engine connectivity check
|
||||
type StorageCheckRequest struct {
|
||||
Provider string `json:"provider"`
|
||||
MinIO json.RawMessage `json:"minio,omitempty"`
|
||||
COS json.RawMessage `json:"cos,omitempty"`
|
||||
TOS json.RawMessage `json:"tos,omitempty"`
|
||||
S3 json.RawMessage `json:"s3,omitempty"`
|
||||
OBS json.RawMessage `json:"obs,omitempty"`
|
||||
}
|
||||
|
||||
// StorageCheckResponse is the response for storage engine check
|
||||
type StorageCheckResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
Message string `json:"message"`
|
||||
BucketCreated bool `json:"bucket_created,omitempty"`
|
||||
}
|
||||
|
||||
// DeploymentCapability describes whether a deployment exposes a feature route.
|
||||
type DeploymentCapability struct {
|
||||
Supported bool `json:"supported"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// DeploymentCapabilitiesData is the payload of GET /system/capabilities.
|
||||
type DeploymentCapabilitiesData struct {
|
||||
Edition string `json:"edition"`
|
||||
Capabilities map[string]DeploymentCapability `json:"capabilities"`
|
||||
}
|
||||
|
||||
// GetSystemInfo gets system version and configuration information
|
||||
func (c *Client) GetSystemInfo(ctx context.Context) (*SystemInfo, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, "/api/v1/system/info", nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Data *SystemInfo `json:"data"`
|
||||
}
|
||||
if err := parseResponse(resp, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.Data, nil
|
||||
}
|
||||
|
||||
// GetDeploymentCapabilities returns the deployment feature snapshot for SPA menu gating.
|
||||
func (c *Client) GetDeploymentCapabilities(ctx context.Context) (*DeploymentCapabilitiesData, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, "/api/v1/system/capabilities", nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Data *DeploymentCapabilitiesData `json:"data"`
|
||||
}
|
||||
if err := parseResponse(resp, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.Data, nil
|
||||
}
|
||||
|
||||
// ListParserEngines lists available document parser engines
|
||||
func (c *Client) ListParserEngines(ctx context.Context) ([]ParserEngine, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, "/api/v1/system/parser-engines", nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Data []ParserEngine `json:"data"`
|
||||
Connected bool `json:"connected"`
|
||||
}
|
||||
if err := parseResponse(resp, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.Data, nil
|
||||
}
|
||||
|
||||
// CheckParserEngines checks parser engine availability with given config overrides
|
||||
func (c *Client) CheckParserEngines(ctx context.Context, config any) ([]ParserEngine, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, "/api/v1/system/parser-engines/check", config, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Data []ParserEngine `json:"data"`
|
||||
}
|
||||
if err := parseResponse(resp, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.Data, nil
|
||||
}
|
||||
|
||||
// ReconnectDocReader reconnects the document parser service to a new address
|
||||
func (c *Client) ReconnectDocReader(ctx context.Context, addr string) error {
|
||||
req := map[string]string{"addr": addr}
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, "/api/v1/system/docreader/reconnect", req, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return parseResponse(resp, nil)
|
||||
}
|
||||
|
||||
// GetStorageEngineStatus gets the availability status of all storage engines
|
||||
func (c *Client) GetStorageEngineStatus(ctx context.Context) (*StorageEngineStatusResponse, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, "/api/v1/system/storage-engine-status", nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Data *StorageEngineStatusResponse `json:"data"`
|
||||
}
|
||||
if err := parseResponse(resp, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.Data, nil
|
||||
}
|
||||
|
||||
// CheckStorageEngine tests connectivity for a storage engine
|
||||
func (c *Client) CheckStorageEngine(ctx context.Context, req *StorageCheckRequest) (*StorageCheckResponse, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, "/api/v1/system/storage-engine-check", req, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Data *StorageCheckResponse `json:"data"`
|
||||
}
|
||||
if err := parseResponse(resp, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.Data, nil
|
||||
}
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Tag represents a knowledge base tag.
|
||||
type Tag struct {
|
||||
ID string `json:"id"`
|
||||
SeqID int64 `json:"seq_id"`
|
||||
TenantID uint64 `json:"tenant_id"`
|
||||
KnowledgeBaseID string `json:"knowledge_base_id"`
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TagWithStats represents tag information along with usage statistics.
|
||||
type TagWithStats struct {
|
||||
Tag
|
||||
KnowledgeCount int64 `json:"knowledge_count"`
|
||||
ChunkCount int64 `json:"chunk_count"`
|
||||
}
|
||||
|
||||
// CreateTagPayload is used to create a new tag.
|
||||
type CreateTagPayload struct {
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color,omitempty"`
|
||||
SortOrder int `json:"sort_order,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateTagPayload is used to update an existing tag.
|
||||
type UpdateTagPayload struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Color *string `json:"color,omitempty"`
|
||||
SortOrder *int `json:"sort_order,omitempty"`
|
||||
}
|
||||
|
||||
// TagsPage contains paginated tag results.
|
||||
type TagsPage struct {
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Tags []TagWithStats `json:"data"`
|
||||
}
|
||||
|
||||
// TagsResponse wraps the paginated tags response.
|
||||
type TagsResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data *TagsPage `json:"data"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Code string `json:"code,omitempty"`
|
||||
}
|
||||
|
||||
// TagResponse wraps a single tag response.
|
||||
type TagResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data *Tag `json:"data"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Code string `json:"code,omitempty"`
|
||||
}
|
||||
|
||||
type tagSimpleResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Code string `json:"code,omitempty"`
|
||||
}
|
||||
|
||||
// ListTags returns paginated tags under a knowledge base.
|
||||
func (c *Client) ListTags(ctx context.Context,
|
||||
knowledgeBaseID string, page, pageSize int, keyword string,
|
||||
) (*TagsPage, error) {
|
||||
path := fmt.Sprintf("/api/v1/knowledge-bases/%s/tags", knowledgeBaseID)
|
||||
query := url.Values{}
|
||||
if page > 0 {
|
||||
query.Add("page", strconv.Itoa(page))
|
||||
}
|
||||
if pageSize > 0 {
|
||||
query.Add("page_size", strconv.Itoa(pageSize))
|
||||
}
|
||||
if keyword != "" {
|
||||
query.Add("keyword", keyword)
|
||||
}
|
||||
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, path, nil, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response TagsResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if response.Data == nil {
|
||||
return &TagsPage{}, nil
|
||||
}
|
||||
return response.Data, nil
|
||||
}
|
||||
|
||||
// CreateTag creates a new tag under a knowledge base.
|
||||
func (c *Client) CreateTag(ctx context.Context,
|
||||
knowledgeBaseID string, payload *CreateTagPayload,
|
||||
) (*Tag, error) {
|
||||
path := fmt.Sprintf("/api/v1/knowledge-bases/%s/tags", knowledgeBaseID)
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, path, payload, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response TagResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response.Data, nil
|
||||
}
|
||||
|
||||
// UpdateTag updates an existing tag.
|
||||
// tagID can be either UUID or seq_id (as string).
|
||||
func (c *Client) UpdateTag(ctx context.Context,
|
||||
knowledgeBaseID, tagID string, payload *UpdateTagPayload,
|
||||
) (*Tag, error) {
|
||||
path := fmt.Sprintf("/api/v1/knowledge-bases/%s/tags/%s", knowledgeBaseID, tagID)
|
||||
resp, err := c.doRequest(ctx, http.MethodPut, path, payload, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response TagResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response.Data, nil
|
||||
}
|
||||
|
||||
// UpdateTagBySeqID updates an existing tag by seq_id.
|
||||
func (c *Client) UpdateTagBySeqID(ctx context.Context,
|
||||
knowledgeBaseID string, tagSeqID int64, payload *UpdateTagPayload,
|
||||
) (*Tag, error) {
|
||||
return c.UpdateTag(ctx, knowledgeBaseID, strconv.FormatInt(tagSeqID, 10), payload)
|
||||
}
|
||||
|
||||
// DeleteTag deletes a tag.
|
||||
// tagID can be either UUID or seq_id (as string).
|
||||
// Set force to true to delete even if the tag is referenced.
|
||||
// Set contentOnly to true to only delete the content under the tag but keep the tag itself.
|
||||
// excludeIDs: seq_ids of chunks to exclude from deletion.
|
||||
func (c *Client) DeleteTag(ctx context.Context,
|
||||
knowledgeBaseID, tagID string, force bool, contentOnly bool, excludeIDs []int64,
|
||||
) error {
|
||||
path := fmt.Sprintf("/api/v1/knowledge-bases/%s/tags/%s", knowledgeBaseID, tagID)
|
||||
query := url.Values{}
|
||||
if force {
|
||||
query.Add("force", "true")
|
||||
}
|
||||
if contentOnly {
|
||||
query.Add("content_only", "true")
|
||||
}
|
||||
|
||||
var body interface{}
|
||||
if len(excludeIDs) > 0 {
|
||||
body = map[string]interface{}{
|
||||
"exclude_ids": excludeIDs,
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := c.doRequest(ctx, http.MethodDelete, path, body, query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var response tagSimpleResponse
|
||||
return parseResponse(resp, &response)
|
||||
}
|
||||
|
||||
// DeleteTagBySeqID deletes a tag by seq_id.
|
||||
func (c *Client) DeleteTagBySeqID(ctx context.Context,
|
||||
knowledgeBaseID string, tagSeqID int64, force bool, contentOnly bool, excludeIDs []int64,
|
||||
) error {
|
||||
return c.DeleteTag(ctx, knowledgeBaseID, strconv.FormatInt(tagSeqID, 10), force, contentOnly, excludeIDs)
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
// Package client provides the implementation for interacting with the WeKnora API
|
||||
// The Tenant related interfaces are used to manage tenants in the system
|
||||
// Tenants can be created, retrieved, updated, deleted, and queried
|
||||
// They can also be used to manage retriever engines for different tasks
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RetrieverEngines defines a collection of retriever engine parameters
|
||||
type RetrieverEngines struct {
|
||||
Engines []RetrieverEngineParams `json:"engines"`
|
||||
}
|
||||
|
||||
// RetrieverEngineParams contains configuration for retriever engines
|
||||
type RetrieverEngineParams struct {
|
||||
RetrieverType string `json:"retriever_type"` // Type of retriever (e.g., keywords, vector)
|
||||
RetrieverEngineType string `json:"retriever_engine_type"` // Type of engine implementing the retriever
|
||||
}
|
||||
|
||||
// Tenant represents tenant information in the system
|
||||
type Tenant struct {
|
||||
ID uint64 `yaml:"id" json:"id" gorm:"primaryKey"`
|
||||
// Tenant name
|
||||
Name string `yaml:"name" json:"name"`
|
||||
// Tenant description
|
||||
Description string `yaml:"description" json:"description"`
|
||||
// Tenant status (active, inactive)
|
||||
Status string `yaml:"status" json:"status" gorm:"default:'active'"`
|
||||
// Configured retrieval engines
|
||||
RetrieverEngines RetrieverEngines `yaml:"retriever_engines" json:"retriever_engines" gorm:"type:json"`
|
||||
// Business/department information
|
||||
Business string `yaml:"business" json:"business"`
|
||||
// Storage quota (Bytes), default is 10GB
|
||||
StorageQuota int64 `yaml:"storage_quota" json:"storage_quota" gorm:"default:10737418240"`
|
||||
// Storage used (Bytes)
|
||||
StorageUsed int64 `yaml:"storage_used" json:"storage_used" gorm:"default:0"`
|
||||
// APIKey is only populated by CreateTenant when the server has
|
||||
// tenant.auto_create_api_key (env WEKNORA_TENANT_AUTO_CREATE_API_KEY)
|
||||
// enabled: it carries the plaintext token of an auto-created full_access
|
||||
// key. Empty otherwise. Save it on receipt — it is never returned again.
|
||||
APIKey string `yaml:"api_key,omitempty" json:"api_key,omitempty"`
|
||||
// Creation timestamp
|
||||
CreatedAt time.Time `yaml:"created_at" json:"created_at"`
|
||||
// Last update timestamp
|
||||
UpdatedAt time.Time `yaml:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
// TenantResponse represents the API response structure for tenant operations
|
||||
type TenantResponse struct {
|
||||
Success bool `json:"success"` // Whether the operation was successful
|
||||
Data Tenant `json:"data"` // Tenant data
|
||||
}
|
||||
|
||||
// TenantListResponse represents the API response structure for listing tenants
|
||||
type TenantListResponse struct {
|
||||
Success bool `json:"success"` // Whether the operation was successful
|
||||
Data struct {
|
||||
Items []Tenant `json:"items"` // List of tenant items
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// TenantAPIKeyRole is the tenant RBAC role bound to a revocable API key.
|
||||
type TenantAPIKeyRole string
|
||||
|
||||
const (
|
||||
TenantAPIKeyRoleViewer TenantAPIKeyRole = "viewer"
|
||||
TenantAPIKeyRoleContributor TenantAPIKeyRole = "contributor"
|
||||
TenantAPIKeyRoleAdmin TenantAPIKeyRole = "admin"
|
||||
)
|
||||
|
||||
// TenantAPIKey is the API key metadata returned by list/create APIs.
|
||||
type TenantAPIKey struct {
|
||||
ID uint64 `json:"id"`
|
||||
TenantID uint64 `json:"tenant_id"`
|
||||
ScopeType string `json:"scope_type"`
|
||||
Name string `json:"name"`
|
||||
APIKey string `json:"api_key"`
|
||||
Role TenantAPIKeyRole `json:"role"`
|
||||
FullAccess bool `json:"full_access"`
|
||||
KnowledgeBaseIDs []string `json:"knowledge_base_ids"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// CreateTenantAPIKeyRequest creates a revocable tenant API key.
|
||||
type CreateTenantAPIKeyRequest struct {
|
||||
Name string `json:"name"`
|
||||
Role TenantAPIKeyRole `json:"role,omitempty"`
|
||||
FullAccess bool `json:"full_access,omitempty"`
|
||||
KnowledgeBaseIDs []string `json:"knowledge_base_ids,omitempty"`
|
||||
Capabilities []string `json:"capabilities,omitempty"`
|
||||
ExpiresAtUnix *int64 `json:"expires_at_unix,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateTenantAPIKeyRequest replaces an existing tenant API key's configurable attributes.
|
||||
type UpdateTenantAPIKeyRequest struct {
|
||||
Name string `json:"name"`
|
||||
FullAccess bool `json:"full_access"`
|
||||
KnowledgeBaseIDs []string `json:"knowledge_base_ids"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
ExpiresAtUnix *int64 `json:"expires_at_unix"`
|
||||
}
|
||||
|
||||
// CreatedTenantAPIKey includes the created API key. Token is kept for
|
||||
// backward-compatible clients; APIKey is also returned by list APIs.
|
||||
type CreatedTenantAPIKey struct {
|
||||
TenantAPIKey
|
||||
Token string `json:"token,omitempty"`
|
||||
}
|
||||
|
||||
type tenantAPIKeyListResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data []TenantAPIKey `json:"data"`
|
||||
}
|
||||
|
||||
type tenantAPIKeyCreateResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data CreatedTenantAPIKey `json:"data"`
|
||||
}
|
||||
|
||||
// CreateTenant creates a new tenant
|
||||
func (c *Client) CreateTenant(ctx context.Context, tenant *Tenant) (*Tenant, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, "/api/v1/tenants", tenant, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response TenantResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &response.Data, nil
|
||||
}
|
||||
|
||||
// GetTenant retrieves a tenant by ID
|
||||
func (c *Client) GetTenant(ctx context.Context, tenantID uint64) (*Tenant, error) {
|
||||
path := fmt.Sprintf("/api/v1/tenants/%d", tenantID)
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, path, nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response TenantResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &response.Data, nil
|
||||
}
|
||||
|
||||
// UpdateTenant updates an existing tenant
|
||||
func (c *Client) UpdateTenant(ctx context.Context, tenant *Tenant) (*Tenant, error) {
|
||||
path := fmt.Sprintf("/api/v1/tenants/%d", tenant.ID)
|
||||
resp, err := c.doRequest(ctx, http.MethodPut, path, tenant, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response TenantResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &response.Data, nil
|
||||
}
|
||||
|
||||
// DeleteTenant removes a tenant by ID
|
||||
func (c *Client) DeleteTenant(ctx context.Context, tenantID uint64) error {
|
||||
path := fmt.Sprintf("/api/v1/tenants/%d", tenantID)
|
||||
resp, err := c.doRequest(ctx, http.MethodDelete, path, nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
return parseResponse(resp, &response)
|
||||
}
|
||||
|
||||
// ListTenants retrieves all tenants
|
||||
func (c *Client) ListTenants(ctx context.Context) ([]Tenant, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, "/api/v1/tenants", nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response TenantListResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return response.Data.Items, nil
|
||||
}
|
||||
|
||||
// ListAllTenants retrieves all tenants in the system (requires cross-tenant access)
|
||||
func (c *Client) ListAllTenants(ctx context.Context) ([]Tenant, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, "/api/v1/tenants/all", nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response TenantListResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return response.Data.Items, nil
|
||||
}
|
||||
|
||||
// TenantSearchResponse represents the API response for searching tenants
|
||||
type TenantSearchResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Items []Tenant `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// SearchTenants searches tenants with pagination (requires cross-tenant access)
|
||||
func (c *Client) SearchTenants(ctx context.Context, keyword string, tenantID uint64, page, pageSize int) ([]Tenant, int64, error) {
|
||||
queryParams := url.Values{}
|
||||
if keyword != "" {
|
||||
queryParams.Set("keyword", keyword)
|
||||
}
|
||||
if tenantID > 0 {
|
||||
queryParams.Set("tenant_id", strconv.FormatUint(tenantID, 10))
|
||||
}
|
||||
queryParams.Set("page", strconv.Itoa(page))
|
||||
queryParams.Set("page_size", strconv.Itoa(pageSize))
|
||||
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, "/api/v1/tenants/search", nil, queryParams)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
var response TenantSearchResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return response.Data.Items, response.Data.Total, nil
|
||||
}
|
||||
|
||||
// ListTenantAPIKeys lists API keys for a tenant.
|
||||
func (c *Client) ListTenantAPIKeys(ctx context.Context, tenantID uint64) ([]TenantAPIKey, error) {
|
||||
path := fmt.Sprintf("/api/v1/tenants/%d/api-keys", tenantID)
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, path, nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response tenantAPIKeyListResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response.Data, nil
|
||||
}
|
||||
|
||||
// CreateTenantAPIKey creates a scoped API key.
|
||||
func (c *Client) CreateTenantAPIKey(
|
||||
ctx context.Context, tenantID uint64, req *CreateTenantAPIKeyRequest,
|
||||
) (*CreatedTenantAPIKey, error) {
|
||||
path := fmt.Sprintf("/api/v1/tenants/%d/api-keys", tenantID)
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, path, req, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response tenantAPIKeyCreateResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &response.Data, nil
|
||||
}
|
||||
|
||||
// UpdateTenantAPIKey replaces an existing tenant API key's configuration.
|
||||
func (c *Client) UpdateTenantAPIKey(
|
||||
ctx context.Context, tenantID uint64, keyID uint64, req *UpdateTenantAPIKeyRequest,
|
||||
) (*TenantAPIKey, error) {
|
||||
path := fmt.Sprintf("/api/v1/tenants/%d/api-keys/%d", tenantID, keyID)
|
||||
resp, err := c.doRequest(ctx, http.MethodPut, path, req, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var response struct {
|
||||
Success bool `json:"success"`
|
||||
Data TenantAPIKey `json:"data"`
|
||||
}
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &response.Data, nil
|
||||
}
|
||||
|
||||
// DeleteTenantAPIKey revokes a tenant API key.
|
||||
func (c *Client) DeleteTenantAPIKey(ctx context.Context, tenantID uint64, keyID uint64) error {
|
||||
path := fmt.Sprintf("/api/v1/tenants/%d/api-keys/%d", tenantID, keyID)
|
||||
resp, err := c.doRequest(ctx, http.MethodDelete, path, nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
return parseResponse(resp, &response)
|
||||
}
|
||||
|
||||
// GetTenantKV retrieves a tenant KV configuration by key
|
||||
func (c *Client) GetTenantKV(ctx context.Context, key string) (json.RawMessage, error) {
|
||||
path := fmt.Sprintf("/api/v1/tenants/kv/%s", key)
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, path, nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Success bool `json:"success"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
if err := parseResponse(resp, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.Data, nil
|
||||
}
|
||||
|
||||
// UpdateTenantKV updates a tenant KV configuration by key
|
||||
func (c *Client) UpdateTenantKV(ctx context.Context, key string, value any) (json.RawMessage, error) {
|
||||
path := fmt.Sprintf("/api/v1/tenants/kv/%s", key)
|
||||
resp, err := c.doRequest(ctx, http.MethodPut, path, value, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Success bool `json:"success"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
if err := parseResponse(resp, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.Data, nil
|
||||
}
|
||||
|
||||
// APIPrincipalMode controls how X-API-Key requests map to terminal principals.
|
||||
type APIPrincipalMode string
|
||||
|
||||
const (
|
||||
APIPrincipalModeTenant APIPrincipalMode = "tenant"
|
||||
APIPrincipalModeDirect APIPrincipalMode = "direct_header"
|
||||
APIPrincipalModeSignedToken APIPrincipalMode = "signed_token"
|
||||
)
|
||||
|
||||
// APIPrincipalConfig describes tenant API-key principal mapping settings.
|
||||
type APIPrincipalConfig struct {
|
||||
Mode APIPrincipalMode `json:"mode"`
|
||||
DirectHeaderName string `json:"direct_header_name"`
|
||||
SignedTokenHeaderName string `json:"signed_token_header_name"`
|
||||
RequireDirectHeader bool `json:"require_direct_header"`
|
||||
HasHMACSecret bool `json:"has_hmac_secret"`
|
||||
HMACSecret string `json:"hmac_secret,omitempty"`
|
||||
}
|
||||
|
||||
type apiPrincipalConfigResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data APIPrincipalConfig `json:"data"`
|
||||
}
|
||||
|
||||
// UpdateAPIPrincipalConfigRequest updates tenant API-key principal mapping.
|
||||
type UpdateAPIPrincipalConfigRequest struct {
|
||||
Mode APIPrincipalMode `json:"mode"`
|
||||
DirectHeaderName string `json:"direct_header_name,omitempty"`
|
||||
SignedTokenHeaderName string `json:"signed_token_header_name,omitempty"`
|
||||
RequireDirectHeader bool `json:"require_direct_header,omitempty"`
|
||||
HMACSecret string `json:"hmac_secret,omitempty"`
|
||||
}
|
||||
|
||||
// CreateAPIPrincipalTestTokenRequest signs a short-lived JWT for API integration testing.
|
||||
type CreateAPIPrincipalTestTokenRequest struct {
|
||||
ExternalUserID string `json:"external_user_id"`
|
||||
ExpiresInSeconds int `json:"expires_in_seconds,omitempty"`
|
||||
}
|
||||
|
||||
// APIPrincipalTestToken is a short-lived JWT signed with the tenant API principal HMAC secret.
|
||||
type APIPrincipalTestToken struct {
|
||||
Token string `json:"token"`
|
||||
HeaderName string `json:"header_name"`
|
||||
ExpiresInSeconds int `json:"expires_in_seconds"`
|
||||
ExpiresAtUnix int64 `json:"expires_at_unix"`
|
||||
ExternalUserID string `json:"external_user_id"`
|
||||
}
|
||||
|
||||
type apiPrincipalTestTokenResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data APIPrincipalTestToken `json:"data"`
|
||||
}
|
||||
|
||||
// GetAPIPrincipalConfig returns how X-API-Key requests map to principals for a tenant.
|
||||
func (c *Client) GetAPIPrincipalConfig(ctx context.Context, tenantID uint64) (*APIPrincipalConfig, error) {
|
||||
path := fmt.Sprintf("/api/v1/tenants/%d/api-principal-config", tenantID)
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, path, nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response apiPrincipalConfigResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &response.Data, nil
|
||||
}
|
||||
|
||||
// UpdateAPIPrincipalConfig updates how X-API-Key requests map to principals for a tenant.
|
||||
func (c *Client) UpdateAPIPrincipalConfig(
|
||||
ctx context.Context, tenantID uint64, req *UpdateAPIPrincipalConfigRequest,
|
||||
) (*APIPrincipalConfig, error) {
|
||||
path := fmt.Sprintf("/api/v1/tenants/%d/api-principal-config", tenantID)
|
||||
resp, err := c.doRequest(ctx, http.MethodPut, path, req, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response apiPrincipalConfigResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &response.Data, nil
|
||||
}
|
||||
|
||||
// CreateAPIPrincipalTestToken signs a short-lived JWT with the tenant API principal HMAC secret.
|
||||
func (c *Client) CreateAPIPrincipalTestToken(
|
||||
ctx context.Context, tenantID uint64, req *CreateAPIPrincipalTestTokenRequest,
|
||||
) (*APIPrincipalTestToken, error) {
|
||||
path := fmt.Sprintf("/api/v1/tenants/%d/api-principal-test-token", tenantID)
|
||||
resp, err := c.doRequest(ctx, http.MethodPost, path, req, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response apiPrincipalTestTokenResponse
|
||||
if err := parseResponse(resp, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &response.Data, nil
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// WebSearchProvider represents a web search provider
|
||||
type WebSearchProvider struct {
|
||||
Name string `json:"name"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// GetWebSearchProviders returns the list of available web search providers
|
||||
func (c *Client) GetWebSearchProviders(ctx context.Context) ([]json.RawMessage, error) {
|
||||
resp, err := c.doRequest(ctx, http.MethodGet, "/api/v1/web-search/providers", nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Success bool `json:"success"`
|
||||
Data []json.RawMessage `json:"data"`
|
||||
}
|
||||
if err := parseResponse(resp, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.Data, nil
|
||||
}
|
||||
Reference in New Issue
Block a user