mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 17:38:10 +00:00
- IO abstraction layer with OutputChannel routing and capability validation - Three-layer memory (Context-Document-Graph) with TF-IDF relevance pruning - OneBot V11 QQ protocol plugin with Reverse WebSocket client - Plugin system with hot-reload (SKILL.md + native factories) - Knowledge system with TF-IDF vector indexing - Personality system (personal.md) - Text memory (JSONL with rotation) - Change tracker (overlayfs) with rollback - Lua adapter VM - Design document (DESIGN.md) Module: gitcode.com/JianFeeeee/HomeAgent
510 lines
12 KiB
Go
510 lines
12 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
luaVM "gitcode.com/JianFeeeee/HomeAgent/internal/lua"
|
|
)
|
|
|
|
type Message struct {
|
|
Role string `json:"role"`
|
|
Content string `json:"content"`
|
|
ReasoningContent string `json:"reasoning_content,omitempty"`
|
|
ToolCallID string `json:"tool_call_id,omitempty"`
|
|
ToolCalls []ToolCall `json:"-"`
|
|
}
|
|
|
|
func (m Message) MarshalJSON() ([]byte, error) {
|
|
raw := map[string]interface{}{
|
|
"role": m.Role,
|
|
"content": m.Content,
|
|
}
|
|
if m.ReasoningContent != "" {
|
|
raw["reasoning_content"] = m.ReasoningContent
|
|
}
|
|
if m.ToolCallID != "" {
|
|
raw["tool_call_id"] = m.ToolCallID
|
|
}
|
|
if len(m.ToolCalls) > 0 {
|
|
apiTCs := make([]apiToolCall, len(m.ToolCalls))
|
|
for i, tc := range m.ToolCalls {
|
|
argsBytes, _ := json.Marshal(tc.Arguments)
|
|
apiTCs[i] = apiToolCall{
|
|
ID: tc.ID,
|
|
Type: "function",
|
|
Function: apiFunction{
|
|
Name: tc.Name,
|
|
Arguments: string(argsBytes),
|
|
},
|
|
}
|
|
}
|
|
raw["tool_calls"] = apiTCs
|
|
}
|
|
return json.Marshal(raw)
|
|
}
|
|
|
|
type CompletionRequest struct {
|
|
Model string `json:"model,omitempty"`
|
|
Messages []Message `json:"messages"`
|
|
Temperature float64 `json:"temperature,omitempty"`
|
|
MaxTokens int `json:"max_tokens,omitempty"`
|
|
Stream bool `json:"stream,omitempty"`
|
|
Tools []interface{} `json:"tools,omitempty"`
|
|
ToolChoice interface{} `json:"tool_choice,omitempty"`
|
|
ExtraBody map[string]interface{} `json:"-"`
|
|
}
|
|
|
|
func (r *CompletionRequest) MarshalJSON() ([]byte, error) {
|
|
type Alias CompletionRequest
|
|
data, err := json.Marshal((*Alias)(r))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(r.ExtraBody) == 0 {
|
|
return data, nil
|
|
}
|
|
var raw map[string]interface{}
|
|
if err := json.Unmarshal(data, &raw); err != nil {
|
|
return nil, err
|
|
}
|
|
for k, v := range r.ExtraBody {
|
|
raw[k] = v
|
|
}
|
|
return json.Marshal(raw)
|
|
}
|
|
|
|
type CompletionResponse struct {
|
|
Content string `json:"content"`
|
|
FinishReason string `json:"finish_reason,omitempty"`
|
|
TokenUsage TokenUsage `json:"token_usage,omitempty"`
|
|
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
|
}
|
|
|
|
type TokenUsage struct {
|
|
Prompt int `json:"prompt"`
|
|
Completion int `json:"completion"`
|
|
Total int `json:"total"`
|
|
}
|
|
|
|
type ToolCall struct {
|
|
ID string `json:"id"`
|
|
Type string `json:"type"`
|
|
Name string `json:"name"`
|
|
Arguments map[string]interface{} `json:"arguments"`
|
|
}
|
|
|
|
type apiToolCall struct {
|
|
ID string `json:"id"`
|
|
Type string `json:"type"`
|
|
Function apiFunction `json:"function"`
|
|
}
|
|
|
|
type apiFunction struct {
|
|
Name string `json:"name"`
|
|
Arguments string `json:"arguments"`
|
|
}
|
|
|
|
type StreamChunk struct {
|
|
Content string `json:"content"`
|
|
Done bool `json:"done"`
|
|
ToolCall *ToolCall `json:"tool_call,omitempty"`
|
|
}
|
|
|
|
type Provider interface {
|
|
Name() string
|
|
Chat(ctx context.Context, req *CompletionRequest) (*CompletionResponse, error)
|
|
ChatStream(ctx context.Context, req *CompletionRequest) (<-chan StreamChunk, error)
|
|
}
|
|
|
|
type BaseConfig struct {
|
|
Model string `json:"model"`
|
|
BaseURL string `json:"base_url"`
|
|
APIKey string `json:"api_key"`
|
|
Temperature float64 `json:"temperature"`
|
|
MaxTokens int `json:"max_tokens"`
|
|
}
|
|
|
|
type OpenAIProvider struct {
|
|
cfg BaseConfig
|
|
client *http.Client
|
|
}
|
|
|
|
func NewOpenAIProvider(cfg BaseConfig) *OpenAIProvider {
|
|
if cfg.BaseURL == "" {
|
|
cfg.BaseURL = "https://api.openai.com/v1"
|
|
}
|
|
if cfg.Temperature == 0 {
|
|
cfg.Temperature = 0.7
|
|
}
|
|
if cfg.MaxTokens == 0 {
|
|
cfg.MaxTokens = 4096
|
|
}
|
|
return &OpenAIProvider{
|
|
cfg: cfg,
|
|
client: &http.Client{Timeout: 60 * time.Second},
|
|
}
|
|
}
|
|
|
|
func (p *OpenAIProvider) Name() string { return "openai" }
|
|
|
|
func (p *OpenAIProvider) Chat(ctx context.Context, req *CompletionRequest) (*CompletionResponse, error) {
|
|
if req.Model == "" {
|
|
req.Model = p.cfg.Model
|
|
}
|
|
|
|
body, _ := json.Marshal(req)
|
|
httpReq, _ := http.NewRequestWithContext(ctx, "POST", p.cfg.BaseURL+"/chat/completions", strings.NewReader(string(body)))
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
httpReq.Header.Set("Authorization", "Bearer "+p.cfg.APIKey)
|
|
|
|
resp, err := p.client.Do(httpReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("api call: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != 200 {
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
return nil, fmt.Errorf("api error %d: %s", resp.StatusCode, string(respBody))
|
|
}
|
|
|
|
var rawResult struct {
|
|
Choices []struct {
|
|
Message struct {
|
|
Content *string `json:"content"`
|
|
ReasoningContent *string `json:"reasoning_content"`
|
|
ToolCalls []rawToolCall `json:"tool_calls"`
|
|
Role string `json:"role"`
|
|
} `json:"message"`
|
|
FinishReason string `json:"finish_reason"`
|
|
} `json:"choices"`
|
|
Usage struct {
|
|
PromptTokens int `json:"prompt_tokens"`
|
|
CompletionTokens int `json:"completion_tokens"`
|
|
TotalTokens int `json:"total_tokens"`
|
|
} `json:"usage"`
|
|
}
|
|
|
|
if err := json.NewDecoder(resp.Body).Decode(&rawResult); err != nil {
|
|
return nil, fmt.Errorf("decode: %w", err)
|
|
}
|
|
|
|
if len(rawResult.Choices) == 0 {
|
|
return nil, fmt.Errorf("no choices returned")
|
|
}
|
|
|
|
ch := rawResult.Choices[0]
|
|
content := ""
|
|
if ch.Message.Content != nil {
|
|
content = *ch.Message.Content
|
|
}
|
|
|
|
var toolCalls []ToolCall
|
|
for _, tc := range ch.Message.ToolCalls {
|
|
tc := tc
|
|
args := make(map[string]interface{})
|
|
if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil {
|
|
args["_raw"] = tc.Function.Arguments
|
|
}
|
|
toolCalls = append(toolCalls, ToolCall{
|
|
ID: tc.ID,
|
|
Type: tc.Type,
|
|
Name: tc.Function.Name,
|
|
Arguments: args,
|
|
})
|
|
}
|
|
|
|
return &CompletionResponse{
|
|
Content: content,
|
|
FinishReason: ch.FinishReason,
|
|
TokenUsage: TokenUsage{
|
|
Prompt: rawResult.Usage.PromptTokens,
|
|
Completion: rawResult.Usage.CompletionTokens,
|
|
Total: rawResult.Usage.TotalTokens,
|
|
},
|
|
ToolCalls: toolCalls,
|
|
}, nil
|
|
}
|
|
|
|
func (p *OpenAIProvider) ChatStream(ctx context.Context, req *CompletionRequest) (<-chan StreamChunk, error) {
|
|
req.Stream = true
|
|
ch := make(chan StreamChunk, 64)
|
|
|
|
body, _ := json.Marshal(req)
|
|
httpReq, _ := http.NewRequestWithContext(ctx, "POST", p.cfg.BaseURL+"/chat/completions", strings.NewReader(string(body)))
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
httpReq.Header.Set("Authorization", "Bearer "+p.cfg.APIKey)
|
|
|
|
resp, err := p.client.Do(httpReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("stream api: %w", err)
|
|
}
|
|
|
|
go func() {
|
|
defer resp.Body.Close()
|
|
defer close(ch)
|
|
|
|
decoder := json.NewDecoder(resp.Body)
|
|
for {
|
|
var line struct {
|
|
Choices []struct {
|
|
Delta struct {
|
|
Content string `json:"content"`
|
|
} `json:"delta"`
|
|
FinishReason *string `json:"finish_reason"`
|
|
} `json:"choices"`
|
|
}
|
|
|
|
if err := decoder.Decode(&line); err != nil {
|
|
break
|
|
}
|
|
|
|
if len(line.Choices) > 0 {
|
|
ch <- StreamChunk{
|
|
Content: line.Choices[0].Delta.Content,
|
|
Done: line.Choices[0].FinishReason != nil,
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
|
|
return ch, nil
|
|
}
|
|
|
|
type OllamaProvider struct {
|
|
cfg BaseConfig
|
|
client *http.Client
|
|
}
|
|
|
|
func NewOllamaProvider(cfg BaseConfig) *OllamaProvider {
|
|
if cfg.BaseURL == "" {
|
|
cfg.BaseURL = "http://localhost:11434"
|
|
}
|
|
if cfg.Temperature == 0 {
|
|
cfg.Temperature = 0.7
|
|
}
|
|
if cfg.MaxTokens == 0 {
|
|
cfg.MaxTokens = 4096
|
|
}
|
|
return &OllamaProvider{
|
|
cfg: cfg,
|
|
client: &http.Client{Timeout: 120 * time.Second},
|
|
}
|
|
}
|
|
|
|
func (p *OllamaProvider) Name() string { return "ollama" }
|
|
|
|
func (p *OllamaProvider) Chat(ctx context.Context, req *CompletionRequest) (*CompletionResponse, error) {
|
|
ollamaReq := map[string]interface{}{
|
|
"model": req.Model,
|
|
"messages": req.Messages,
|
|
"stream": false,
|
|
"options": map[string]interface{}{
|
|
"temperature": req.Temperature,
|
|
"num_predict": req.MaxTokens,
|
|
},
|
|
}
|
|
|
|
body, _ := json.Marshal(ollamaReq)
|
|
httpReq, _ := http.NewRequestWithContext(ctx, "POST", p.cfg.BaseURL+"/api/chat", strings.NewReader(string(body)))
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := p.client.Do(httpReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("ollama chat: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
var result struct {
|
|
Message struct {
|
|
Content string `json:"content"`
|
|
} `json:"message"`
|
|
DoneReason string `json:"done_reason"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return nil, fmt.Errorf("decode: %w", err)
|
|
}
|
|
|
|
return &CompletionResponse{
|
|
Content: result.Message.Content,
|
|
FinishReason: result.DoneReason,
|
|
}, nil
|
|
}
|
|
|
|
func (p *OllamaProvider) ChatStream(ctx context.Context, req *CompletionRequest) (<-chan StreamChunk, error) {
|
|
ch := make(chan StreamChunk, 64)
|
|
|
|
go func() {
|
|
defer close(ch)
|
|
ch <- StreamChunk{Done: true}
|
|
}()
|
|
|
|
return ch, nil
|
|
}
|
|
|
|
type LuaAdaptedProvider struct {
|
|
name string
|
|
base Provider
|
|
vm *luaVM.VM
|
|
adapter string
|
|
}
|
|
|
|
func NewLuaAdaptedProvider(base Provider, vm *luaVM.VM, adapter string) *LuaAdaptedProvider {
|
|
return &LuaAdaptedProvider{
|
|
name: fmt.Sprintf("lua_%s", adapter),
|
|
base: base,
|
|
vm: vm,
|
|
adapter: adapter,
|
|
}
|
|
}
|
|
|
|
func (p *LuaAdaptedProvider) Name() string { return p.name }
|
|
|
|
func (p *LuaAdaptedProvider) Chat(ctx context.Context, req *CompletionRequest) (*CompletionResponse, error) {
|
|
inputMap := map[string]interface{}{
|
|
"model": req.Model,
|
|
"messages": messagesToMap(req.Messages),
|
|
"temperature": req.Temperature,
|
|
"max_tokens": req.MaxTokens,
|
|
"stream": false,
|
|
}
|
|
|
|
transformed, err := p.vm.CallTransform(p.adapter, inputMap)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("lua transform: %w", err)
|
|
}
|
|
|
|
transformedReq := &CompletionRequest{
|
|
Model: getString(transformed, "model"),
|
|
Temperature: getFloat(transformed, "temperature"),
|
|
MaxTokens: int(getFloat(transformed, "max_tokens")),
|
|
Stream: false,
|
|
}
|
|
|
|
if msgs, ok := transformed["messages"].([]interface{}); ok {
|
|
for _, m := range msgs {
|
|
if mm, ok := m.(map[string]interface{}); ok {
|
|
transformedReq.Messages = append(transformedReq.Messages, Message{
|
|
Role: getString(mm, "role"),
|
|
Content: getString(mm, "content"),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
resp, err := p.base.Chat(ctx, transformedReq)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
func (p *LuaAdaptedProvider) ChatStream(ctx context.Context, req *CompletionRequest) (<-chan StreamChunk, error) {
|
|
return p.base.ChatStream(ctx, req)
|
|
}
|
|
|
|
type ProviderManager struct {
|
|
mu sync.RWMutex
|
|
providers map[string]Provider
|
|
default_ string
|
|
}
|
|
|
|
func NewProviderManager() *ProviderManager {
|
|
return &ProviderManager{
|
|
providers: make(map[string]Provider),
|
|
}
|
|
}
|
|
|
|
func (m *ProviderManager) Register(name string, p Provider) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.providers[name] = p
|
|
if m.default_ == "" {
|
|
m.default_ = name
|
|
}
|
|
}
|
|
|
|
func (m *ProviderManager) SetDefault(name string) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if _, ok := m.providers[name]; !ok {
|
|
return fmt.Errorf("provider %s not found", name)
|
|
}
|
|
m.default_ = name
|
|
return nil
|
|
}
|
|
|
|
func (m *ProviderManager) Get(name string) Provider {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
if name == "" {
|
|
name = m.default_
|
|
}
|
|
return m.providers[name]
|
|
}
|
|
|
|
func (m *ProviderManager) Default() Provider {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
return m.providers[m.default_]
|
|
}
|
|
|
|
func (m *ProviderManager) List() []string {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
var names []string
|
|
for n := range m.providers {
|
|
names = append(names, n)
|
|
}
|
|
return names
|
|
}
|
|
|
|
type rawToolCall struct {
|
|
ID string `json:"id"`
|
|
Type string `json:"type"`
|
|
Function struct {
|
|
Name string `json:"name"`
|
|
Arguments string `json:"arguments"`
|
|
} `json:"function"`
|
|
}
|
|
|
|
func messagesToMap(msgs []Message) []interface{} {
|
|
result := make([]interface{}, len(msgs))
|
|
for i, m := range msgs {
|
|
result[i] = map[string]interface{}{
|
|
"role": m.Role,
|
|
"content": m.Content,
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func getString(m map[string]interface{}, key string) string {
|
|
if v, ok := m[key]; ok {
|
|
if s, ok := v.(string); ok {
|
|
return s
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func getFloat(m map[string]interface{}, key string) float64 {
|
|
if v, ok := m[key]; ok {
|
|
switch n := v.(type) {
|
|
case float64:
|
|
return n
|
|
case int:
|
|
return float64(n)
|
|
}
|
|
}
|
|
return 0
|
|
}
|