Compare commits
116 Commits
f560ecf70b
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c65c11bcfb | ||
|
|
04f1fd464b | ||
|
|
6e9c453ee0 | ||
|
|
645b7351a8 | ||
|
|
57088565bd | ||
|
|
4b6769e531 | ||
|
|
d144ee76d9 | ||
|
|
abcaad6609 | ||
|
|
50ce0200af | ||
|
|
58ccd63f4a | ||
|
|
3611d7eb59 | ||
|
|
8974d2f52c | ||
|
|
6b0d03f2d6 | ||
|
|
fb4deb1161 | ||
|
|
0e5d37666f | ||
|
|
093103bdd7 | ||
|
|
6c9a1ba56b | ||
|
|
93ecfc8a34 | ||
|
|
0c9c590d8f | ||
|
|
d130254e88 | ||
|
|
6e7a063300 | ||
|
|
c05b93299c | ||
|
|
cad1bd46c1 | ||
|
|
4bddce3700 | ||
|
|
fcc71987bf | ||
|
|
8458edf5a8 | ||
|
|
07b06bb0d3 | ||
|
|
3389b1d83b | ||
|
|
4f6000a43a | ||
|
|
9ba46b40cc | ||
|
|
5bb456272e | ||
|
|
8999f48fb9 | ||
|
|
b2f280a7f1 | ||
|
|
65cbd5d6a6 | ||
|
|
caac1d397a | ||
|
|
742f1ca838 | ||
|
|
e36bade353 | ||
|
|
01d8bcdbf5 | ||
|
|
f6a395bce9 | ||
|
|
dc34c63256 | ||
|
|
cdfccf9a24 | ||
|
|
1f112259d2 | ||
|
|
a505ffaaa9 | ||
|
|
32be271aa3 | ||
|
|
133ec27938 | ||
|
|
d79760a289 | ||
|
|
2580360f91 | ||
|
|
fe4dd0c982 | ||
|
|
83f99d3577 | ||
|
|
e521434073 | ||
|
|
916c5d3904 | ||
|
|
5b1cbb46fa | ||
|
|
1fcab8365e | ||
|
|
c855c30ae2 | ||
|
|
915b029d2c | ||
|
|
b599e1ab38 | ||
|
|
0d94734090 | ||
|
|
a0ff384b81 | ||
|
|
09b5e0d08f | ||
|
|
7d51c5d0f3 | ||
|
|
b97cd67d72 | ||
|
|
888c9fec65 | ||
|
|
4f07994bdc | ||
|
|
776fd7a2c4 | ||
|
|
9c6b0dc1fa | ||
|
|
9f51bd3853 | ||
|
|
b386c1181f | ||
|
|
b8e7649e69 | ||
|
|
6664c1a0fc | ||
|
|
e0c3fe554f | ||
|
|
40943ff4d3 | ||
|
|
6c03a1a277 | ||
|
|
27288e2aaa | ||
|
|
1c728ec7a7 | ||
|
|
78059083c2 | ||
|
|
34cd4ac141 | ||
|
|
343366b12d | ||
|
|
978369eeaa | ||
|
|
c39e1c267d | ||
|
|
9af21895c6 | ||
|
|
e3bd6f219f | ||
|
|
ae62c2c8d8 | ||
|
|
04db7c2f01 | ||
|
|
3d889e70b5 | ||
|
|
ef53e9bebe | ||
|
|
a546bfe596 | ||
|
|
23c21f87bb | ||
|
|
850ca103e5 | ||
|
|
b7b5fcbf79 | ||
|
|
1e13c7796d | ||
|
|
9a727b21ad | ||
|
|
beb944c390 | ||
|
|
5844dd1494 | ||
|
|
84c4010213 | ||
|
|
86260e218c | ||
|
|
2c694e2b2b | ||
|
|
66ccb7a732 | ||
|
|
deece322ef | ||
|
|
e7c8fef32d | ||
|
|
eedda0ec4b | ||
|
|
96ffbd5cf5 | ||
|
|
85b11fa9ff | ||
|
|
1675af98d4 | ||
|
|
61a0ddfdfd | ||
|
|
26ab5c59e3 | ||
|
|
35cc8c068f | ||
|
|
27fdec1361 | ||
|
|
76827a71cc | ||
|
|
3a9a7dbe99 | ||
|
|
d3361c13c5 | ||
|
|
7c1a8b0122 | ||
|
|
eeca909b65 | ||
|
|
b18d96ac13 | ||
|
|
b861b92e5d | ||
|
|
17f0afac80 | ||
|
|
931b646c30 |
14
Makefile
14
Makefile
@@ -1,4 +1,4 @@
|
||||
.PHONY: setconfig run lint setup-whisper build-whisper download-whisper-model docker-up docker-down docker-logs noextra-run
|
||||
.PHONY: setconfig run lint lintall install-linters setup-whisper build-whisper download-whisper-model docker-up docker-down docker-logs noextra-run installdelve checkdelve
|
||||
|
||||
run: setconfig
|
||||
go build -tags extra -o gf-lt && ./gf-lt
|
||||
@@ -15,9 +15,21 @@ noextra-run: setconfig
|
||||
setconfig:
|
||||
find config.toml &>/dev/null || cp config.example.toml config.toml
|
||||
|
||||
installdelve:
|
||||
go install github.com/go-delve/delve/cmd/dlv@latest
|
||||
|
||||
checkdelve:
|
||||
which dlv &>/dev/null || installdelve
|
||||
|
||||
install-linters: ## Install additional linters (noblanks)
|
||||
go install github.com/GrailFinder/noblanks-linter/cmd/noblanks@latest
|
||||
|
||||
lint: ## Run linters. Use make install-linters first.
|
||||
golangci-lint run -c .golangci.yml ./...
|
||||
|
||||
lintall: lint
|
||||
noblanks ./...
|
||||
|
||||
# Whisper STT Setup (in batteries directory)
|
||||
setup-whisper: build-whisper download-whisper-model
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ made with use of [tview](https://github.com/rivo/tview)
|
||||
- tts/stt (run make commands to get deps);
|
||||
- image input;
|
||||
- function calls (function calls are implemented natively, to avoid calling outside sources);
|
||||
- [character specific context (unique feature)](char-specific-context.md)
|
||||
- [character specific context (unique feature)](docs/char-specific-context.md)
|
||||
|
||||
#### how it looks
|
||||

|
||||
|
||||
@@ -71,8 +71,8 @@ func (ag *AgentClient) buildRequest(sysprompt, msg string) ([]byte, error) {
|
||||
// Build prompt for completion endpoints
|
||||
if isCompletion {
|
||||
var sb strings.Builder
|
||||
for _, m := range messages {
|
||||
sb.WriteString(m.ToPrompt())
|
||||
for i := range messages {
|
||||
sb.WriteString(messages[i].ToPrompt())
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
prompt := strings.TrimSpace(sb.String())
|
||||
@@ -110,8 +110,8 @@ func (ag *AgentClient) buildRequest(sysprompt, msg string) ([]byte, error) {
|
||||
req := models.NewDSChatReq(*chatBody)
|
||||
return json.Marshal(req)
|
||||
case isOpenRouter:
|
||||
// OpenRouter chat
|
||||
req := models.NewOpenRouterChatReq(*chatBody, defaultProps)
|
||||
// OpenRouter chat - agents don't use reasoning by default
|
||||
req := models.NewOpenRouterChatReq(*chatBody, defaultProps, "")
|
||||
return json.Marshal(req)
|
||||
default:
|
||||
// Assume llama.cpp chat (OpenAI format)
|
||||
@@ -140,7 +140,6 @@ func (ag *AgentClient) LLMRequest(body io.Reader) ([]byte, error) {
|
||||
ag.log.Error("failed to read request body", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", ag.cfg.CurrentAPI, bytes.NewReader(bodyBytes))
|
||||
if err != nil {
|
||||
ag.log.Error("failed to create request", "error", err)
|
||||
@@ -150,22 +149,18 @@ func (ag *AgentClient) LLMRequest(body io.Reader) ([]byte, error) {
|
||||
req.Header.Add("Content-Type", "application/json")
|
||||
req.Header.Add("Authorization", "Bearer "+ag.getToken())
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
|
||||
ag.log.Debug("agent LLM request", "url", ag.cfg.CurrentAPI, "body_preview", string(bodyBytes[:min(len(bodyBytes), 500)]))
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
ag.log.Error("llamacpp api request failed", "error", err, "url", ag.cfg.CurrentAPI)
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
responseBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
ag.log.Error("failed to read response", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
ag.log.Error("agent LLM request failed", "status", resp.StatusCode, "response", string(responseBytes[:min(len(responseBytes), 1000)]))
|
||||
return responseBytes, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(responseBytes[:min(len(responseBytes), 200)]))
|
||||
@@ -178,7 +173,6 @@ func (ag *AgentClient) LLMRequest(body io.Reader) ([]byte, error) {
|
||||
// Return raw response as fallback
|
||||
return responseBytes, nil
|
||||
}
|
||||
|
||||
return []byte(text), nil
|
||||
}
|
||||
|
||||
|
||||
34
bot_test.go
34
bot_test.go
@@ -1,12 +1,10 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"gf-lt/config"
|
||||
"gf-lt/models"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConsolidateConsecutiveAssistantMessages(t *testing.T) {
|
||||
// Mock config for testing
|
||||
testCfg := &config.Config{
|
||||
@@ -14,7 +12,6 @@ func TestConsolidateConsecutiveAssistantMessages(t *testing.T) {
|
||||
WriteNextMsgAsCompletionAgent: "",
|
||||
}
|
||||
cfg = testCfg
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input []models.RoleMsg
|
||||
@@ -114,38 +111,31 @@ func TestConsolidateConsecutiveAssistantMessages(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := consolidateAssistantMessages(tt.input)
|
||||
|
||||
if len(result) != len(tt.expected) {
|
||||
t.Errorf("Expected %d messages, got %d", len(tt.expected), len(result))
|
||||
t.Logf("Result: %+v", result)
|
||||
t.Logf("Expected: %+v", tt.expected)
|
||||
return
|
||||
}
|
||||
|
||||
for i, expectedMsg := range tt.expected {
|
||||
if i >= len(result) {
|
||||
t.Errorf("Result has fewer messages than expected at index %d", i)
|
||||
continue
|
||||
}
|
||||
|
||||
actualMsg := result[i]
|
||||
if actualMsg.Role != expectedMsg.Role {
|
||||
t.Errorf("Message %d: expected role '%s', got '%s'", i, expectedMsg.Role, actualMsg.Role)
|
||||
}
|
||||
|
||||
if actualMsg.Content != expectedMsg.Content {
|
||||
t.Errorf("Message %d: expected content '%s', got '%s'", i, expectedMsg.Content, actualMsg.Content)
|
||||
}
|
||||
|
||||
if actualMsg.ToolCallID != expectedMsg.ToolCallID {
|
||||
t.Errorf("Message %d: expected ToolCallID '%s', got '%s'", i, expectedMsg.ToolCallID, actualMsg.ToolCallID)
|
||||
}
|
||||
}
|
||||
|
||||
// Additional check: ensure no messages were lost
|
||||
if !reflect.DeepEqual(result, tt.expected) {
|
||||
t.Errorf("Result does not match expected:\nResult: %+v\nExpected: %+v", result, tt.expected)
|
||||
@@ -153,7 +143,6 @@ func TestConsolidateConsecutiveAssistantMessages(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalFuncCall(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -213,7 +202,6 @@ func TestUnmarshalFuncCall(t *testing.T) {
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := unmarshalFuncCall(tt.jsonStr)
|
||||
@@ -238,7 +226,6 @@ func TestUnmarshalFuncCall(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertJSONToMapStringString(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -265,7 +252,6 @@ func TestConvertJSONToMapStringString(t *testing.T) {
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := convertJSONToMapStringString(tt.jsonStr)
|
||||
@@ -287,7 +273,6 @@ func TestConvertJSONToMapStringString(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseKnownToTag(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -378,7 +363,6 @@ func TestParseKnownToTag(t *testing.T) {
|
||||
wantKnownTo: []string{"Alice", "Bob", "Carl"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Set up config
|
||||
@@ -402,7 +386,6 @@ func TestParseKnownToTag(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessMessageTag(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -498,7 +481,6 @@ func TestProcessMessageTag(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
testCfg := &config.Config{
|
||||
@@ -529,7 +511,6 @@ func TestProcessMessageTag(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterMessagesForCharacter(t *testing.T) {
|
||||
messages := []models.RoleMsg{
|
||||
{Role: "system", Content: "System message", KnownTo: nil}, // visible to all
|
||||
@@ -539,7 +520,6 @@ func TestFilterMessagesForCharacter(t *testing.T) {
|
||||
{Role: "Alice", Content: "Private to Carl", KnownTo: []string{"Alice", "Carl"}},
|
||||
{Role: "Carl", Content: "Hi all", KnownTo: nil}, // visible to all
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
enabled bool
|
||||
@@ -583,7 +563,6 @@ func TestFilterMessagesForCharacter(t *testing.T) {
|
||||
wantIndices: []int{0, 1, 5},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
testCfg := &config.Config{
|
||||
@@ -591,15 +570,12 @@ func TestFilterMessagesForCharacter(t *testing.T) {
|
||||
CharSpecificContextTag: "@",
|
||||
}
|
||||
cfg = testCfg
|
||||
|
||||
got := filterMessagesForCharacter(messages, tt.character)
|
||||
|
||||
if len(got) != len(tt.wantIndices) {
|
||||
t.Errorf("filterMessagesForCharacter() returned %d messages, want %d", len(got), len(tt.wantIndices))
|
||||
t.Logf("got: %v", got)
|
||||
return
|
||||
}
|
||||
|
||||
for i, idx := range tt.wantIndices {
|
||||
if got[i].Content != messages[idx].Content {
|
||||
t.Errorf("filterMessagesForCharacter() message %d content = %q, want %q", i, got[i].Content, messages[idx].Content)
|
||||
@@ -608,7 +584,6 @@ func TestFilterMessagesForCharacter(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoleMsgCopyPreservesKnownTo(t *testing.T) {
|
||||
// Test that the Copy() method preserves the KnownTo field
|
||||
originalMsg := models.RoleMsg{
|
||||
@@ -616,9 +591,7 @@ func TestRoleMsgCopyPreservesKnownTo(t *testing.T) {
|
||||
Content: "Test message",
|
||||
KnownTo: []string{"Bob", "Charlie"},
|
||||
}
|
||||
|
||||
copiedMsg := originalMsg.Copy()
|
||||
|
||||
if copiedMsg.Role != originalMsg.Role {
|
||||
t.Errorf("Copy() failed to preserve Role: got %q, want %q", copiedMsg.Role, originalMsg.Role)
|
||||
}
|
||||
@@ -635,7 +608,6 @@ func TestRoleMsgCopyPreservesKnownTo(t *testing.T) {
|
||||
t.Errorf("Copy() failed to preserve hasContentParts flag")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKnownToFieldPreservationScenario(t *testing.T) {
|
||||
// Test the specific scenario from the log where KnownTo field was getting lost
|
||||
originalMsg := models.RoleMsg{
|
||||
@@ -643,28 +615,22 @@ func TestKnownToFieldPreservationScenario(t *testing.T) {
|
||||
Content: `Alice: "Okay, Bob. The word is... **'Ephemeral'**. (ooc: @Bob@)"`,
|
||||
KnownTo: []string{"Bob"}, // This was detected in the log
|
||||
}
|
||||
|
||||
t.Logf("Original message - Role: %s, Content: %s, KnownTo: %v",
|
||||
originalMsg.Role, originalMsg.Content, originalMsg.KnownTo)
|
||||
|
||||
// Simulate what happens when the message gets copied during processing
|
||||
copiedMsg := originalMsg.Copy()
|
||||
|
||||
t.Logf("Copied message - Role: %s, Content: %s, KnownTo: %v",
|
||||
copiedMsg.Role, copiedMsg.Content, copiedMsg.KnownTo)
|
||||
|
||||
// Check if KnownTo field survived the copy
|
||||
if len(copiedMsg.KnownTo) == 0 {
|
||||
t.Error("ERROR: KnownTo field was lost during copy!")
|
||||
} else {
|
||||
t.Log("SUCCESS: KnownTo field was preserved during copy!")
|
||||
}
|
||||
|
||||
// Verify the content is the same
|
||||
if copiedMsg.Content != originalMsg.Content {
|
||||
t.Errorf("Content was changed during copy: got %s, want %s", copiedMsg.Content, originalMsg.Content)
|
||||
}
|
||||
|
||||
// Verify the KnownTo slice is properly copied
|
||||
if !reflect.DeepEqual(copiedMsg.KnownTo, originalMsg.KnownTo) {
|
||||
t.Errorf("KnownTo was not properly copied: got %v, want %v", copiedMsg.KnownTo, originalMsg.KnownTo)
|
||||
|
||||
63
colors.go
Normal file
63
colors.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
)
|
||||
|
||||
var (
|
||||
colorschemes = map[string]tview.Theme{
|
||||
"default": tview.Theme{
|
||||
PrimitiveBackgroundColor: tcell.ColorDefault,
|
||||
ContrastBackgroundColor: tcell.ColorGray,
|
||||
MoreContrastBackgroundColor: tcell.ColorSteelBlue,
|
||||
BorderColor: tcell.ColorGray,
|
||||
TitleColor: tcell.ColorRed,
|
||||
GraphicsColor: tcell.ColorBlue,
|
||||
PrimaryTextColor: tcell.ColorLightGray,
|
||||
SecondaryTextColor: tcell.ColorYellow,
|
||||
TertiaryTextColor: tcell.ColorOrange,
|
||||
InverseTextColor: tcell.ColorPurple,
|
||||
ContrastSecondaryTextColor: tcell.ColorLime,
|
||||
},
|
||||
"gruvbox": tview.Theme{
|
||||
PrimitiveBackgroundColor: tcell.NewHexColor(0x282828), // Background: #282828 (dark gray)
|
||||
ContrastBackgroundColor: tcell.ColorDarkGoldenrod, // Selected option: warm yellow (#b57614)
|
||||
MoreContrastBackgroundColor: tcell.ColorDarkSlateGray, // Non-selected options: dark grayish-blue (#32302f)
|
||||
BorderColor: tcell.ColorLightGray, // Light gray (#a89984)
|
||||
TitleColor: tcell.ColorRed, // Red (#fb4934)
|
||||
GraphicsColor: tcell.ColorDarkCyan, // Cyan (#689d6a)
|
||||
PrimaryTextColor: tcell.ColorLightGray, // Light gray (#d5c4a1)
|
||||
SecondaryTextColor: tcell.ColorYellow, // Yellow (#fabd2f)
|
||||
TertiaryTextColor: tcell.ColorOrange, // Orange (#fe8019)
|
||||
InverseTextColor: tcell.ColorWhite, // White (#f9f5d7) for selected text
|
||||
ContrastSecondaryTextColor: tcell.ColorLightGreen, // Light green (#b8bb26)
|
||||
},
|
||||
"solarized": tview.Theme{
|
||||
PrimitiveBackgroundColor: tcell.NewHexColor(0x002b36), // Background: #002b36 (base03)
|
||||
ContrastBackgroundColor: tcell.ColorDarkCyan, // Selected option: cyan (#2aa198)
|
||||
MoreContrastBackgroundColor: tcell.ColorDarkSlateGray, // Non-selected options: dark blue (#073642)
|
||||
BorderColor: tcell.ColorLightBlue, // Light blue (#839496)
|
||||
TitleColor: tcell.ColorRed, // Red (#dc322f)
|
||||
GraphicsColor: tcell.ColorBlue, // Blue (#268bd2)
|
||||
PrimaryTextColor: tcell.ColorWhite, // White (#fdf6e3)
|
||||
SecondaryTextColor: tcell.ColorYellow, // Yellow (#b58900)
|
||||
TertiaryTextColor: tcell.ColorOrange, // Orange (#cb4b16)
|
||||
InverseTextColor: tcell.ColorWhite, // White (#eee8d5) for selected text
|
||||
ContrastSecondaryTextColor: tcell.ColorLightCyan, // Light cyan (#93a1a1)
|
||||
},
|
||||
"dracula": tview.Theme{
|
||||
PrimitiveBackgroundColor: tcell.NewHexColor(0x282a36), // Background: #282a36
|
||||
ContrastBackgroundColor: tcell.ColorDarkMagenta, // Selected option: magenta (#bd93f9)
|
||||
MoreContrastBackgroundColor: tcell.ColorDarkGray, // Non-selected options: dark gray (#44475a)
|
||||
BorderColor: tcell.ColorLightGray, // Light gray (#f8f8f2)
|
||||
TitleColor: tcell.ColorRed, // Red (#ff5555)
|
||||
GraphicsColor: tcell.ColorDarkCyan, // Cyan (#8be9fd)
|
||||
PrimaryTextColor: tcell.ColorWhite, // White (#f8f8f2)
|
||||
SecondaryTextColor: tcell.ColorYellow, // Yellow (#f1fa8c)
|
||||
TertiaryTextColor: tcell.ColorOrange, // Orange (#ffb86c)
|
||||
InverseTextColor: tcell.ColorWhite, // White (#f8f8f2) for selected text
|
||||
ContrastSecondaryTextColor: tcell.ColorLightGreen, // Light green (#50fa7b)
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -10,7 +10,10 @@ DeepSeekModel = "deepseek-reasoner"
|
||||
OpenRouterCompletionAPI = "https://openrouter.ai/api/v1/completions"
|
||||
OpenRouterChatAPI = "https://openrouter.ai/api/v1/chat/completions"
|
||||
# OpenRouterToken = ""
|
||||
# embeddings
|
||||
EmbedURL = "http://localhost:8082/v1/embeddings"
|
||||
HFToken = ""
|
||||
#
|
||||
ShowSys = true
|
||||
LogFile = "log.txt"
|
||||
UserRole = "user"
|
||||
@@ -21,9 +24,9 @@ ChunkLimit = 100000
|
||||
AutoScrollEnabled = true
|
||||
AutoCleanToolCallsFromCtx = false
|
||||
# rag settings
|
||||
RAGEnabled = false
|
||||
RAGBatchSize = 1
|
||||
RAGWordLimit = 80
|
||||
RAGWorkers = 2
|
||||
RAGDir = "ragimport"
|
||||
# extra tts
|
||||
TTS_ENABLED = false
|
||||
@@ -41,7 +44,7 @@ STT_LANG = "en" # Language for speech recognition (for WHISPER_BINARY mode)
|
||||
STT_SR = 16000 # Sample rate for audio recording
|
||||
#
|
||||
DBPATH = "gflt.db"
|
||||
FilePickerDir = "." # Directory where file picker should start
|
||||
FilePickerDir = "." # Directory for file picker start and coding assistant file operations (relative paths resolved against this)
|
||||
FilePickerExts = "png,jpg,jpeg,gif,webp" # Comma-separated list of allowed file extensions for file picker
|
||||
EnableMouse = false # Enable mouse support in the UI
|
||||
# character specific context
|
||||
@@ -49,3 +52,10 @@ CharSpecificContextEnabled = true
|
||||
CharSpecificContextTag = "@"
|
||||
AutoTurn = true
|
||||
StripThinkingFromAPI = true # Strip <think> blocks from messages before sending to LLM (keeps them in chat history)
|
||||
# OpenRouter reasoning configuration (only applies to OpenRouter chat API)
|
||||
# Valid values: xhigh, high, medium, low, minimal, none (empty or none = disabled)
|
||||
# Models that support reasoning will include thinking content wrapped in <think> tags
|
||||
ReasoningEffort = "medium"
|
||||
# playwright tools
|
||||
PlaywrightEnabled = false
|
||||
PlaywrightDebug = false
|
||||
|
||||
@@ -18,8 +18,8 @@ type Config struct {
|
||||
UserRole string `toml:"UserRole"`
|
||||
ToolRole string `toml:"ToolRole"`
|
||||
ToolUse bool `toml:"ToolUse"`
|
||||
ThinkUse bool `toml:"ThinkUse"`
|
||||
StripThinkingFromAPI bool `toml:"StripThinkingFromAPI"`
|
||||
ReasoningEffort string `toml:"ReasoningEffort"`
|
||||
AssistantRole string `toml:"AssistantRole"`
|
||||
SysDir string `toml:"SysDir"`
|
||||
ChunkLimit uint32 `toml:"ChunkLimit"`
|
||||
@@ -34,12 +34,11 @@ type Config struct {
|
||||
ImagePreview bool `toml:"ImagePreview"`
|
||||
EnableMouse bool `toml:"EnableMouse"`
|
||||
// embeddings
|
||||
RAGEnabled bool `toml:"RAGEnabled"`
|
||||
EmbedURL string `toml:"EmbedURL"`
|
||||
HFToken string `toml:"HFToken"`
|
||||
RAGDir string `toml:"RAGDir"`
|
||||
// rag settings
|
||||
RAGWorkers uint32 `toml:"RAGWorkers"`
|
||||
RAGEnabled bool `toml:"RAGEnabled"`
|
||||
RAGDir string `toml:"RAGDir"`
|
||||
RAGBatchSize int `toml:"RAGBatchSize"`
|
||||
RAGWordLimit uint32 `toml:"RAGWordLimit"`
|
||||
// deepseek
|
||||
@@ -71,6 +70,9 @@ type Config struct {
|
||||
CharSpecificContextEnabled bool `toml:"CharSpecificContextEnabled"`
|
||||
CharSpecificContextTag string `toml:"CharSpecificContextTag"`
|
||||
AutoTurn bool `toml:"AutoTurn"`
|
||||
// playwright browser
|
||||
PlaywrightEnabled bool `toml:"PlaywrightEnabled"`
|
||||
PlaywrightDebug bool `toml:"PlaywrightDebug"` // !headless
|
||||
}
|
||||
|
||||
func LoadConfig(fn string) (*Config, error) {
|
||||
@@ -124,6 +126,9 @@ func LoadConfig(fn string) (*Config, error) {
|
||||
if config.CompletionAPI != "" {
|
||||
config.ApiLinks = append(config.ApiLinks, config.CompletionAPI)
|
||||
}
|
||||
if config.RAGDir == "" {
|
||||
config.RAGDir = "ragimport"
|
||||
}
|
||||
// if any value is empty fill with default
|
||||
return config, nil
|
||||
}
|
||||
|
||||
@@ -80,9 +80,6 @@ This document explains how to set up and configure the application using the `co
|
||||
#### RAGWordLimit (`80`)
|
||||
- Maximum number of words in a batch to tokenize and store.
|
||||
|
||||
#### RAGWorkers (`2`)
|
||||
- Number of concurrent workers for RAG processing.
|
||||
|
||||
#### RAGDir (`"ragimport"`)
|
||||
- Directory containing documents for RAG processing.
|
||||
|
||||
@@ -140,14 +137,24 @@ This document explains how to set up and configure the application using the `co
|
||||
- Path to the SQLite database file used for storing conversation history and other data.
|
||||
|
||||
#### FilePickerDir (`"."`)
|
||||
- Directory where the file (image) picker should start when selecting files.
|
||||
|
||||
#### FilePickerExts (`"png,jpg,jpeg,gif,webp"`)
|
||||
- Comma-separated list of allowed file extensions for the file picker.
|
||||
- Directory where the file picker starts and where relative paths in coding assistant file tools (file_read, file_write, etc.) are resolved against. Use absolute paths (starting with `/`) to bypass this.
|
||||
|
||||
#### EnableMouse (`false`)
|
||||
- Enable or disable mouse support in the UI. When set to `true`, allows clicking buttons and interacting with UI elements using the mouse, but prevents the terminal from handling mouse events normally (such as selecting and copying text). When set to `false`, enables default terminal behavior allowing you to select and copy text, but disables mouse interaction with UI elements.
|
||||
|
||||
### Character-Specific Context Settings (/completion only)
|
||||
|
||||
[character specific context page for more info](./char-specific-context.md)
|
||||
|
||||
#### CharSpecificContextEnabled (`true`)
|
||||
- Enable or disable character-specific context functionality.
|
||||
|
||||
#### CharSpecificContextTag (`"@"`)
|
||||
- The tag prefix used to reference character-specific context in messages.
|
||||
|
||||
#### AutoTurn (`true`)
|
||||
- Enable or disable automatic turn detection/switching.
|
||||
|
||||
### Additional Features
|
||||
|
||||
Those could be switched in program, but also bould be setup in config.
|
||||
@@ -155,8 +162,20 @@ Those could be switched in program, but also bould be setup in config.
|
||||
#### ToolUse
|
||||
- Enable or disable explanation of tools to llm, so it could use them.
|
||||
|
||||
#### ThinkUse
|
||||
- Enable or disable insertion of <think> token at the beggining of llm resp.
|
||||
#### Playwright Browser Automation
|
||||
These settings enable browser automation tools available to the LLM.
|
||||
|
||||
- **PlaywrightEnabled** (`false`)
|
||||
- Enable or disable Playwright browser automation tools for the LLM. When enabled, the LLM can use tools like `pw_browser`, `pw_close`, and `pw_status` to automate browser interactions.
|
||||
|
||||
- **PlaywrightDebug** (`false`)
|
||||
- Enable debug mode for Playwright browser. When set to `true`, the browser runs in visible (non-headless) mode, displaying the GUI for debugging purposes. When `false`, the browser runs in headless mode by default.
|
||||
|
||||
### StripThinkingFromAPI (`true`)
|
||||
- Strip thinking blocks from messages before sending to LLM. Keeps them in chat history for local viewing but reduces token usage in API calls.
|
||||
|
||||
#### ReasoningEffort (`"medium"`)
|
||||
- OpenRouter reasoning configuration (only applies to OpenRouter chat API). Valid values: `xhigh`, `high`, `medium`, `low`, `minimal`, `none`. Empty or `none` disables reasoning.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
|
||||
50
extra/tts.go
50
extra/tts.go
@@ -92,8 +92,6 @@ func (o *KokoroOrator) stoproutine() {
|
||||
|
||||
func (o *KokoroOrator) readroutine() {
|
||||
tokenizer, _ := english.NewSentenceTokenizer(nil)
|
||||
// var sentenceBuf bytes.Buffer
|
||||
// var remainder strings.Builder
|
||||
for {
|
||||
select {
|
||||
case chunk := <-TTSTextChan:
|
||||
@@ -106,24 +104,28 @@ func (o *KokoroOrator) readroutine() {
|
||||
continue
|
||||
}
|
||||
text := o.textBuffer.String()
|
||||
o.mu.Unlock()
|
||||
sentences := tokenizer.Tokenize(text)
|
||||
o.logger.Debug("adding chunk", "chunk", chunk, "text", text, "sen-len", len(sentences))
|
||||
for i, sentence := range sentences {
|
||||
if i == len(sentences)-1 { // last sentence
|
||||
o.mu.Lock()
|
||||
o.textBuffer.Reset()
|
||||
_, err := o.textBuffer.WriteString(sentence.Text)
|
||||
if len(sentences) <= 1 {
|
||||
o.mu.Unlock()
|
||||
if err != nil {
|
||||
o.logger.Warn("failed to write to stringbuilder", "error", err)
|
||||
continue
|
||||
}
|
||||
continue // if only one (often incomplete) sentence; wait for next chunk
|
||||
completeSentences := sentences[:len(sentences)-1]
|
||||
remaining := sentences[len(sentences)-1].Text
|
||||
o.textBuffer.Reset()
|
||||
o.textBuffer.WriteString(remaining)
|
||||
o.mu.Unlock()
|
||||
|
||||
for _, sentence := range completeSentences {
|
||||
o.mu.Lock()
|
||||
interrupted := o.interrupt
|
||||
o.mu.Unlock()
|
||||
if interrupted {
|
||||
return
|
||||
}
|
||||
cleanedText := models.CleanText(sentence.Text)
|
||||
if cleanedText == "" {
|
||||
continue // Skip empty text after cleaning
|
||||
continue
|
||||
}
|
||||
o.logger.Debug("calling Speak with sentence", "sent", cleanedText)
|
||||
if err := o.Speak(cleanedText); err != nil {
|
||||
@@ -338,24 +340,28 @@ func (o *GoogleTranslateOrator) readroutine() {
|
||||
continue
|
||||
}
|
||||
text := o.textBuffer.String()
|
||||
o.mu.Unlock()
|
||||
sentences := tokenizer.Tokenize(text)
|
||||
o.logger.Debug("adding chunk", "chunk", chunk, "text", text, "sen-len", len(sentences))
|
||||
for i, sentence := range sentences {
|
||||
if i == len(sentences)-1 { // last sentence
|
||||
o.mu.Lock()
|
||||
o.textBuffer.Reset()
|
||||
_, err := o.textBuffer.WriteString(sentence.Text)
|
||||
if len(sentences) <= 1 {
|
||||
o.mu.Unlock()
|
||||
if err != nil {
|
||||
o.logger.Warn("failed to write to stringbuilder", "error", err)
|
||||
continue
|
||||
}
|
||||
continue // if only one (often incomplete) sentence; wait for next chunk
|
||||
completeSentences := sentences[:len(sentences)-1]
|
||||
remaining := sentences[len(sentences)-1].Text
|
||||
o.textBuffer.Reset()
|
||||
o.textBuffer.WriteString(remaining)
|
||||
o.mu.Unlock()
|
||||
|
||||
for _, sentence := range completeSentences {
|
||||
o.mu.Lock()
|
||||
interrupted := o.interrupt
|
||||
o.mu.Unlock()
|
||||
if interrupted {
|
||||
return
|
||||
}
|
||||
cleanedText := models.CleanText(sentence.Text)
|
||||
if cleanedText == "" {
|
||||
continue // Skip empty text after cleaning
|
||||
continue
|
||||
}
|
||||
o.logger.Debug("calling Speak with sentence", "sent", cleanedText)
|
||||
if err := o.Speak(cleanedText); err != nil {
|
||||
|
||||
8
go.mod
8
go.mod
@@ -6,22 +6,28 @@ require (
|
||||
github.com/BurntSushi/toml v1.5.0
|
||||
github.com/GrailFinder/google-translate-tts v0.1.3
|
||||
github.com/GrailFinder/searchagent v0.2.0
|
||||
github.com/PuerkitoBio/goquery v1.11.0
|
||||
github.com/deckarep/golang-set/v2 v2.8.0
|
||||
github.com/gdamore/tcell/v2 v2.13.2
|
||||
github.com/glebarez/go-sqlite v1.22.0
|
||||
github.com/gopxl/beep/v2 v2.1.1
|
||||
github.com/gordonklaus/portaudio v0.0.0-20250206071425-98a94950218b
|
||||
github.com/jmoiron/sqlx v1.4.0
|
||||
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728
|
||||
github.com/neurosnap/sentences v1.1.2
|
||||
github.com/playwright-community/playwright-go v0.5700.1
|
||||
github.com/rivo/tview v0.42.0
|
||||
github.com/yuin/goldmark v1.4.13
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/PuerkitoBio/goquery v1.11.0 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/ebitengine/oto/v3 v3.4.0 // indirect
|
||||
github.com/ebitengine/purego v0.9.1 // indirect
|
||||
github.com/gdamore/encoding v1.0.1 // indirect
|
||||
github.com/go-jose/go-jose/v3 v3.0.4 // indirect
|
||||
github.com/go-stack/stack v1.8.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/hajimehoshi/go-mp3 v0.3.4 // indirect
|
||||
github.com/hajimehoshi/oto/v2 v2.3.1 // indirect
|
||||
|
||||
17
go.sum
17
go.sum
@@ -10,8 +10,11 @@ github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43
|
||||
github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
|
||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/deckarep/golang-set/v2 v2.8.0 h1:swm0rlPCmdWn9mESxKOjWk8hXSqoxOp+ZlfuyaAdFlQ=
|
||||
github.com/deckarep/golang-set/v2 v2.8.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/ebitengine/oto/v3 v3.4.0 h1:br0PgASsEWaoWn38b2Goe7m1GKFYfNgnsjSd5Gg+/bQ=
|
||||
@@ -24,8 +27,13 @@ github.com/gdamore/tcell/v2 v2.13.2 h1:5j4srfF8ow3HICOv/61/sOhQtA25qxEB2XR3Q/Bhx
|
||||
github.com/gdamore/tcell/v2 v2.13.2/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
|
||||
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
|
||||
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
|
||||
github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY=
|
||||
github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/go-stack/stack v1.8.1 h1:ntEHSVwIt7PNXNpgPmVfMrNhLtgjlmnZha2kOpuRiDw=
|
||||
github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
@@ -43,6 +51,8 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
|
||||
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
|
||||
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728 h1:QwWKgMY28TAXaDl+ExRDqGQltzXqN/xypdKP86niVn8=
|
||||
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728/go.mod h1:1fEHWurg7pvf5SG6XNE5Q8UZmOwex51Mkx3SLhrW5B4=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
@@ -57,6 +67,8 @@ github.com/neurosnap/sentences v1.1.2 h1:iphYOzx/XckXeBiLIUBkPu2EKMJ+6jDbz/sLJZ7
|
||||
github.com/neurosnap/sentences v1.1.2/go.mod h1:/pwU4E9XNL21ygMIkOIllv/SMy2ujHwpf8GQPu1YPbQ=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/playwright-community/playwright-go v0.5700.1 h1:PNFb1byWqrTT720rEO0JL88C6Ju0EmUnR5deFLvtP/U=
|
||||
github.com/playwright-community/playwright-go v0.5700.1/go.mod h1:MlSn1dZrx8rszbCxY6x3qK89ZesJUYVx21B2JnkoNF0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
@@ -65,8 +77,11 @@ github.com/rivo/tview v0.42.0 h1:b/ftp+RxtDsHSaynXTbJb+/n/BxDEi+W3UfF5jILK6c=
|
||||
github.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
@@ -149,6 +164,8 @@ golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxb
|
||||
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
|
||||
697
helpfuncs.go
697
helpfuncs.go
@@ -7,14 +7,55 @@ import (
|
||||
"image"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"math/rand/v2"
|
||||
"github.com/rivo/tview"
|
||||
)
|
||||
|
||||
// Cached model color - updated by background goroutine
|
||||
var cachedModelColor string = "orange"
|
||||
|
||||
// startModelColorUpdater starts a background goroutine that periodically updates
|
||||
// the cached model color. Only runs HTTP requests for local llama.cpp APIs.
|
||||
func startModelColorUpdater() {
|
||||
go func() {
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
// Initial check
|
||||
updateCachedModelColor()
|
||||
for range ticker.C {
|
||||
updateCachedModelColor()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// updateCachedModelColor updates the global cachedModelColor variable
|
||||
func updateCachedModelColor() {
|
||||
if !isLocalLlamacpp() {
|
||||
cachedModelColor = "orange"
|
||||
return
|
||||
}
|
||||
// Check if model is loaded
|
||||
loaded, err := isModelLoaded(chatBody.Model)
|
||||
if err != nil {
|
||||
// On error, assume not loaded (red)
|
||||
cachedModelColor = "red"
|
||||
return
|
||||
}
|
||||
if loaded {
|
||||
cachedModelColor = "green"
|
||||
} else {
|
||||
cachedModelColor = "red"
|
||||
}
|
||||
}
|
||||
|
||||
func isASCII(s string) bool {
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] > unicode.MaxASCII {
|
||||
@@ -24,21 +65,30 @@ func isASCII(s string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func mapToString[V any](m map[string]V) string {
|
||||
rs := strings.Builder{}
|
||||
for k, v := range m {
|
||||
fmt.Fprintf(&rs, "%v: %v\n", k, v)
|
||||
}
|
||||
return rs.String()
|
||||
}
|
||||
|
||||
// stripThinkingFromMsg removes thinking blocks from assistant messages.
|
||||
// Skips user, tool, and system messages as they may contain thinking examples.
|
||||
func stripThinkingFromMsg(msg *models.RoleMsg) *models.RoleMsg {
|
||||
if !cfg.StripThinkingFromAPI {
|
||||
return msg
|
||||
}
|
||||
// Skip user, tool, and system messages - they might contain thinking examples
|
||||
// Skip user, tool, they might contain thinking and system messages - examples
|
||||
if msg.Role == cfg.UserRole || msg.Role == cfg.ToolRole || msg.Role == "system" {
|
||||
return msg
|
||||
}
|
||||
// Strip thinking from assistant messages
|
||||
if thinkRE.MatchString(msg.Content) {
|
||||
msg.Content = thinkRE.ReplaceAllString(msg.Content, "")
|
||||
// Clean up any double newlines that might result
|
||||
msg.Content = strings.TrimSpace(msg.Content)
|
||||
msgText := msg.GetText()
|
||||
if thinkRE.MatchString(msgText) {
|
||||
cleanedText := thinkRE.ReplaceAllString(msgText, "")
|
||||
cleanedText = strings.TrimSpace(cleanedText)
|
||||
msg.SetText(cleanedText)
|
||||
}
|
||||
return msg
|
||||
}
|
||||
@@ -57,19 +107,23 @@ func refreshChatDisplay() {
|
||||
displayText := chatToText(filteredMessages, cfg.ShowSys)
|
||||
textView.SetText(displayText)
|
||||
colorText()
|
||||
updateStatusLine()
|
||||
if scrollToEndEnabled {
|
||||
textView.ScrollToEnd()
|
||||
}
|
||||
}
|
||||
|
||||
// stopTTSIfNotForUser: character specific context, not meant fot the human to hear
|
||||
func stopTTSIfNotForUser(msg *models.RoleMsg) {
|
||||
if strings.Contains(cfg.CurrentAPI, "/chat") || !cfg.CharSpecificContextEnabled {
|
||||
return
|
||||
}
|
||||
viewingAs := cfg.UserRole
|
||||
if cfg.WriteNextMsgAs != "" {
|
||||
viewingAs = cfg.WriteNextMsgAs
|
||||
}
|
||||
// stop tts if msg is not for user
|
||||
if cfg.CharSpecificContextEnabled &&
|
||||
!slices.Contains(msg.KnownTo, viewingAs) && cfg.TTS_ENABLED {
|
||||
if !slices.Contains(msg.KnownTo, viewingAs) && cfg.TTS_ENABLED {
|
||||
TTSDoneChan <- true
|
||||
}
|
||||
}
|
||||
@@ -127,8 +181,8 @@ func colorText() {
|
||||
}
|
||||
|
||||
func updateStatusLine() {
|
||||
statusLineWidget.SetText(makeStatusLine())
|
||||
helpView.SetText(fmt.Sprintf(helpText, makeStatusLine()))
|
||||
status := makeStatusLine()
|
||||
statusLineWidget.SetText(status)
|
||||
}
|
||||
|
||||
func initSysCards() ([]string, error) {
|
||||
@@ -144,7 +198,11 @@ func initSysCards() ([]string, error) {
|
||||
logger.Warn("empty role", "file", cc.FilePath)
|
||||
continue
|
||||
}
|
||||
sysMap[cc.Role] = cc
|
||||
if cc.ID == "" {
|
||||
cc.ID = models.ComputeCardID(cc.Role, cc.FilePath)
|
||||
}
|
||||
sysMap[cc.ID] = cc
|
||||
roleToID[cc.Role] = cc.ID
|
||||
labels = append(labels, cc.Role)
|
||||
}
|
||||
return labels, nil
|
||||
@@ -164,6 +222,8 @@ func startNewChat(keepSysP bool) {
|
||||
newChat := &models.Chat{
|
||||
ID: id + 1,
|
||||
Name: fmt.Sprintf("%d_%s", id+1, cfg.AssistantRole),
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
// chat is written to db when we get first llm response (or any)
|
||||
// actual chat history (messages) would be parsed then
|
||||
Msgs: "",
|
||||
@@ -231,24 +291,25 @@ func listRolesWithUser() []string {
|
||||
return result
|
||||
}
|
||||
|
||||
func loadImage() {
|
||||
func loadImage() error {
|
||||
filepath := defaultImage
|
||||
cc, ok := sysMap[cfg.AssistantRole]
|
||||
if ok {
|
||||
cc := GetCardByRole(cfg.AssistantRole)
|
||||
if cc != nil {
|
||||
if strings.HasSuffix(cc.FilePath, ".png") {
|
||||
filepath = cc.FilePath
|
||||
}
|
||||
}
|
||||
file, err := os.Open(filepath)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
return fmt.Errorf("failed to open image: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
img, _, err := image.Decode(file)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
return fmt.Errorf("failed to decode image: %w", err)
|
||||
}
|
||||
imgView.SetImage(img)
|
||||
return nil
|
||||
}
|
||||
|
||||
func strInSlice(s string, sl []string) bool {
|
||||
@@ -270,22 +331,11 @@ func isLocalLlamacpp() bool {
|
||||
return host == "localhost" || host == "127.0.0.1" || host == "::1"
|
||||
}
|
||||
|
||||
// getModelColor returns the color tag for the model name based on its load status.
|
||||
// getModelColor returns the cached color tag for the model name.
|
||||
// The cached value is updated by a background goroutine every 5 seconds.
|
||||
// For non-local models, returns orange. For local llama.cpp models, returns green if loaded, red if not.
|
||||
func getModelColor() string {
|
||||
if !isLocalLlamacpp() {
|
||||
return "orange"
|
||||
}
|
||||
// Check if model is loaded
|
||||
loaded, err := isModelLoaded(chatBody.Model)
|
||||
if err != nil {
|
||||
// On error, assume not loaded (red)
|
||||
return "red"
|
||||
}
|
||||
if loaded {
|
||||
return "green"
|
||||
}
|
||||
return "red"
|
||||
return cachedModelColor
|
||||
}
|
||||
|
||||
func makeStatusLine() string {
|
||||
@@ -319,21 +369,101 @@ func makeStatusLine() string {
|
||||
}
|
||||
// Get model color based on load status for local llama.cpp models
|
||||
modelColor := getModelColor()
|
||||
statusLine := fmt.Sprintf(indexLineCompletion, boolColors[botRespMode], botRespMode, activeChatName,
|
||||
boolColors[cfg.ToolUse], cfg.ToolUse, modelColor, chatBody.Model, boolColors[cfg.SkipLLMResp],
|
||||
cfg.SkipLLMResp, cfg.CurrentAPI, boolColors[isRecording], isRecording, persona,
|
||||
botPersona, boolColors[injectRole], injectRole)
|
||||
statusLine := fmt.Sprintf(statusLineTempl, activeChatName,
|
||||
boolColors[cfg.ToolUse], modelColor, chatBody.Model, boolColors[cfg.SkipLLMResp],
|
||||
cfg.CurrentAPI, persona, botPersona)
|
||||
if cfg.STT_ENABLED {
|
||||
recordingS := fmt.Sprintf(" | [%s:-:b]voice recording[-:-:-] (ctrl+r)",
|
||||
boolColors[isRecording])
|
||||
statusLine += recordingS
|
||||
}
|
||||
// completion endpoint
|
||||
if !strings.Contains(cfg.CurrentAPI, "chat") {
|
||||
roleInject := fmt.Sprintf(" | [%s:-:b]role injection[-:-:-] (alt+7)", boolColors[injectRole])
|
||||
statusLine += roleInject
|
||||
}
|
||||
// context tokens
|
||||
contextTokens := getContextTokens()
|
||||
maxCtx := getMaxContextTokens()
|
||||
if maxCtx == 0 {
|
||||
maxCtx = 16384
|
||||
}
|
||||
if contextTokens > 0 {
|
||||
contextInfo := fmt.Sprintf(" | context-estim: [orange:-:b]%d/%d[-:-:-]", contextTokens, maxCtx)
|
||||
statusLine += contextInfo
|
||||
}
|
||||
return statusLine + imageInfo + shellModeInfo
|
||||
}
|
||||
|
||||
var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
|
||||
|
||||
func randString(n int) string {
|
||||
b := make([]rune, n)
|
||||
for i := range b {
|
||||
b[i] = letters[rand.IntN(len(letters))]
|
||||
func getContextTokens() int {
|
||||
if chatBody == nil || chatBody.Messages == nil {
|
||||
return 0
|
||||
}
|
||||
return string(b)
|
||||
total := 0
|
||||
messages := chatBody.Messages
|
||||
for i := range messages {
|
||||
msg := &messages[i]
|
||||
if msg.Stats != nil && msg.Stats.Tokens > 0 {
|
||||
total += msg.Stats.Tokens
|
||||
} else if msg.GetText() != "" {
|
||||
total += len(msg.GetText()) / 4
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
const deepseekContext = 128000
|
||||
|
||||
func getMaxContextTokens() int {
|
||||
if chatBody == nil || chatBody.Model == "" {
|
||||
return 0
|
||||
}
|
||||
modelName := chatBody.Model
|
||||
switch {
|
||||
case strings.Contains(cfg.CurrentAPI, "openrouter"):
|
||||
if orModelsData != nil {
|
||||
for i := range orModelsData.Data {
|
||||
m := &orModelsData.Data[i]
|
||||
if m.ID == modelName {
|
||||
return m.ContextLength
|
||||
}
|
||||
}
|
||||
}
|
||||
case strings.Contains(cfg.CurrentAPI, "deepseek"):
|
||||
return deepseekContext
|
||||
default:
|
||||
if localModelsData != nil {
|
||||
for i := range localModelsData.Data {
|
||||
m := &localModelsData.Data[i]
|
||||
if m.ID == modelName {
|
||||
for _, arg := range m.Status.Args {
|
||||
if strings.HasPrefix(arg, "--ctx-size") {
|
||||
if strings.Contains(arg, "=") {
|
||||
val := strings.Split(arg, "=")[1]
|
||||
if n, err := strconv.Atoi(val); err == nil {
|
||||
return n
|
||||
}
|
||||
} else {
|
||||
idx := -1
|
||||
for j, a := range m.Status.Args {
|
||||
if a == "--ctx-size" && j+1 < len(m.Status.Args) {
|
||||
idx = j + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx != -1 {
|
||||
if n, err := strconv.Atoi(m.Status.Args[idx]); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// set of roles within card definition and mention in chat history
|
||||
@@ -343,13 +473,9 @@ func listChatRoles() []string {
|
||||
if !ok {
|
||||
return cbc
|
||||
}
|
||||
currentCard, ok := sysMap[currentChat.Agent]
|
||||
if !ok {
|
||||
// case which won't let to switch roles:
|
||||
// started new chat (basic_sys or any other), at the start it yet be saved or have chatbody
|
||||
// if it does not have a card or chars, it'll return an empty slice
|
||||
// log error
|
||||
logger.Warn("failed to find current card in sysMap", "agent", currentChat.Agent, "sysMap", sysMap)
|
||||
currentCard := GetCardByRole(currentChat.Agent)
|
||||
if currentCard == nil {
|
||||
logger.Warn("failed to find current card", "agent", currentChat.Agent)
|
||||
return cbc
|
||||
}
|
||||
charset := []string{}
|
||||
@@ -365,12 +491,479 @@ func listChatRoles() []string {
|
||||
func deepseekModelValidator() error {
|
||||
if cfg.CurrentAPI == cfg.DeepSeekChatAPI || cfg.CurrentAPI == cfg.DeepSeekCompletionAPI {
|
||||
if chatBody.Model != "deepseek-chat" && chatBody.Model != "deepseek-reasoner" {
|
||||
if err := notifyUser("bad request", "wrong deepseek model name"); err != nil {
|
||||
logger.Warn("failed ot notify user", "error", err)
|
||||
return err
|
||||
}
|
||||
showToast("bad request", "wrong deepseek model name")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// == shellmode ==
|
||||
|
||||
func toggleShellMode() {
|
||||
shellMode = !shellMode
|
||||
setShellMode(shellMode)
|
||||
if shellMode {
|
||||
shellInput.SetLabel(fmt.Sprintf("[%s]$ ", cfg.FilePickerDir))
|
||||
} else {
|
||||
textArea.SetPlaceholder("input is multiline; press <Enter> to start the next line;\npress <Esc> to send the message.")
|
||||
}
|
||||
updateStatusLine()
|
||||
}
|
||||
|
||||
func updateFlexLayout() {
|
||||
if fullscreenMode {
|
||||
// flex already contains only focused widget; do nothing
|
||||
return
|
||||
}
|
||||
flex.Clear()
|
||||
flex.AddItem(textView, 0, 40, false)
|
||||
if shellMode {
|
||||
flex.AddItem(shellInput, 0, 10, false)
|
||||
} else {
|
||||
flex.AddItem(bottomFlex, 0, 10, true)
|
||||
}
|
||||
if positionVisible {
|
||||
flex.AddItem(statusLineWidget, 0, 2, false)
|
||||
}
|
||||
// Keep focus on currently focused widget
|
||||
focused := app.GetFocus()
|
||||
switch {
|
||||
case focused == textView:
|
||||
app.SetFocus(textView)
|
||||
case shellMode:
|
||||
app.SetFocus(shellInput)
|
||||
default:
|
||||
app.SetFocus(textArea)
|
||||
}
|
||||
}
|
||||
|
||||
func executeCommandAndDisplay(cmdText string) {
|
||||
cmdText = strings.TrimSpace(cmdText)
|
||||
if cmdText == "" {
|
||||
fmt.Fprintf(textView, "\n[red]Error: No command provided[-:-:-]\n")
|
||||
if scrollToEndEnabled {
|
||||
textView.ScrollToEnd()
|
||||
}
|
||||
colorText()
|
||||
return
|
||||
}
|
||||
workingDir := cfg.FilePickerDir
|
||||
// Handle cd command specially to update working directory
|
||||
if strings.HasPrefix(cmdText, "cd ") {
|
||||
newDir := strings.TrimPrefix(cmdText, "cd ")
|
||||
newDir = strings.TrimSpace(newDir)
|
||||
// Handle cd ~ or cdHOME
|
||||
if strings.HasPrefix(newDir, "~") {
|
||||
home := os.Getenv("HOME")
|
||||
newDir = strings.Replace(newDir, "~", home, 1)
|
||||
}
|
||||
// Check if directory exists
|
||||
if _, err := os.Stat(newDir); err == nil {
|
||||
workingDir = newDir
|
||||
cfg.FilePickerDir = workingDir
|
||||
// Update shell input label with new directory
|
||||
shellInput.SetLabel(fmt.Sprintf("[%s]$ ", cfg.FilePickerDir))
|
||||
outputContent := workingDir
|
||||
// Add the command being executed to the chat
|
||||
fmt.Fprintf(textView, "\n[-:-:b](%d) <%s>: [-:-:-]\n$ %s\n",
|
||||
len(chatBody.Messages), cfg.ToolRole, cmdText)
|
||||
fmt.Fprintf(textView, "%s\n", outputContent)
|
||||
combinedMsg := models.RoleMsg{
|
||||
Role: cfg.ToolRole,
|
||||
Content: "$ " + cmdText + "\n\n" + outputContent,
|
||||
}
|
||||
chatBody.Messages = append(chatBody.Messages, combinedMsg)
|
||||
if scrollToEndEnabled {
|
||||
textView.ScrollToEnd()
|
||||
}
|
||||
colorText()
|
||||
return
|
||||
} else {
|
||||
outputContent := "cd: " + newDir + ": No such file or directory"
|
||||
fmt.Fprintf(textView, "\n[-:-:b](%d) <%s>: [-:-:-]\n$ %s\n",
|
||||
len(chatBody.Messages), cfg.ToolRole, cmdText)
|
||||
fmt.Fprintf(textView, "[red]%s[-:-:-]\n", outputContent)
|
||||
combinedMsg := models.RoleMsg{
|
||||
Role: cfg.ToolRole,
|
||||
Content: "$ " + cmdText + "\n\n" + outputContent,
|
||||
}
|
||||
chatBody.Messages = append(chatBody.Messages, combinedMsg)
|
||||
if scrollToEndEnabled {
|
||||
textView.ScrollToEnd()
|
||||
}
|
||||
colorText()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Use /bin/sh to support pipes, redirects, etc.
|
||||
cmd := exec.Command("/bin/sh", "-c", cmdText)
|
||||
cmd.Dir = workingDir
|
||||
// Execute the command and get output
|
||||
output, err := cmd.CombinedOutput()
|
||||
// Add the command being executed to the chat
|
||||
fmt.Fprintf(textView, "\n[-:-:b](%d) <%s>: [-:-:-]\n$ %s\n",
|
||||
len(chatBody.Messages), cfg.ToolRole, cmdText)
|
||||
var outputContent string
|
||||
if err != nil {
|
||||
// Include both output and error
|
||||
errorMsg := "Error: " + err.Error()
|
||||
fmt.Fprintf(textView, "[red]%s[-:-:-]\n", errorMsg)
|
||||
if len(output) > 0 {
|
||||
outputStr := string(output)
|
||||
fmt.Fprintf(textView, "[red]%s[-:-:-]\n", outputStr)
|
||||
outputContent = errorMsg + "\n" + outputStr
|
||||
} else {
|
||||
outputContent = errorMsg
|
||||
}
|
||||
} else {
|
||||
// Only output if successful
|
||||
if len(output) > 0 {
|
||||
outputStr := string(output)
|
||||
fmt.Fprintf(textView, "[green]%s[-:-:-]\n", outputStr)
|
||||
outputContent = outputStr
|
||||
} else {
|
||||
successMsg := "Command executed successfully (no output)"
|
||||
fmt.Fprintf(textView, "[green]%s[-:-:-]\n", successMsg)
|
||||
outputContent = successMsg
|
||||
}
|
||||
}
|
||||
// Combine command and output in a single message for chat history
|
||||
combinedContent := "$ " + cmdText + "\n\n" + outputContent
|
||||
combinedMsg := models.RoleMsg{
|
||||
Role: cfg.ToolRole,
|
||||
Content: combinedContent,
|
||||
}
|
||||
chatBody.Messages = append(chatBody.Messages, combinedMsg)
|
||||
// Scroll to end and update colors
|
||||
if scrollToEndEnabled {
|
||||
textView.ScrollToEnd()
|
||||
}
|
||||
colorText()
|
||||
// Add command to history (avoid duplicates at the end)
|
||||
if len(shellHistory) == 0 || shellHistory[len(shellHistory)-1] != cmdText {
|
||||
shellHistory = append(shellHistory, cmdText)
|
||||
}
|
||||
shellHistoryPos = -1
|
||||
}
|
||||
|
||||
// == search ==
|
||||
|
||||
// Global variables for search state
|
||||
var searchResults []int
|
||||
var searchResultLengths []int // To store the length of each match in the formatted string
|
||||
var searchIndex int
|
||||
var searchText string
|
||||
var originalTextForSearch string
|
||||
|
||||
// performSearch searches for the given term in the textView content and highlights matches
|
||||
func performSearch(term string) {
|
||||
searchText = term
|
||||
if searchText == "" {
|
||||
searchResults = nil
|
||||
searchResultLengths = nil
|
||||
originalTextForSearch = ""
|
||||
// Re-render text without highlights
|
||||
textView.SetText(chatToText(chatBody.Messages, cfg.ShowSys))
|
||||
colorText()
|
||||
return
|
||||
}
|
||||
// Get formatted text and search directly in it to avoid mapping issues
|
||||
formattedText := textView.GetText(true)
|
||||
originalTextForSearch = formattedText
|
||||
searchTermLower := strings.ToLower(searchText)
|
||||
formattedTextLower := strings.ToLower(formattedText)
|
||||
// Find all occurrences of the search term in the formatted text directly
|
||||
formattedSearchResults := []int{}
|
||||
searchStart := 0
|
||||
for {
|
||||
pos := strings.Index(formattedTextLower[searchStart:], searchTermLower)
|
||||
if pos == -1 {
|
||||
break
|
||||
}
|
||||
absolutePos := searchStart + pos
|
||||
formattedSearchResults = append(formattedSearchResults, absolutePos)
|
||||
searchStart = absolutePos + len(searchText)
|
||||
}
|
||||
if len(formattedSearchResults) == 0 {
|
||||
// No matches found
|
||||
searchResults = nil
|
||||
searchResultLengths = nil
|
||||
notification := "Pattern not found: " + term
|
||||
showToast("search", notification)
|
||||
return
|
||||
}
|
||||
// Store the formatted text positions and lengths for accurate highlighting
|
||||
searchResults = formattedSearchResults
|
||||
// Create lengths array - all matches have the same length as the search term
|
||||
searchResultLengths = make([]int, len(formattedSearchResults))
|
||||
for i := range searchResultLengths {
|
||||
searchResultLengths[i] = len(searchText)
|
||||
}
|
||||
searchIndex = 0
|
||||
highlightCurrentMatch()
|
||||
}
|
||||
|
||||
// highlightCurrentMatch highlights the current search match and scrolls to it
|
||||
func highlightCurrentMatch() {
|
||||
if len(searchResults) == 0 || searchIndex >= len(searchResults) {
|
||||
return
|
||||
}
|
||||
// Get the stored formatted text
|
||||
formattedText := originalTextForSearch
|
||||
// For tview to properly support highlighting and scrolling, we need to work with its region system
|
||||
// Instead of just applying highlights, we need to add region tags to the text
|
||||
highlightedText := addRegionTags(formattedText, searchResults, searchResultLengths, searchIndex, searchText)
|
||||
// Update the text view with the text that includes region tags
|
||||
textView.SetText(highlightedText)
|
||||
// Highlight the current region and scroll to it
|
||||
// Need to identify which position in the results array corresponds to the current match
|
||||
// The region ID will be search_<position>_<index>
|
||||
currentRegion := fmt.Sprintf("search_%d_%d", searchResults[searchIndex], searchIndex)
|
||||
textView.Highlight(currentRegion).ScrollToHighlight()
|
||||
// Send notification about which match we're at
|
||||
notification := fmt.Sprintf("Match %d of %d", searchIndex+1, len(searchResults))
|
||||
showToast("search", notification)
|
||||
}
|
||||
|
||||
// showSearchBar shows the search input field as an overlay
|
||||
func showSearchBar() {
|
||||
// Create a temporary flex to combine search and main content
|
||||
updatedFlex := tview.NewFlex().SetDirection(tview.FlexRow).
|
||||
AddItem(searchField, 3, 0, true). // Search field at top
|
||||
AddItem(flex, 0, 1, false) // Main flex layout below
|
||||
// Add the search overlay as a page
|
||||
pages.AddPage(searchPageName, updatedFlex, true, true)
|
||||
app.SetFocus(searchField)
|
||||
}
|
||||
|
||||
// hideSearchBar hides the search input field
|
||||
func hideSearchBar() {
|
||||
pages.RemovePage(searchPageName)
|
||||
// Return focus to the text view
|
||||
app.SetFocus(textView)
|
||||
// Clear the search field
|
||||
searchField.SetText("")
|
||||
}
|
||||
|
||||
// Global variables for index overlay functionality
|
||||
var indexPageName = "indexOverlay"
|
||||
|
||||
// showIndexBar shows the index input field as an overlay at the top
|
||||
func showIndexBar() {
|
||||
// Create a temporary flex to combine index input and main content
|
||||
updatedFlex := tview.NewFlex().SetDirection(tview.FlexRow).
|
||||
AddItem(indexPickWindow, 3, 0, true). // Index field at top
|
||||
AddItem(flex, 0, 1, false) // Main flex layout below
|
||||
|
||||
// Add the index overlay as a page
|
||||
pages.AddPage(indexPageName, updatedFlex, true, true)
|
||||
app.SetFocus(indexPickWindow)
|
||||
}
|
||||
|
||||
// hideIndexBar hides the index input field
|
||||
func hideIndexBar() {
|
||||
pages.RemovePage(indexPageName)
|
||||
// Return focus to the text view
|
||||
app.SetFocus(textView)
|
||||
// Clear the index field
|
||||
indexPickWindow.SetText("")
|
||||
}
|
||||
|
||||
// addRegionTags adds region tags to search matches in the text for tview highlighting
|
||||
func addRegionTags(text string, positions []int, lengths []int, currentIdx int, searchTerm string) string {
|
||||
if len(positions) == 0 {
|
||||
return text
|
||||
}
|
||||
var result strings.Builder
|
||||
lastEnd := 0
|
||||
for i, pos := range positions {
|
||||
endPos := pos + lengths[i]
|
||||
// Add text before this match
|
||||
if pos > lastEnd {
|
||||
result.WriteString(text[lastEnd:pos])
|
||||
}
|
||||
// The matched text, which may contain its own formatting tags
|
||||
actualText := text[pos:endPos]
|
||||
// Add region tag and highlighting for this match
|
||||
// Use a unique region id that includes the match index to avoid conflicts
|
||||
regionId := fmt.Sprintf("search_%d_%d", pos, i) // position + index to ensure uniqueness
|
||||
var highlightStart, highlightEnd string
|
||||
if i == currentIdx {
|
||||
// Current match - use different highlighting
|
||||
highlightStart = fmt.Sprintf(`["%s"][yellow:blue:b]`, regionId) // Current match with region and special highlight
|
||||
highlightEnd = `[-:-:-][""]` // Reset formatting and close region
|
||||
} else {
|
||||
// Other matches - use regular highlighting
|
||||
highlightStart = fmt.Sprintf(`["%s"][gold:red:u]`, regionId) // Other matches with region and highlight
|
||||
highlightEnd = `[-:-:-][""]` // Reset formatting and close region
|
||||
}
|
||||
result.WriteString(highlightStart)
|
||||
result.WriteString(actualText)
|
||||
result.WriteString(highlightEnd)
|
||||
lastEnd = endPos
|
||||
}
|
||||
// Add the rest of the text after the last processed match
|
||||
if lastEnd < len(text) {
|
||||
result.WriteString(text[lastEnd:])
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// searchNext finds the next occurrence of the search term
|
||||
func searchNext() {
|
||||
if len(searchResults) == 0 {
|
||||
showToast("search", "No search results to navigate")
|
||||
return
|
||||
}
|
||||
searchIndex = (searchIndex + 1) % len(searchResults)
|
||||
highlightCurrentMatch()
|
||||
}
|
||||
|
||||
// searchPrev finds the previous occurrence of the search term
|
||||
func searchPrev() {
|
||||
if len(searchResults) == 0 {
|
||||
showToast("search", "No search results to navigate")
|
||||
return
|
||||
}
|
||||
if searchIndex == 0 {
|
||||
searchIndex = len(searchResults) - 1
|
||||
} else {
|
||||
searchIndex--
|
||||
}
|
||||
highlightCurrentMatch()
|
||||
}
|
||||
|
||||
// == tab completion ==
|
||||
|
||||
func scanFiles(dir, filter string) []string {
|
||||
const maxDepth = 3
|
||||
const maxFiles = 50
|
||||
var files []string
|
||||
var scanRecursive func(currentDir string, currentDepth int, relPath string)
|
||||
scanRecursive = func(currentDir string, currentDepth int, relPath string) {
|
||||
if len(files) >= maxFiles {
|
||||
return
|
||||
}
|
||||
if currentDepth > maxDepth {
|
||||
return
|
||||
}
|
||||
entries, err := os.ReadDir(currentDir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if len(files) >= maxFiles {
|
||||
return
|
||||
}
|
||||
name := entry.Name()
|
||||
if strings.HasPrefix(name, ".") {
|
||||
continue
|
||||
}
|
||||
fullPath := name
|
||||
if relPath != "" {
|
||||
fullPath = relPath + "/" + name
|
||||
}
|
||||
if entry.IsDir() {
|
||||
// Recursively scan subdirectories
|
||||
scanRecursive(filepath.Join(currentDir, name), currentDepth+1, fullPath)
|
||||
continue
|
||||
}
|
||||
// Check if file matches filter
|
||||
if filter == "" || strings.HasPrefix(strings.ToLower(fullPath), strings.ToLower(filter)) {
|
||||
files = append(files, fullPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
scanRecursive(dir, 0, "")
|
||||
return files
|
||||
}
|
||||
|
||||
// models logic that is too complex for models package
|
||||
func MsgToText(i int, m *models.RoleMsg) string {
|
||||
var contentStr string
|
||||
var imageIndicators []string
|
||||
if !m.HasContentParts {
|
||||
contentStr = m.Content
|
||||
} else {
|
||||
var textParts []string
|
||||
for _, part := range m.ContentParts {
|
||||
switch p := part.(type) {
|
||||
case models.TextContentPart:
|
||||
if p.Type == "text" {
|
||||
textParts = append(textParts, p.Text)
|
||||
}
|
||||
case models.ImageContentPart:
|
||||
displayPath := p.Path
|
||||
if displayPath == "" {
|
||||
displayPath = "image"
|
||||
} else {
|
||||
displayPath = extractDisplayPath(displayPath, cfg.FilePickerDir)
|
||||
}
|
||||
imageIndicators = append(imageIndicators, fmt.Sprintf("[orange::i][image: %s][-:-:-]", displayPath))
|
||||
case map[string]any:
|
||||
if partType, exists := p["type"]; exists {
|
||||
switch partType {
|
||||
case "text":
|
||||
if textVal, textExists := p["text"]; textExists {
|
||||
if textStr, isStr := textVal.(string); isStr {
|
||||
textParts = append(textParts, textStr)
|
||||
}
|
||||
}
|
||||
case "image_url":
|
||||
var displayPath string
|
||||
if pathVal, pathExists := p["path"]; pathExists {
|
||||
if pathStr, isStr := pathVal.(string); isStr && pathStr != "" {
|
||||
displayPath = extractDisplayPath(pathStr, cfg.FilePickerDir)
|
||||
}
|
||||
}
|
||||
if displayPath == "" {
|
||||
displayPath = "image"
|
||||
}
|
||||
imageIndicators = append(imageIndicators, fmt.Sprintf("[orange::i][image: %s][-:-:-]", displayPath))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
contentStr = strings.Join(textParts, " ") + " "
|
||||
}
|
||||
contentStr, _ = strings.CutPrefix(contentStr, m.Role+":")
|
||||
icon := fmt.Sprintf("(%d) <%s>: ", i, m.Role)
|
||||
var finalContent strings.Builder
|
||||
if len(imageIndicators) > 0 {
|
||||
for _, indicator := range imageIndicators {
|
||||
finalContent.WriteString(indicator)
|
||||
finalContent.WriteString("\n")
|
||||
}
|
||||
}
|
||||
finalContent.WriteString(contentStr)
|
||||
if m.Stats != nil {
|
||||
fmt.Fprintf(&finalContent, "\n[gray::i][%d tok, %.1fs, %.1f t/s][-:-:-]", m.Stats.Tokens, m.Stats.Duration, m.Stats.TokensPerSec)
|
||||
}
|
||||
textMsg := fmt.Sprintf("[-:-:b]%s[-:-:-]\n%s\n", icon, finalContent.String())
|
||||
return strings.ReplaceAll(textMsg, "\n\n", "\n")
|
||||
}
|
||||
|
||||
// extractDisplayPath returns a path suitable for display, potentially relative to imageBaseDir
|
||||
func extractDisplayPath(p, bp string) string {
|
||||
if p == "" {
|
||||
return ""
|
||||
}
|
||||
// If base directory is set, try to make path relative to it
|
||||
if bp != "" {
|
||||
if rel, err := filepath.Rel(bp, p); err == nil {
|
||||
// Check if relative path doesn't start with ".." (meaning it's within base dir)
|
||||
// If it starts with "..", we might still want to show it as relative
|
||||
// but for now we show full path if it goes outside base dir
|
||||
if !strings.HasPrefix(rel, "..") {
|
||||
p = rel
|
||||
}
|
||||
}
|
||||
}
|
||||
// Truncate long paths to last 60 characters if needed
|
||||
if len(p) > 60 {
|
||||
return "..." + p[len(p)-60:]
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
280
llm.go
280
llm.go
@@ -3,7 +3,6 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"gf-lt/models"
|
||||
"io"
|
||||
"strings"
|
||||
@@ -11,12 +10,11 @@ import (
|
||||
|
||||
var imageAttachmentPath string // Global variable to track image attachment for next message
|
||||
var lastImg string // for ctrl+j
|
||||
var RAGMsg = "Retrieved context for user's query:\n"
|
||||
|
||||
// containsToolSysMsg checks if the toolSysMsg already exists in the chat body
|
||||
func containsToolSysMsg() bool {
|
||||
for _, msg := range chatBody.Messages {
|
||||
if msg.Role == cfg.ToolRole && msg.Content == toolSysMsg {
|
||||
for i := range chatBody.Messages {
|
||||
if chatBody.Messages[i].Role == cfg.ToolRole && chatBody.Messages[i].Content == toolSysMsg {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -120,73 +118,69 @@ func (lcp LCPCompletion) FormMsg(msg, role string, resume bool) (io.Reader, erro
|
||||
logger.Debug("formmsg lcpcompletion", "link", cfg.CurrentAPI)
|
||||
localImageAttachmentPath := imageAttachmentPath
|
||||
var multimodalData []string
|
||||
if msg != "" { // otherwise let the bot to continue
|
||||
var newMsg models.RoleMsg
|
||||
if localImageAttachmentPath != "" {
|
||||
newMsg = models.NewMultimodalMsg(role, []any{})
|
||||
newMsg.AddTextPart(msg)
|
||||
imageURL, err := models.CreateImageURLFromPath(localImageAttachmentPath)
|
||||
if err != nil {
|
||||
logger.Error("failed to create image URL from path for completion",
|
||||
"error", err, "path", localImageAttachmentPath)
|
||||
return nil, err
|
||||
}
|
||||
// Extract base64 part from data URL (e.g., "data:image/jpeg;base64,...")
|
||||
parts := strings.SplitN(imageURL, ",", 2)
|
||||
if len(parts) == 2 {
|
||||
multimodalData = append(multimodalData, parts[1])
|
||||
} else {
|
||||
logger.Error("invalid image data URL format", "url", imageURL)
|
||||
return nil, errors.New("invalid image data URL format")
|
||||
}
|
||||
newMsg.AddImagePart(imageURL, localImageAttachmentPath)
|
||||
imageAttachmentPath = "" // Clear the attachment after use
|
||||
} else { // not a multimodal msg or image passed in tool call
|
||||
newMsg = models.RoleMsg{Role: role, Content: msg}
|
||||
}
|
||||
if msg != "" { // otherwise let the bot to continue
|
||||
newMsg := models.RoleMsg{Role: role, Content: msg}
|
||||
newMsg = *processMessageTag(&newMsg)
|
||||
chatBody.Messages = append(chatBody.Messages, newMsg)
|
||||
}
|
||||
// if rag - add as system message to avoid conflicts with tool usage
|
||||
if !resume && cfg.RAGEnabled {
|
||||
um := chatBody.Messages[len(chatBody.Messages)-1].Content
|
||||
logger.Debug("RAG is enabled, preparing RAG context", "user_message", um)
|
||||
ragResp, err := chatRagUse(um)
|
||||
if err != nil {
|
||||
logger.Error("failed to form a rag msg", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
logger.Debug("RAG response received", "response_len", len(ragResp),
|
||||
"response_preview", ragResp[:min(len(ragResp), 100)])
|
||||
// Use system role for RAG context to avoid conflicts with tool usage
|
||||
ragMsg := models.RoleMsg{Role: "system", Content: RAGMsg + ragResp}
|
||||
chatBody.Messages = append(chatBody.Messages, ragMsg)
|
||||
logger.Debug("RAG message added to chat body", "message_count", len(chatBody.Messages))
|
||||
}
|
||||
// sending description of the tools and how to use them
|
||||
if cfg.ToolUse && !resume && role == cfg.UserRole && !containsToolSysMsg() {
|
||||
chatBody.Messages = append(chatBody.Messages, models.RoleMsg{Role: cfg.ToolRole, Content: toolSysMsg})
|
||||
}
|
||||
filteredMessages, botPersona := filterMessagesForCurrentCharacter(chatBody.Messages)
|
||||
// Build prompt and extract images inline as we process each message
|
||||
messages := make([]string, len(filteredMessages))
|
||||
for i, m := range filteredMessages {
|
||||
messages[i] = stripThinkingFromMsg(&m).ToPrompt()
|
||||
for i := range filteredMessages {
|
||||
m := stripThinkingFromMsg(&filteredMessages[i])
|
||||
messages[i] = m.ToPrompt()
|
||||
// Extract images from this message and add marker inline
|
||||
if len(m.ContentParts) > 0 {
|
||||
for _, part := range m.ContentParts {
|
||||
var imgURL string
|
||||
// Check for struct type
|
||||
if imgPart, ok := part.(models.ImageContentPart); ok {
|
||||
imgURL = imgPart.ImageURL.URL
|
||||
} else if partMap, ok := part.(map[string]any); ok {
|
||||
// Check for map type (from JSON unmarshaling)
|
||||
if partType, exists := partMap["type"]; exists && partType == "image_url" {
|
||||
if imgURLMap, ok := partMap["image_url"].(map[string]any); ok {
|
||||
if url, ok := imgURLMap["url"].(string); ok {
|
||||
imgURL = url
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if imgURL != "" {
|
||||
// Extract base64 part from data URL (e.g., "data:image/jpeg;base64,...")
|
||||
parts := strings.SplitN(imgURL, ",", 2)
|
||||
if len(parts) == 2 {
|
||||
multimodalData = append(multimodalData, parts[1])
|
||||
messages[i] += " <__media__>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
prompt := strings.Join(messages, "\n")
|
||||
// Add multimodal media markers to the prompt text when multimodal data is present
|
||||
// This is required by llama.cpp multimodal models so they know where to insert media
|
||||
if len(multimodalData) > 0 {
|
||||
// Add a media marker for each item in the multimodal data
|
||||
var sb strings.Builder
|
||||
sb.WriteString(prompt)
|
||||
for range multimodalData {
|
||||
sb.WriteString(" <__media__>") // llama.cpp default multimodal marker
|
||||
}
|
||||
prompt = sb.String()
|
||||
}
|
||||
// needs to be after <__media__> if there are images
|
||||
if !resume {
|
||||
botMsgStart := "\n" + botPersona + ":\n"
|
||||
prompt += botMsgStart
|
||||
}
|
||||
if cfg.ThinkUse && !cfg.ToolUse {
|
||||
prompt += "<think>"
|
||||
}
|
||||
logger.Debug("checking prompt for /completion", "tool_use", cfg.ToolUse,
|
||||
"msg", msg, "resume", resume, "prompt", prompt, "multimodal_data_count", len(multimodalData))
|
||||
payload := models.NewLCPReq(prompt, chatBody.Model, multimodalData,
|
||||
@@ -230,17 +224,15 @@ func (op LCPChat) ParseChunk(data []byte) (*models.TextChunk, error) {
|
||||
logger.Error("failed to decode", "error", err, "line", string(data))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Handle multiple choices safely
|
||||
if len(llmchunk.Choices) == 0 {
|
||||
logger.Warn("LCPChat ParseChunk: no choices in response", "data", string(data))
|
||||
return &models.TextChunk{Finished: true}, nil
|
||||
logger.Warn("LCPChat empty chunk choices", "raw_data", string(data), "chunk", llmchunk)
|
||||
return &models.TextChunk{}, nil
|
||||
}
|
||||
|
||||
lastChoice := llmchunk.Choices[len(llmchunk.Choices)-1]
|
||||
resp := &models.TextChunk{
|
||||
Chunk: llmchunk.Choices[len(llmchunk.Choices)-1].Delta.Content,
|
||||
Chunk: lastChoice.Delta.Content,
|
||||
Reasoning: lastChoice.Delta.ReasoningContent,
|
||||
}
|
||||
|
||||
// Check for tool calls in all choices, not just the last one
|
||||
for _, choice := range llmchunk.Choices {
|
||||
if len(choice.Delta.ToolCalls) > 0 {
|
||||
@@ -255,8 +247,7 @@ func (op LCPChat) ParseChunk(data []byte) (*models.TextChunk, error) {
|
||||
break // Process only the first tool call
|
||||
}
|
||||
}
|
||||
|
||||
if llmchunk.Choices[len(llmchunk.Choices)-1].FinishReason == "stop" {
|
||||
if lastChoice.FinishReason == "stop" {
|
||||
if resp.Chunk != "" {
|
||||
logger.Error("text inside of finish llmchunk", "chunk", llmchunk)
|
||||
}
|
||||
@@ -302,23 +293,6 @@ func (op LCPChat) FormMsg(msg, role string, resume bool) (io.Reader, error) {
|
||||
logger.Debug("LCPChat FormMsg: added message to chatBody", "role", newMsg.Role,
|
||||
"content_len", len(newMsg.Content), "message_count_after_add", len(chatBody.Messages))
|
||||
}
|
||||
// if rag - add as system message to avoid conflicts with tool usage
|
||||
if !resume && cfg.RAGEnabled {
|
||||
um := chatBody.Messages[len(chatBody.Messages)-1].Content
|
||||
logger.Debug("LCPChat: RAG is enabled, preparing RAG context", "user_message", um)
|
||||
ragResp, err := chatRagUse(um)
|
||||
if err != nil {
|
||||
logger.Error("LCPChat: failed to form a rag msg", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
logger.Debug("LCPChat: RAG response received",
|
||||
"response_len", len(ragResp), "response_preview", ragResp[:min(len(ragResp), 100)])
|
||||
// Use system role for RAG context to avoid conflicts with tool usage
|
||||
ragMsg := models.RoleMsg{Role: "system", Content: RAGMsg + ragResp}
|
||||
chatBody.Messages = append(chatBody.Messages, ragMsg)
|
||||
logger.Debug("LCPChat: RAG message added to chat body", "role", ragMsg.Role,
|
||||
"rag_content_len", len(ragMsg.Content), "message_count_after_rag", len(chatBody.Messages))
|
||||
}
|
||||
filteredMessages, _ := filterMessagesForCurrentCharacter(chatBody.Messages)
|
||||
// openai /v1/chat does not support custom roles; needs to be user, assistant, system
|
||||
// Add persona suffix to the last user message to indicate who the assistant should reply as
|
||||
@@ -327,14 +301,23 @@ func (op LCPChat) FormMsg(msg, role string, resume bool) (io.Reader, error) {
|
||||
Model: chatBody.Model,
|
||||
Stream: chatBody.Stream,
|
||||
}
|
||||
for i, msg := range filteredMessages {
|
||||
strippedMsg := *stripThinkingFromMsg(&msg)
|
||||
if strippedMsg.Role == cfg.UserRole {
|
||||
for i := range filteredMessages {
|
||||
strippedMsg := *stripThinkingFromMsg(&filteredMessages[i])
|
||||
switch strippedMsg.Role {
|
||||
case cfg.UserRole:
|
||||
bodyCopy.Messages[i] = strippedMsg
|
||||
bodyCopy.Messages[i].Role = "user"
|
||||
} else {
|
||||
case cfg.AssistantRole:
|
||||
bodyCopy.Messages[i] = strippedMsg
|
||||
bodyCopy.Messages[i].Role = "assistant"
|
||||
case cfg.ToolRole:
|
||||
bodyCopy.Messages[i] = strippedMsg
|
||||
bodyCopy.Messages[i].Role = "tool"
|
||||
default:
|
||||
bodyCopy.Messages[i] = strippedMsg
|
||||
}
|
||||
// Clear ToolCalls - they're stored in chat history for display but not sent to LLM
|
||||
// bodyCopy.Messages[i].ToolCall = nil
|
||||
}
|
||||
// Clean null/empty messages to prevent API issues
|
||||
bodyCopy.Messages = consolidateAssistantMessages(bodyCopy.Messages)
|
||||
@@ -364,6 +347,10 @@ func (ds DeepSeekerCompletion) ParseChunk(data []byte) (*models.TextChunk, error
|
||||
logger.Error("failed to decode", "error", err, "line", string(data))
|
||||
return nil, err
|
||||
}
|
||||
if len(llmchunk.Choices) == 0 {
|
||||
logger.Warn("empty chunk choices", "raw_data", string(data), "chunk", llmchunk)
|
||||
return &models.TextChunk{}, nil
|
||||
}
|
||||
resp := &models.TextChunk{
|
||||
Chunk: llmchunk.Choices[0].Text,
|
||||
}
|
||||
@@ -390,30 +377,14 @@ func (ds DeepSeekerCompletion) FormMsg(msg, role string, resume bool) (io.Reader
|
||||
newMsg = *processMessageTag(&newMsg)
|
||||
chatBody.Messages = append(chatBody.Messages, newMsg)
|
||||
}
|
||||
// if rag - add as system message to avoid conflicts with tool usage
|
||||
if !resume && cfg.RAGEnabled {
|
||||
um := chatBody.Messages[len(chatBody.Messages)-1].Content
|
||||
logger.Debug("DeepSeekerCompletion: RAG is enabled, preparing RAG context", "user_message", um)
|
||||
ragResp, err := chatRagUse(um)
|
||||
if err != nil {
|
||||
logger.Error("DeepSeekerCompletion: failed to form a rag msg", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
logger.Debug("DeepSeekerCompletion: RAG response received",
|
||||
"response_len", len(ragResp), "response_preview", ragResp[:min(len(ragResp), 100)])
|
||||
// Use system role for RAG context to avoid conflicts with tool usage
|
||||
ragMsg := models.RoleMsg{Role: "system", Content: RAGMsg + ragResp}
|
||||
chatBody.Messages = append(chatBody.Messages, ragMsg)
|
||||
logger.Debug("DeepSeekerCompletion: RAG message added to chat body", "message_count", len(chatBody.Messages))
|
||||
}
|
||||
// sending description of the tools and how to use them
|
||||
if cfg.ToolUse && !resume && role == cfg.UserRole && !containsToolSysMsg() {
|
||||
chatBody.Messages = append(chatBody.Messages, models.RoleMsg{Role: cfg.ToolRole, Content: toolSysMsg})
|
||||
}
|
||||
filteredMessages, botPersona := filterMessagesForCurrentCharacter(chatBody.Messages)
|
||||
messages := make([]string, len(filteredMessages))
|
||||
for i, m := range filteredMessages {
|
||||
messages[i] = stripThinkingFromMsg(&m).ToPrompt()
|
||||
for i := range filteredMessages {
|
||||
messages[i] = stripThinkingFromMsg(&filteredMessages[i]).ToPrompt()
|
||||
}
|
||||
prompt := strings.Join(messages, "\n")
|
||||
// strings builder?
|
||||
@@ -421,9 +392,6 @@ func (ds DeepSeekerCompletion) FormMsg(msg, role string, resume bool) (io.Reader
|
||||
botMsgStart := "\n" + botPersona + ":\n"
|
||||
prompt += botMsgStart
|
||||
}
|
||||
if cfg.ThinkUse && !cfg.ToolUse {
|
||||
prompt += "<think>"
|
||||
}
|
||||
logger.Debug("checking prompt for /completion", "tool_use", cfg.ToolUse,
|
||||
"msg", msg, "resume", resume, "prompt", prompt)
|
||||
payload := models.NewDSCompletionReq(prompt, chatBody.Model,
|
||||
@@ -448,6 +416,10 @@ func (ds DeepSeekerChat) ParseChunk(data []byte) (*models.TextChunk, error) {
|
||||
return nil, err
|
||||
}
|
||||
resp := &models.TextChunk{}
|
||||
if len(llmchunk.Choices) == 0 {
|
||||
logger.Warn("empty chunk choices", "raw_data", string(data), "chunk", llmchunk)
|
||||
return resp, nil
|
||||
}
|
||||
if llmchunk.Choices[0].FinishReason != "" {
|
||||
if llmchunk.Choices[0].Delta.Content != "" {
|
||||
logger.Error("text inside of finish llmchunk", "chunk", llmchunk)
|
||||
@@ -478,22 +450,6 @@ func (ds DeepSeekerChat) FormMsg(msg, role string, resume bool) (io.Reader, erro
|
||||
newMsg = *processMessageTag(&newMsg)
|
||||
chatBody.Messages = append(chatBody.Messages, newMsg)
|
||||
}
|
||||
// if rag - add as system message to avoid conflicts with tool usage
|
||||
if !resume && cfg.RAGEnabled {
|
||||
um := chatBody.Messages[len(chatBody.Messages)-1].Content
|
||||
logger.Debug("RAG is enabled, preparing RAG context", "user_message", um)
|
||||
ragResp, err := chatRagUse(um)
|
||||
if err != nil {
|
||||
logger.Error("failed to form a rag msg", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
logger.Debug("RAG response received", "response_len", len(ragResp),
|
||||
"response_preview", ragResp[:min(len(ragResp), 100)])
|
||||
// Use system role for RAG context to avoid conflicts with tool usage
|
||||
ragMsg := models.RoleMsg{Role: "system", Content: RAGMsg + ragResp}
|
||||
chatBody.Messages = append(chatBody.Messages, ragMsg)
|
||||
logger.Debug("RAG message added to chat body", "message_count", len(chatBody.Messages))
|
||||
}
|
||||
// Create copy of chat body with standardized user role
|
||||
filteredMessages, _ := filterMessagesForCurrentCharacter(chatBody.Messages)
|
||||
// Add persona suffix to the last user message to indicate who the assistant should reply as
|
||||
@@ -502,14 +458,27 @@ func (ds DeepSeekerChat) FormMsg(msg, role string, resume bool) (io.Reader, erro
|
||||
Model: chatBody.Model,
|
||||
Stream: chatBody.Stream,
|
||||
}
|
||||
for i, msg := range filteredMessages {
|
||||
strippedMsg := *stripThinkingFromMsg(&msg)
|
||||
if strippedMsg.Role == cfg.UserRole || i == 1 {
|
||||
for i := range filteredMessages {
|
||||
strippedMsg := *stripThinkingFromMsg(&filteredMessages[i])
|
||||
switch strippedMsg.Role {
|
||||
case cfg.UserRole:
|
||||
if i == 1 {
|
||||
bodyCopy.Messages[i] = strippedMsg
|
||||
bodyCopy.Messages[i].Role = "user"
|
||||
} else {
|
||||
bodyCopy.Messages[i] = strippedMsg
|
||||
}
|
||||
case cfg.AssistantRole:
|
||||
bodyCopy.Messages[i] = strippedMsg
|
||||
bodyCopy.Messages[i].Role = "assistant"
|
||||
case cfg.ToolRole:
|
||||
bodyCopy.Messages[i] = strippedMsg
|
||||
bodyCopy.Messages[i].Role = "tool"
|
||||
default:
|
||||
bodyCopy.Messages[i] = strippedMsg
|
||||
}
|
||||
// Clear ToolCalls - they're stored in chat history for display but not sent to LLM
|
||||
// bodyCopy.Messages[i].ToolCall = nil
|
||||
}
|
||||
// Clean null/empty messages to prevent API issues
|
||||
bodyCopy.Messages = consolidateAssistantMessages(bodyCopy.Messages)
|
||||
@@ -533,6 +502,10 @@ func (or OpenRouterCompletion) ParseChunk(data []byte) (*models.TextChunk, error
|
||||
logger.Error("failed to decode", "error", err, "line", string(data))
|
||||
return nil, err
|
||||
}
|
||||
if len(llmchunk.Choices) == 0 {
|
||||
logger.Warn("empty chunk choices", "raw_data", string(data), "chunk", llmchunk)
|
||||
return &models.TextChunk{}, nil
|
||||
}
|
||||
resp := &models.TextChunk{
|
||||
Chunk: llmchunk.Choices[len(llmchunk.Choices)-1].Text,
|
||||
}
|
||||
@@ -556,30 +529,14 @@ func (or OpenRouterCompletion) FormMsg(msg, role string, resume bool) (io.Reader
|
||||
newMsg = *processMessageTag(&newMsg)
|
||||
chatBody.Messages = append(chatBody.Messages, newMsg)
|
||||
}
|
||||
// if rag - add as system message to avoid conflicts with tool usage
|
||||
if !resume && cfg.RAGEnabled {
|
||||
um := chatBody.Messages[len(chatBody.Messages)-1].Content
|
||||
logger.Debug("RAG is enabled, preparing RAG context", "user_message", um)
|
||||
ragResp, err := chatRagUse(um)
|
||||
if err != nil {
|
||||
logger.Error("failed to form a rag msg", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
logger.Debug("RAG response received", "response_len",
|
||||
len(ragResp), "response_preview", ragResp[:min(len(ragResp), 100)])
|
||||
// Use system role for RAG context to avoid conflicts with tool usage
|
||||
ragMsg := models.RoleMsg{Role: "system", Content: RAGMsg + ragResp}
|
||||
chatBody.Messages = append(chatBody.Messages, ragMsg)
|
||||
logger.Debug("RAG message added to chat body", "message_count", len(chatBody.Messages))
|
||||
}
|
||||
// sending description of the tools and how to use them
|
||||
if cfg.ToolUse && !resume && role == cfg.UserRole && !containsToolSysMsg() {
|
||||
chatBody.Messages = append(chatBody.Messages, models.RoleMsg{Role: cfg.ToolRole, Content: toolSysMsg})
|
||||
}
|
||||
filteredMessages, botPersona := filterMessagesForCurrentCharacter(chatBody.Messages)
|
||||
messages := make([]string, len(filteredMessages))
|
||||
for i, m := range filteredMessages {
|
||||
messages[i] = stripThinkingFromMsg(&m).ToPrompt()
|
||||
for i := range filteredMessages {
|
||||
messages[i] = stripThinkingFromMsg(&filteredMessages[i]).ToPrompt()
|
||||
}
|
||||
prompt := strings.Join(messages, "\n")
|
||||
// strings builder?
|
||||
@@ -587,9 +544,6 @@ func (or OpenRouterCompletion) FormMsg(msg, role string, resume bool) (io.Reader
|
||||
botMsgStart := "\n" + botPersona + ":\n"
|
||||
prompt += botMsgStart
|
||||
}
|
||||
if cfg.ThinkUse && !cfg.ToolUse {
|
||||
prompt += "<think>"
|
||||
}
|
||||
stopSlice := chatBody.MakeStopSliceExcluding("", listChatRoles())
|
||||
logger.Debug("checking prompt for /completion", "tool_use", cfg.ToolUse,
|
||||
"msg", msg, "resume", resume, "prompt", prompt, "stop_strings", stopSlice)
|
||||
@@ -614,12 +568,18 @@ func (or OpenRouterChat) ParseChunk(data []byte) (*models.TextChunk, error) {
|
||||
logger.Error("failed to decode", "error", err, "line", string(data))
|
||||
return nil, err
|
||||
}
|
||||
if len(llmchunk.Choices) == 0 {
|
||||
logger.Warn("empty chunk choices", "raw_data", string(data), "chunk", llmchunk)
|
||||
return &models.TextChunk{}, nil
|
||||
}
|
||||
lastChoice := llmchunk.Choices[len(llmchunk.Choices)-1]
|
||||
resp := &models.TextChunk{
|
||||
Chunk: llmchunk.Choices[len(llmchunk.Choices)-1].Delta.Content,
|
||||
Chunk: lastChoice.Delta.Content,
|
||||
Reasoning: lastChoice.Delta.Reasoning,
|
||||
}
|
||||
// Handle tool calls similar to LCPChat
|
||||
if len(llmchunk.Choices[len(llmchunk.Choices)-1].Delta.ToolCalls) > 0 {
|
||||
toolCall := llmchunk.Choices[len(llmchunk.Choices)-1].Delta.ToolCalls[0]
|
||||
if len(lastChoice.Delta.ToolCalls) > 0 {
|
||||
toolCall := lastChoice.Delta.ToolCalls[0]
|
||||
resp.ToolChunk = toolCall.Function.Arguments
|
||||
fname := toolCall.Function.Name
|
||||
if fname != "" {
|
||||
@@ -631,7 +591,7 @@ func (or OpenRouterChat) ParseChunk(data []byte) (*models.TextChunk, error) {
|
||||
if resp.ToolChunk != "" {
|
||||
resp.ToolResp = true
|
||||
}
|
||||
if llmchunk.Choices[len(llmchunk.Choices)-1].FinishReason == "stop" {
|
||||
if lastChoice.FinishReason == "stop" {
|
||||
if resp.Chunk != "" {
|
||||
logger.Error("text inside of finish llmchunk", "chunk", llmchunk)
|
||||
}
|
||||
@@ -675,22 +635,6 @@ func (or OpenRouterChat) FormMsg(msg, role string, resume bool) (io.Reader, erro
|
||||
newMsg = *processMessageTag(&newMsg)
|
||||
chatBody.Messages = append(chatBody.Messages, newMsg)
|
||||
}
|
||||
// if rag - add as system message to avoid conflicts with tool usage
|
||||
if !resume && cfg.RAGEnabled {
|
||||
um := chatBody.Messages[len(chatBody.Messages)-1].Content
|
||||
logger.Debug("RAG is enabled, preparing RAG context", "user_message", um)
|
||||
ragResp, err := chatRagUse(um)
|
||||
if err != nil {
|
||||
logger.Error("failed to form a rag msg", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
logger.Debug("RAG response received", "response_len", len(ragResp),
|
||||
"response_preview", ragResp[:min(len(ragResp), 100)])
|
||||
// Use system role for RAG context to avoid conflicts with tool usage
|
||||
ragMsg := models.RoleMsg{Role: "system", Content: RAGMsg + ragResp}
|
||||
chatBody.Messages = append(chatBody.Messages, ragMsg)
|
||||
logger.Debug("RAG message added to chat body", "message_count", len(chatBody.Messages))
|
||||
}
|
||||
// Create copy of chat body with standardized user role
|
||||
filteredMessages, _ := filterMessagesForCurrentCharacter(chatBody.Messages)
|
||||
// Add persona suffix to the last user message to indicate who the assistant should reply as
|
||||
@@ -699,18 +643,28 @@ func (or OpenRouterChat) FormMsg(msg, role string, resume bool) (io.Reader, erro
|
||||
Model: chatBody.Model,
|
||||
Stream: chatBody.Stream,
|
||||
}
|
||||
for i, msg := range filteredMessages {
|
||||
strippedMsg := *stripThinkingFromMsg(&msg)
|
||||
bodyCopy.Messages[i] = strippedMsg
|
||||
// Standardize role if it's a user role
|
||||
if bodyCopy.Messages[i].Role == cfg.UserRole {
|
||||
for i := range filteredMessages {
|
||||
strippedMsg := *stripThinkingFromMsg(&filteredMessages[i])
|
||||
switch strippedMsg.Role {
|
||||
case cfg.UserRole:
|
||||
bodyCopy.Messages[i] = strippedMsg
|
||||
bodyCopy.Messages[i].Role = "user"
|
||||
case cfg.AssistantRole:
|
||||
bodyCopy.Messages[i] = strippedMsg
|
||||
bodyCopy.Messages[i].Role = "assistant"
|
||||
case cfg.ToolRole:
|
||||
bodyCopy.Messages[i] = strippedMsg
|
||||
bodyCopy.Messages[i].Role = "tool"
|
||||
default:
|
||||
bodyCopy.Messages[i] = strippedMsg
|
||||
}
|
||||
// Clear ToolCalls - they're stored in chat history for display but not sent to LLM
|
||||
// literally deletes data that we need
|
||||
// bodyCopy.Messages[i].ToolCall = nil
|
||||
}
|
||||
// Clean null/empty messages to prevent API issues
|
||||
bodyCopy.Messages = consolidateAssistantMessages(bodyCopy.Messages)
|
||||
orBody := models.NewOpenRouterChatReq(*bodyCopy, defaultLCPProps)
|
||||
orBody := models.NewOpenRouterChatReq(*bodyCopy, defaultLCPProps, cfg.ReasoningEffort)
|
||||
if cfg.ToolUse && !resume && role != cfg.ToolRole {
|
||||
orBody.Tools = baseTools // set tools to use
|
||||
}
|
||||
|
||||
7
main.go
7
main.go
@@ -7,14 +7,19 @@ import (
|
||||
var (
|
||||
boolColors = map[bool]string{true: "green", false: "red"}
|
||||
botRespMode = false
|
||||
toolRunningMode = false
|
||||
editMode = false
|
||||
roleEditMode = false
|
||||
injectRole = true
|
||||
selectedIndex = int(-1)
|
||||
shellMode = false
|
||||
shellHistory []string
|
||||
shellHistoryPos int = -1
|
||||
thinkingCollapsed = false
|
||||
indexLineCompletion = "F12 to show keys help | llm turn: [%s:-:b]%v[-:-:-] (F6) | chat: [orange:-:b]%s[-:-:-] (F1) | toolUseAdviced: [%s:-:b]%v[-:-:-] (ctrl+k) | model: [%s:-:b]%s[-:-:-] (ctrl+l) | skip LLM resp: [%s:-:b]%v[-:-:-] (F10)\nAPI: [orange:-:b]%s[-:-:-] (ctrl+v) | recording: [%s:-:b]%v[-:-:-] (ctrl+r) | writing as: [orange:-:b]%s[-:-:-] (ctrl+q) | bot will write as [orange:-:b]%s[-:-:-] (ctrl+x) | role injection (alt+7) [%s:-:b]%v[-:-:-]"
|
||||
toolCollapsed = true
|
||||
statusLineTempl = "help (F12) | chat: [orange:-:b]%s[-:-:-] (F1) | [%s:-:b]tool use[-:-:-] (ctrl+k) | model: [%s:-:b]%s[-:-:-] (ctrl+l) | [%s:-:b]skip LLM resp[-:-:-] (F10) | API: [orange:-:b]%s[-:-:-] (ctrl+v)\nwriting as: [orange:-:b]%s[-:-:-] (ctrl+q) | bot will write as [orange:-:b]%s[-:-:-] (ctrl+x)"
|
||||
focusSwitcher = map[tview.Primitive]tview.Primitive{}
|
||||
app *tview.Application
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
42
main_test.go
42
main_test.go
@@ -1,42 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gf-lt/config"
|
||||
"gf-lt/models"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRemoveThinking(t *testing.T) {
|
||||
cases := []struct {
|
||||
cb *models.ChatBody
|
||||
toolMsgs uint8
|
||||
}{
|
||||
{cb: &models.ChatBody{
|
||||
Stream: true,
|
||||
Messages: []models.RoleMsg{
|
||||
{Role: "tool", Content: "should be ommited"},
|
||||
{Role: "system", Content: "should stay"},
|
||||
{Role: "user", Content: "hello, how are you?"},
|
||||
{Role: "assistant", Content: "Oh, hi. <think>I should thank user and continue the conversation</think> I am geat, thank you! How are you?"},
|
||||
},
|
||||
},
|
||||
toolMsgs: uint8(1),
|
||||
},
|
||||
}
|
||||
for i, tc := range cases {
|
||||
t.Run(fmt.Sprintf("run_%d", i), func(t *testing.T) {
|
||||
cfg = &config.Config{ToolRole: "tool"} // Initialize cfg.ToolRole for test
|
||||
mNum := len(tc.cb.Messages)
|
||||
removeThinking(tc.cb)
|
||||
if len(tc.cb.Messages) != mNum-int(tc.toolMsgs) {
|
||||
t.Errorf("failed to delete tools msg %v; expected %d, got %d", tc.cb.Messages, mNum-int(tc.toolMsgs), len(tc.cb.Messages))
|
||||
}
|
||||
for _, msg := range tc.cb.Messages {
|
||||
if strings.Contains(msg.Content, "<think>") {
|
||||
t.Errorf("msg contains think tag; msg: %s\n", msg.Content)
|
||||
}
|
||||
}
|
||||
}) }
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
package models
|
||||
|
||||
import "strings"
|
||||
import (
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// https://github.com/malfoyslastname/character-card-spec-v2/blob/main/spec_v2.md
|
||||
// what a bloat; trim to Role->Msg pair and first msg
|
||||
@@ -31,6 +35,7 @@ func (c *CharCardSpec) Simplify(userName, fpath string) *CharCard {
|
||||
fm := strings.ReplaceAll(strings.ReplaceAll(c.FirstMes, "{{char}}", c.Name), "{{user}}", userName)
|
||||
sysPr := strings.ReplaceAll(strings.ReplaceAll(c.Description, "{{char}}", c.Name), "{{user}}", userName)
|
||||
return &CharCard{
|
||||
ID: ComputeCardID(c.Name, fpath),
|
||||
SysPrompt: sysPr,
|
||||
FirstMsg: fm,
|
||||
Role: c.Name,
|
||||
@@ -39,7 +44,12 @@ func (c *CharCardSpec) Simplify(userName, fpath string) *CharCard {
|
||||
}
|
||||
}
|
||||
|
||||
func ComputeCardID(role, filePath string) string {
|
||||
return fmt.Sprintf("%x", md5.Sum([]byte(role+filePath)))
|
||||
}
|
||||
|
||||
type CharCard struct {
|
||||
ID string `json:"id"`
|
||||
SysPrompt string `json:"sys_prompt"`
|
||||
FirstMsg string `json:"first_msg"`
|
||||
Role string `json:"role"`
|
||||
|
||||
13
models/consts.go
Normal file
13
models/consts.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package models
|
||||
|
||||
const (
|
||||
LoadedMark = "(loaded) "
|
||||
ToolRespMultyType = "multimodel_content"
|
||||
)
|
||||
|
||||
type APIType int
|
||||
|
||||
const (
|
||||
APITypeChat APIType = iota
|
||||
APITypeCompletion
|
||||
)
|
||||
303
models/models.go
303
models/models.go
@@ -5,28 +5,21 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
// imageBaseDir is the base directory for displaying image paths.
|
||||
// If set, image paths will be shown relative to this directory.
|
||||
imageBaseDir = ""
|
||||
)
|
||||
|
||||
// SetImageBaseDir sets the base directory for displaying image paths.
|
||||
// If dir is empty, full paths will be shown.
|
||||
func SetImageBaseDir(dir string) {
|
||||
imageBaseDir = dir
|
||||
}
|
||||
|
||||
type FuncCall struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Args map[string]string `json:"args"`
|
||||
}
|
||||
|
||||
type ToolCall struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Args string `json:"arguments"`
|
||||
}
|
||||
|
||||
type LLMResp struct {
|
||||
Choices []struct {
|
||||
FinishReason string `json:"finish_reason"`
|
||||
@@ -65,6 +58,7 @@ type LLMRespChunk struct {
|
||||
Index int `json:"index"`
|
||||
Delta struct {
|
||||
Content string `json:"content"`
|
||||
ReasoningContent string `json:"reasoning_content"`
|
||||
ToolCalls []ToolDeltaResp `json:"tool_calls"`
|
||||
} `json:"delta"`
|
||||
} `json:"choices"`
|
||||
@@ -86,6 +80,7 @@ type TextChunk struct {
|
||||
ToolResp bool
|
||||
FuncName string
|
||||
ToolID string
|
||||
Reasoning string // For models that send reasoning separately (OpenRouter, etc.)
|
||||
}
|
||||
|
||||
type TextContentPart struct {
|
||||
@@ -107,24 +102,35 @@ type RoleMsg struct {
|
||||
Content string `json:"-"`
|
||||
ContentParts []any `json:"-"`
|
||||
ToolCallID string `json:"tool_call_id,omitempty"` // For tool response messages
|
||||
ToolCall *ToolCall `json:"tool_call,omitempty"` // For assistant messages with tool calls
|
||||
IsShellCommand bool `json:"is_shell_command,omitempty"` // True for shell command outputs (always shown)
|
||||
KnownTo []string `json:"known_to,omitempty"`
|
||||
hasContentParts bool // Flag to indicate which content type to marshal
|
||||
Stats *ResponseStats `json:"stats"`
|
||||
HasContentParts bool // Flag to indicate which content type to marshal
|
||||
}
|
||||
|
||||
// MarshalJSON implements custom JSON marshaling for RoleMsg
|
||||
func (m *RoleMsg) MarshalJSON() ([]byte, error) {
|
||||
if m.hasContentParts {
|
||||
//
|
||||
//nolint:gocritic
|
||||
func (m RoleMsg) MarshalJSON() ([]byte, error) {
|
||||
if m.HasContentParts {
|
||||
// Use structured content format
|
||||
aux := struct {
|
||||
Role string `json:"role"`
|
||||
Content []any `json:"content"`
|
||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||
ToolCall *ToolCall `json:"tool_call,omitempty"`
|
||||
IsShellCommand bool `json:"is_shell_command,omitempty"`
|
||||
KnownTo []string `json:"known_to,omitempty"`
|
||||
Stats *ResponseStats `json:"stats,omitempty"`
|
||||
}{
|
||||
Role: m.Role,
|
||||
Content: m.ContentParts,
|
||||
ToolCallID: m.ToolCallID,
|
||||
ToolCall: m.ToolCall,
|
||||
IsShellCommand: m.IsShellCommand,
|
||||
KnownTo: m.KnownTo,
|
||||
Stats: m.Stats,
|
||||
}
|
||||
return json.Marshal(aux)
|
||||
} else {
|
||||
@@ -133,12 +139,18 @@ func (m *RoleMsg) MarshalJSON() ([]byte, error) {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||
ToolCall *ToolCall `json:"tool_call,omitempty"`
|
||||
IsShellCommand bool `json:"is_shell_command,omitempty"`
|
||||
KnownTo []string `json:"known_to,omitempty"`
|
||||
Stats *ResponseStats `json:"stats,omitempty"`
|
||||
}{
|
||||
Role: m.Role,
|
||||
Content: m.Content,
|
||||
ToolCallID: m.ToolCallID,
|
||||
ToolCall: m.ToolCall,
|
||||
IsShellCommand: m.IsShellCommand,
|
||||
KnownTo: m.KnownTo,
|
||||
Stats: m.Stats,
|
||||
}
|
||||
return json.Marshal(aux)
|
||||
}
|
||||
@@ -151,14 +163,20 @@ func (m *RoleMsg) UnmarshalJSON(data []byte) error {
|
||||
Role string `json:"role"`
|
||||
Content []any `json:"content"`
|
||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||
ToolCall *ToolCall `json:"tool_call,omitempty"`
|
||||
IsShellCommand bool `json:"is_shell_command,omitempty"`
|
||||
KnownTo []string `json:"known_to,omitempty"`
|
||||
Stats *ResponseStats `json:"stats,omitempty"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &structured); err == nil && len(structured.Content) > 0 {
|
||||
m.Role = structured.Role
|
||||
m.ContentParts = structured.Content
|
||||
m.ToolCallID = structured.ToolCallID
|
||||
m.ToolCall = structured.ToolCall
|
||||
m.IsShellCommand = structured.IsShellCommand
|
||||
m.KnownTo = structured.KnownTo
|
||||
m.hasContentParts = true
|
||||
m.Stats = structured.Stats
|
||||
m.HasContentParts = true
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -167,7 +185,10 @@ func (m *RoleMsg) UnmarshalJSON(data []byte) error {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||
ToolCall *ToolCall `json:"tool_call,omitempty"`
|
||||
IsShellCommand bool `json:"is_shell_command,omitempty"`
|
||||
KnownTo []string `json:"known_to,omitempty"`
|
||||
Stats *ResponseStats `json:"stats,omitempty"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &simple); err != nil {
|
||||
return err
|
||||
@@ -175,86 +196,17 @@ func (m *RoleMsg) UnmarshalJSON(data []byte) error {
|
||||
m.Role = simple.Role
|
||||
m.Content = simple.Content
|
||||
m.ToolCallID = simple.ToolCallID
|
||||
m.ToolCall = simple.ToolCall
|
||||
m.IsShellCommand = simple.IsShellCommand
|
||||
m.KnownTo = simple.KnownTo
|
||||
m.hasContentParts = false
|
||||
m.Stats = simple.Stats
|
||||
m.HasContentParts = false
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *RoleMsg) ToText(i int) string {
|
||||
// Convert content to string representation
|
||||
var contentStr string
|
||||
var imageIndicators []string
|
||||
if !m.hasContentParts {
|
||||
contentStr = m.Content
|
||||
} else {
|
||||
// For structured content, collect text parts and image indicators
|
||||
var textParts []string
|
||||
for _, part := range m.ContentParts {
|
||||
switch p := part.(type) {
|
||||
case TextContentPart:
|
||||
if p.Type == "text" {
|
||||
textParts = append(textParts, p.Text)
|
||||
}
|
||||
case ImageContentPart:
|
||||
// Collect image indicator
|
||||
displayPath := p.Path
|
||||
if displayPath == "" {
|
||||
displayPath = "image"
|
||||
} else {
|
||||
displayPath = extractDisplayPath(displayPath)
|
||||
}
|
||||
imageIndicators = append(imageIndicators, fmt.Sprintf("[orange::i][image: %s][-:-:-]", displayPath))
|
||||
case map[string]any:
|
||||
if partType, exists := p["type"]; exists {
|
||||
switch partType {
|
||||
case "text":
|
||||
if textVal, textExists := p["text"]; textExists {
|
||||
if textStr, isStr := textVal.(string); isStr {
|
||||
textParts = append(textParts, textStr)
|
||||
}
|
||||
}
|
||||
case "image_url":
|
||||
// Handle unmarshaled image content
|
||||
var displayPath string
|
||||
if pathVal, pathExists := p["path"]; pathExists {
|
||||
if pathStr, isStr := pathVal.(string); isStr && pathStr != "" {
|
||||
displayPath = extractDisplayPath(pathStr)
|
||||
}
|
||||
}
|
||||
if displayPath == "" {
|
||||
displayPath = "image"
|
||||
}
|
||||
imageIndicators = append(imageIndicators, fmt.Sprintf("[orange::i][image: %s][-:-:-]", displayPath))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
contentStr = strings.Join(textParts, " ") + " "
|
||||
}
|
||||
// check if already has role annotation (/completion makes them)
|
||||
// in that case remove it, and then add to icon
|
||||
// since icon and content are separated by \n
|
||||
contentStr, _ = strings.CutPrefix(contentStr, m.Role+":")
|
||||
// if !strings.HasPrefix(contentStr, m.Role+":") {
|
||||
icon := fmt.Sprintf("(%d) <%s>: ", i, m.Role)
|
||||
// }
|
||||
// Build final message with image indicators before text
|
||||
var finalContent strings.Builder
|
||||
if len(imageIndicators) > 0 {
|
||||
// Add each image indicator on its own line
|
||||
for _, indicator := range imageIndicators {
|
||||
finalContent.WriteString(indicator)
|
||||
finalContent.WriteString("\n")
|
||||
}
|
||||
}
|
||||
finalContent.WriteString(contentStr)
|
||||
textMsg := fmt.Sprintf("[-:-:b]%s[-:-:-]\n%s\n", icon, finalContent.String())
|
||||
return strings.ReplaceAll(textMsg, "\n\n", "\n")
|
||||
}
|
||||
|
||||
func (m *RoleMsg) ToPrompt() string {
|
||||
var contentStr string
|
||||
if !m.hasContentParts {
|
||||
if !m.HasContentParts {
|
||||
contentStr = m.Content
|
||||
} else {
|
||||
// For structured content, just take the text parts
|
||||
@@ -287,7 +239,7 @@ func NewRoleMsg(role, content string) RoleMsg {
|
||||
return RoleMsg{
|
||||
Role: role,
|
||||
Content: content,
|
||||
hasContentParts: false,
|
||||
HasContentParts: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,7 +248,7 @@ func NewMultimodalMsg(role string, contentParts []any) RoleMsg {
|
||||
return RoleMsg{
|
||||
Role: role,
|
||||
ContentParts: contentParts,
|
||||
hasContentParts: true,
|
||||
HasContentParts: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,7 +257,7 @@ func (m *RoleMsg) HasContent() bool {
|
||||
if m.Content != "" {
|
||||
return true
|
||||
}
|
||||
if m.hasContentParts && len(m.ContentParts) > 0 {
|
||||
if m.HasContentParts && len(m.ContentParts) > 0 {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -313,7 +265,7 @@ func (m *RoleMsg) HasContent() bool {
|
||||
|
||||
// IsContentParts returns true if the message uses structured content parts
|
||||
func (m *RoleMsg) IsContentParts() bool {
|
||||
return m.hasContentParts
|
||||
return m.HasContentParts
|
||||
}
|
||||
|
||||
// GetContentParts returns the content parts of the message
|
||||
@@ -329,38 +281,99 @@ func (m *RoleMsg) Copy() RoleMsg {
|
||||
ContentParts: m.ContentParts,
|
||||
ToolCallID: m.ToolCallID,
|
||||
KnownTo: m.KnownTo,
|
||||
hasContentParts: m.hasContentParts,
|
||||
Stats: m.Stats,
|
||||
HasContentParts: m.HasContentParts,
|
||||
ToolCall: m.ToolCall,
|
||||
IsShellCommand: m.IsShellCommand,
|
||||
}
|
||||
}
|
||||
|
||||
// GetText returns the text content of the message, handling both
|
||||
// simple Content and multimodal ContentParts formats.
|
||||
func (m *RoleMsg) GetText() string {
|
||||
if !m.HasContentParts {
|
||||
return m.Content
|
||||
}
|
||||
var textParts []string
|
||||
for _, part := range m.ContentParts {
|
||||
switch p := part.(type) {
|
||||
case TextContentPart:
|
||||
if p.Type == "text" {
|
||||
textParts = append(textParts, p.Text)
|
||||
}
|
||||
case map[string]any:
|
||||
if partType, exists := p["type"]; exists {
|
||||
if partType == "text" {
|
||||
if textVal, textExists := p["text"]; textExists {
|
||||
if textStr, isStr := textVal.(string); isStr {
|
||||
textParts = append(textParts, textStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.Join(textParts, " ")
|
||||
}
|
||||
|
||||
// SetText updates the text content of the message. If the message has
|
||||
// ContentParts (multimodal), it updates the text parts while preserving
|
||||
// images. If not, it sets the simple Content field.
|
||||
func (m *RoleMsg) SetText(text string) {
|
||||
if !m.HasContentParts {
|
||||
m.Content = text
|
||||
return
|
||||
}
|
||||
var newParts []any
|
||||
for _, part := range m.ContentParts {
|
||||
switch p := part.(type) {
|
||||
case TextContentPart:
|
||||
if p.Type == "text" {
|
||||
p.Text = text
|
||||
newParts = append(newParts, p)
|
||||
} else {
|
||||
newParts = append(newParts, p)
|
||||
}
|
||||
case map[string]any:
|
||||
if partType, exists := p["type"]; exists && partType == "text" {
|
||||
p["text"] = text
|
||||
newParts = append(newParts, p)
|
||||
} else {
|
||||
newParts = append(newParts, p)
|
||||
}
|
||||
default:
|
||||
newParts = append(newParts, part)
|
||||
}
|
||||
}
|
||||
m.ContentParts = newParts
|
||||
}
|
||||
|
||||
// AddTextPart adds a text content part to the message
|
||||
func (m *RoleMsg) AddTextPart(text string) {
|
||||
if !m.hasContentParts {
|
||||
if !m.HasContentParts {
|
||||
// Convert to content parts format
|
||||
if m.Content != "" {
|
||||
m.ContentParts = []any{TextContentPart{Type: "text", Text: m.Content}}
|
||||
} else {
|
||||
m.ContentParts = []any{}
|
||||
}
|
||||
m.hasContentParts = true
|
||||
m.HasContentParts = true
|
||||
}
|
||||
|
||||
textPart := TextContentPart{Type: "text", Text: text}
|
||||
m.ContentParts = append(m.ContentParts, textPart)
|
||||
}
|
||||
|
||||
// AddImagePart adds an image content part to the message
|
||||
func (m *RoleMsg) AddImagePart(imageURL, imagePath string) {
|
||||
if !m.hasContentParts {
|
||||
if !m.HasContentParts {
|
||||
// Convert to content parts format
|
||||
if m.Content != "" {
|
||||
m.ContentParts = []any{TextContentPart{Type: "text", Text: m.Content}}
|
||||
} else {
|
||||
m.ContentParts = []any{}
|
||||
}
|
||||
m.hasContentParts = true
|
||||
m.HasContentParts = true
|
||||
}
|
||||
|
||||
imagePart := ImageContentPart{
|
||||
Type: "image_url",
|
||||
Path: imagePath, // Store the original file path
|
||||
@@ -378,7 +391,6 @@ func CreateImageURLFromPath(imagePath string) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Determine the image format based on file extension
|
||||
var mimeType string
|
||||
switch {
|
||||
@@ -395,39 +407,12 @@ func CreateImageURLFromPath(imagePath string) (string, error) {
|
||||
default:
|
||||
mimeType = "image/jpeg" // default
|
||||
}
|
||||
|
||||
// Encode to base64
|
||||
encoded := base64.StdEncoding.EncodeToString(data)
|
||||
|
||||
// Create data URL
|
||||
return fmt.Sprintf("data:%s;base64,%s", mimeType, encoded), nil
|
||||
}
|
||||
|
||||
// extractDisplayPath returns a path suitable for display, potentially relative to imageBaseDir
|
||||
func extractDisplayPath(p string) string {
|
||||
if p == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// If base directory is set, try to make path relative to it
|
||||
if imageBaseDir != "" {
|
||||
if rel, err := filepath.Rel(imageBaseDir, p); err == nil {
|
||||
// Check if relative path doesn't start with ".." (meaning it's within base dir)
|
||||
// If it starts with "..", we might still want to show it as relative
|
||||
// but for now we show full path if it goes outside base dir
|
||||
if !strings.HasPrefix(rel, "..") {
|
||||
p = rel
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Truncate long paths to last 60 characters if needed
|
||||
if len(p) > 60 {
|
||||
return "..." + p[len(p)-60:]
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
type ChatBody struct {
|
||||
Model string `json:"model"`
|
||||
Stream bool `json:"stream"`
|
||||
@@ -435,16 +420,16 @@ type ChatBody struct {
|
||||
}
|
||||
|
||||
func (cb *ChatBody) Rename(oldname, newname string) {
|
||||
for i, m := range cb.Messages {
|
||||
cb.Messages[i].Content = strings.ReplaceAll(m.Content, oldname, newname)
|
||||
cb.Messages[i].Role = strings.ReplaceAll(m.Role, oldname, newname)
|
||||
for i := range cb.Messages {
|
||||
cb.Messages[i].Content = strings.ReplaceAll(cb.Messages[i].Content, oldname, newname)
|
||||
cb.Messages[i].Role = strings.ReplaceAll(cb.Messages[i].Role, oldname, newname)
|
||||
}
|
||||
}
|
||||
|
||||
func (cb *ChatBody) ListRoles() []string {
|
||||
namesMap := make(map[string]struct{})
|
||||
for _, m := range cb.Messages {
|
||||
namesMap[m.Role] = struct{}{}
|
||||
for i := range cb.Messages {
|
||||
namesMap[cb.Messages[i].Role] = struct{}{}
|
||||
}
|
||||
resp := make([]string, len(namesMap))
|
||||
i := 0
|
||||
@@ -531,24 +516,6 @@ type OpenAIReq struct {
|
||||
|
||||
// ===
|
||||
|
||||
// type LLMModels struct {
|
||||
// Object string `json:"object"`
|
||||
// Data []struct {
|
||||
// ID string `json:"id"`
|
||||
// Object string `json:"object"`
|
||||
// Created int `json:"created"`
|
||||
// OwnedBy string `json:"owned_by"`
|
||||
// Meta struct {
|
||||
// VocabType int `json:"vocab_type"`
|
||||
// NVocab int `json:"n_vocab"`
|
||||
// NCtxTrain int `json:"n_ctx_train"`
|
||||
// NEmbd int `json:"n_embd"`
|
||||
// NParams int64 `json:"n_params"`
|
||||
// Size int64 `json:"size"`
|
||||
// } `json:"meta"`
|
||||
// } `json:"data"`
|
||||
// }
|
||||
|
||||
type LlamaCPPReq struct {
|
||||
Model string `json:"model"`
|
||||
Stream bool `json:"stream"`
|
||||
@@ -641,6 +608,26 @@ func (lcp *LCPModels) ListModels() []string {
|
||||
return resp
|
||||
}
|
||||
|
||||
func (lcp *LCPModels) HasVision(modelID string) bool {
|
||||
for _, m := range lcp.Data {
|
||||
if m.ID == modelID {
|
||||
args := m.Status.Args
|
||||
for i := 0; i < len(args)-1; i++ {
|
||||
if args[i] == "--mmproj" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type ResponseStats struct {
|
||||
Tokens int
|
||||
Duration float64
|
||||
TokensPerSec float64
|
||||
}
|
||||
|
||||
type ChatRoundReq struct {
|
||||
UserMsg string
|
||||
Role string
|
||||
@@ -648,9 +635,7 @@ type ChatRoundReq struct {
|
||||
Resume bool
|
||||
}
|
||||
|
||||
type APIType int
|
||||
|
||||
const (
|
||||
APITypeChat APIType = iota
|
||||
APITypeCompletion
|
||||
)
|
||||
type MultimodalToolResp struct {
|
||||
Type string `json:"type"`
|
||||
Parts []map[string]string `json:"parts"`
|
||||
}
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRoleMsgToTextWithImages(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
msg RoleMsg
|
||||
index int
|
||||
expected string // substring to check
|
||||
}{
|
||||
{
|
||||
name: "text and image",
|
||||
index: 0,
|
||||
msg: func() RoleMsg {
|
||||
msg := NewMultimodalMsg("user", []interface{}{})
|
||||
msg.AddTextPart("Look at this picture")
|
||||
msg.AddImagePart("data:image/jpeg;base64,abc123", "/home/user/Pictures/cat.jpg")
|
||||
return msg
|
||||
}(),
|
||||
expected: "[orange::i][image: /home/user/Pictures/cat.jpg][-:-:-]",
|
||||
},
|
||||
{
|
||||
name: "image only",
|
||||
index: 1,
|
||||
msg: func() RoleMsg {
|
||||
msg := NewMultimodalMsg("user", []interface{}{})
|
||||
msg.AddImagePart("data:image/png;base64,xyz789", "/tmp/screenshot_20250217_123456.png")
|
||||
return msg
|
||||
}(),
|
||||
expected: "[orange::i][image: /tmp/screenshot_20250217_123456.png][-:-:-]",
|
||||
},
|
||||
{
|
||||
name: "long filename truncated",
|
||||
index: 2,
|
||||
msg: func() RoleMsg {
|
||||
msg := NewMultimodalMsg("user", []interface{}{})
|
||||
msg.AddTextPart("Check this")
|
||||
msg.AddImagePart("data:image/jpeg;base64,foo", "/very/long/path/to/a/really_long_filename_that_exceeds_forty_characters.jpg")
|
||||
return msg
|
||||
}(),
|
||||
expected: "[orange::i][image: .../to/a/really_long_filename_that_exceeds_forty_characters.jpg][-:-:-]",
|
||||
},
|
||||
{
|
||||
name: "multiple images",
|
||||
index: 3,
|
||||
msg: func() RoleMsg {
|
||||
msg := NewMultimodalMsg("user", []interface{}{})
|
||||
msg.AddTextPart("Multiple images")
|
||||
msg.AddImagePart("data:image/jpeg;base64,a", "/path/img1.jpg")
|
||||
msg.AddImagePart("data:image/png;base64,b", "/path/img2.png")
|
||||
return msg
|
||||
}(),
|
||||
expected: "[orange::i][image: /path/img1.jpg][-:-:-]\n[orange::i][image: /path/img2.png][-:-:-]",
|
||||
},
|
||||
{
|
||||
name: "old format without path",
|
||||
index: 4,
|
||||
msg: RoleMsg{
|
||||
Role: "user",
|
||||
hasContentParts: true,
|
||||
ContentParts: []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "image_url",
|
||||
"image_url": map[string]interface{}{
|
||||
"url": "data:image/jpeg;base64,old",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: "[orange::i][image: image][-:-:-]",
|
||||
},
|
||||
{
|
||||
name: "old format with path",
|
||||
index: 5,
|
||||
msg: RoleMsg{
|
||||
Role: "user",
|
||||
hasContentParts: true,
|
||||
ContentParts: []interface{}{
|
||||
map[string]interface{}{
|
||||
"type": "image_url",
|
||||
"path": "/old/path/photo.jpg",
|
||||
"image_url": map[string]interface{}{
|
||||
"url": "data:image/jpeg;base64,old",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: "[orange::i][image: /old/path/photo.jpg][-:-:-]",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := tt.msg.ToText(tt.index)
|
||||
if !strings.Contains(result, tt.expected) {
|
||||
t.Errorf("ToText() result does not contain expected indicator\ngot: %s\nwant substring: %s", result, tt.expected)
|
||||
}
|
||||
// Ensure the indicator appears before text content
|
||||
if strings.Contains(tt.expected, "cat.jpg") && strings.Contains(result, "Look at this picture") {
|
||||
indicatorPos := strings.Index(result, "[orange::i][image: /home/user/Pictures/cat.jpg][-:-:-]")
|
||||
textPos := strings.Index(result, "Look at this picture")
|
||||
if indicatorPos == -1 || textPos == -1 || indicatorPos >= textPos {
|
||||
t.Errorf("image indicator should appear before text")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractDisplayPath(t *testing.T) {
|
||||
// Save original base dir
|
||||
originalBaseDir := imageBaseDir
|
||||
defer func() { imageBaseDir = originalBaseDir }()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
baseDir string
|
||||
path string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "no base dir shows full path",
|
||||
baseDir: "",
|
||||
path: "/home/user/images/cat.jpg",
|
||||
expected: "/home/user/images/cat.jpg",
|
||||
},
|
||||
{
|
||||
name: "relative path within base dir",
|
||||
baseDir: "/home/user",
|
||||
path: "/home/user/images/cat.jpg",
|
||||
expected: "images/cat.jpg",
|
||||
},
|
||||
{
|
||||
name: "path outside base dir shows full path",
|
||||
baseDir: "/home/user",
|
||||
path: "/tmp/test.jpg",
|
||||
expected: "/tmp/test.jpg",
|
||||
},
|
||||
{
|
||||
name: "same directory",
|
||||
baseDir: "/home/user/images",
|
||||
path: "/home/user/images/cat.jpg",
|
||||
expected: "cat.jpg",
|
||||
},
|
||||
{
|
||||
name: "long path truncated",
|
||||
baseDir: "",
|
||||
path: "/very/long/path/to/a/really_long_filename_that_exceeds_sixty_characters_limit_yes_it_is_very_long.jpg",
|
||||
expected: "..._that_exceeds_sixty_characters_limit_yes_it_is_very_long.jpg",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
imageBaseDir = tt.baseDir
|
||||
result := extractDisplayPath(tt.path)
|
||||
if result != tt.expected {
|
||||
t.Errorf("extractDisplayPath(%q) with baseDir=%q = %q, want %q",
|
||||
tt.path, tt.baseDir, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -32,10 +32,16 @@ type OpenRouterChatReq struct {
|
||||
MinP float32 `json:"min_p"`
|
||||
NPredict int32 `json:"max_tokens"`
|
||||
Tools []Tool `json:"tools"`
|
||||
Reasoning *ReasoningConfig `json:"reasoning,omitempty"`
|
||||
}
|
||||
|
||||
func NewOpenRouterChatReq(cb ChatBody, props map[string]float32) OpenRouterChatReq {
|
||||
return OpenRouterChatReq{
|
||||
type ReasoningConfig struct {
|
||||
Effort string `json:"effort,omitempty"` // xhigh, high, medium, low, minimal, none
|
||||
Summary string `json:"summary,omitempty"` // auto, concise, detailed
|
||||
}
|
||||
|
||||
func NewOpenRouterChatReq(cb ChatBody, props map[string]float32, reasoningEffort string) OpenRouterChatReq {
|
||||
req := OpenRouterChatReq{
|
||||
Messages: cb.Messages,
|
||||
Model: cb.Model,
|
||||
Stream: cb.Stream,
|
||||
@@ -43,6 +49,13 @@ func NewOpenRouterChatReq(cb ChatBody, props map[string]float32) OpenRouterChatR
|
||||
MinP: props["min_p"],
|
||||
NPredict: int32(props["n_predict"]),
|
||||
}
|
||||
// Only include reasoning config if effort is specified and not "none"
|
||||
if reasoningEffort != "" && reasoningEffort != "none" {
|
||||
req.Reasoning = &ReasoningConfig{
|
||||
Effort: reasoningEffort,
|
||||
}
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
type OpenRouterChatRespNonStream struct {
|
||||
@@ -82,6 +95,7 @@ type OpenRouterChatResp struct {
|
||||
Delta struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Reasoning string `json:"reasoning"`
|
||||
ToolCalls []ToolDeltaResp `json:"tool_calls"`
|
||||
} `json:"delta"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
@@ -158,3 +172,16 @@ func (orm *ORModels) ListModels(free bool) []string {
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func (orm *ORModels) HasVision(modelID string) bool {
|
||||
for i := range orm.Data {
|
||||
if orm.Data[i].ID == modelID {
|
||||
for _, mod := range orm.Data[i].Architecture.InputModalities {
|
||||
if mod == "image" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -62,7 +62,6 @@ func TestORModelsListModels(t *testing.T) {
|
||||
t.Errorf("expected 4 total models, got %d", len(allModels))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("integration with or_models.json", func(t *testing.T) {
|
||||
// Attempt to load the real data file from the project root
|
||||
path := filepath.Join("..", "or_models.json")
|
||||
|
||||
@@ -109,6 +109,12 @@ func ReadCardJson(fname string) (*models.CharCard, error) {
|
||||
if err := json.Unmarshal(data, &card); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if card.FilePath == "" {
|
||||
card.FilePath = fname
|
||||
}
|
||||
if card.ID == "" {
|
||||
card.ID = models.ComputeCardID(card.Role, card.FilePath)
|
||||
}
|
||||
return &card, nil
|
||||
}
|
||||
|
||||
|
||||
301
popups.go
301
popups.go
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"gf-lt/models"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
@@ -17,10 +18,14 @@ func showModelSelectionPopup() {
|
||||
} else if strings.Contains(api, "openrouter.ai") {
|
||||
return ORFreeModels
|
||||
}
|
||||
// Assume local llama.cpp
|
||||
updateModelLists()
|
||||
// Assume local llama.cpp - fetch with load status
|
||||
models, err := fetchLCPModelsWithLoadStatus()
|
||||
if err != nil {
|
||||
logger.Error("failed to fetch models with load status", "error", err)
|
||||
return LocalModels
|
||||
}
|
||||
return models
|
||||
}
|
||||
// Get the current model list based on the API
|
||||
modelList := getModelListForAPI(cfg.CurrentAPI)
|
||||
// Check for empty options list
|
||||
@@ -35,9 +40,7 @@ func showModelSelectionPopup() {
|
||||
default:
|
||||
message = "No llama.cpp models loaded. Ensure llama.cpp server is running with models."
|
||||
}
|
||||
if err := notifyUser("Empty list", message); err != nil {
|
||||
logger.Error("failed to send notification", "error", err)
|
||||
}
|
||||
showToast("Empty list", message)
|
||||
return
|
||||
}
|
||||
// Create a list primitive
|
||||
@@ -47,7 +50,7 @@ func showModelSelectionPopup() {
|
||||
// Find the current model index to set as selected
|
||||
currentModelIndex := -1
|
||||
for i, model := range modelList {
|
||||
if model == chatBody.Model {
|
||||
if strings.TrimPrefix(model, models.LoadedMark) == chatBody.Model {
|
||||
currentModelIndex = i
|
||||
}
|
||||
modelListWidget.AddItem(model, "", 0, nil)
|
||||
@@ -57,17 +60,23 @@ func showModelSelectionPopup() {
|
||||
modelListWidget.SetCurrentItem(currentModelIndex)
|
||||
}
|
||||
modelListWidget.SetSelectedFunc(func(index int, mainText string, secondaryText string, shortcut rune) {
|
||||
// Update the model in both chatBody and config
|
||||
chatBody.Model = mainText
|
||||
modelName := strings.TrimPrefix(mainText, models.LoadedMark)
|
||||
chatBody.Model = modelName
|
||||
cfg.CurrentModel = chatBody.Model
|
||||
// Remove the popup page
|
||||
pages.RemovePage("modelSelectionPopup")
|
||||
// Update the status line to reflect the change
|
||||
app.SetFocus(textArea)
|
||||
updateCachedModelColor()
|
||||
updateStatusLine()
|
||||
})
|
||||
modelListWidget.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
pages.RemovePage("modelSelectionPopup")
|
||||
app.SetFocus(textArea)
|
||||
return nil
|
||||
}
|
||||
if event.Key() == tcell.KeyRune && event.Rune() == 'x' {
|
||||
pages.RemovePage("modelSelectionPopup")
|
||||
app.SetFocus(textArea)
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
@@ -108,9 +117,7 @@ func showAPILinkSelectionPopup() {
|
||||
if len(apiLinks) == 0 {
|
||||
logger.Warn("no API links available for selection")
|
||||
message := "No API links available. Please configure API links in your config file."
|
||||
if err := notifyUser("Empty list", message); err != nil {
|
||||
logger.Error("failed to send notification", "error", err)
|
||||
}
|
||||
showToast("Empty list", message)
|
||||
return
|
||||
}
|
||||
// Create a list primitive
|
||||
@@ -132,6 +139,7 @@ func showAPILinkSelectionPopup() {
|
||||
apiListWidget.SetSelectedFunc(func(index int, mainText string, secondaryText string, shortcut rune) {
|
||||
// Update the API in config
|
||||
cfg.CurrentAPI = mainText
|
||||
// updateToolCapabilities()
|
||||
// Update model list based on new API
|
||||
// Helper function to get model list for a given API (same as in props_table.go)
|
||||
getModelListForAPI := func(api string) []string {
|
||||
@@ -149,18 +157,25 @@ func showAPILinkSelectionPopup() {
|
||||
newModelList := getModelListForAPI(cfg.CurrentAPI)
|
||||
// Ensure chatBody.Model is in the new list; if not, set to first available model
|
||||
if len(newModelList) > 0 && !slices.Contains(newModelList, chatBody.Model) {
|
||||
chatBody.Model = newModelList[0]
|
||||
chatBody.Model = strings.TrimPrefix(newModelList[0], models.LoadedMark)
|
||||
cfg.CurrentModel = chatBody.Model
|
||||
updateToolCapabilities()
|
||||
}
|
||||
// Remove the popup page
|
||||
pages.RemovePage("apiLinkSelectionPopup")
|
||||
// Update the parser and status line to reflect the change
|
||||
app.SetFocus(textArea)
|
||||
choseChunkParser()
|
||||
updateCachedModelColor()
|
||||
updateStatusLine()
|
||||
})
|
||||
apiListWidget.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
pages.RemovePage("apiLinkSelectionPopup")
|
||||
app.SetFocus(textArea)
|
||||
return nil
|
||||
}
|
||||
if event.Key() == tcell.KeyRune && event.Rune() == 'x' {
|
||||
pages.RemovePage("apiLinkSelectionPopup")
|
||||
app.SetFocus(textArea)
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
@@ -187,9 +202,7 @@ func showUserRoleSelectionPopup() {
|
||||
if len(roles) == 0 {
|
||||
logger.Warn("no roles available for selection")
|
||||
message := "No roles available for selection."
|
||||
if err := notifyUser("Empty list", message); err != nil {
|
||||
logger.Error("failed to send notification", "error", err)
|
||||
}
|
||||
showToast("Empty list", message)
|
||||
return
|
||||
}
|
||||
// Create a list primitive
|
||||
@@ -220,6 +233,7 @@ func showUserRoleSelectionPopup() {
|
||||
textView.SetText(chatToText(filtered, cfg.ShowSys))
|
||||
// Remove the popup page
|
||||
pages.RemovePage("userRoleSelectionPopup")
|
||||
app.SetFocus(textArea)
|
||||
// Update the status line to reflect the change
|
||||
updateStatusLine()
|
||||
colorText()
|
||||
@@ -227,6 +241,12 @@ func showUserRoleSelectionPopup() {
|
||||
roleListWidget.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
pages.RemovePage("userRoleSelectionPopup")
|
||||
app.SetFocus(textArea)
|
||||
return nil
|
||||
}
|
||||
if event.Key() == tcell.KeyRune && event.Rune() == 'x' {
|
||||
pages.RemovePage("userRoleSelectionPopup")
|
||||
app.SetFocus(textArea)
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
@@ -259,9 +279,7 @@ func showBotRoleSelectionPopup() {
|
||||
if len(roles) == 0 {
|
||||
logger.Warn("no roles available for selection")
|
||||
message := "No roles available for selection."
|
||||
if err := notifyUser("Empty list", message); err != nil {
|
||||
logger.Error("failed to send notification", "error", err)
|
||||
}
|
||||
showToast("Empty list", message)
|
||||
return
|
||||
}
|
||||
// Create a list primitive
|
||||
@@ -289,12 +307,19 @@ func showBotRoleSelectionPopup() {
|
||||
cfg.WriteNextMsgAsCompletionAgent = mainText
|
||||
// Remove the popup page
|
||||
pages.RemovePage("botRoleSelectionPopup")
|
||||
app.SetFocus(textArea)
|
||||
// Update the status line to reflect the change
|
||||
updateStatusLine()
|
||||
})
|
||||
roleListWidget.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
pages.RemovePage("botRoleSelectionPopup")
|
||||
app.SetFocus(textArea)
|
||||
return nil
|
||||
}
|
||||
if event.Key() == tcell.KeyRune && event.Rune() == 'x' {
|
||||
pages.RemovePage("botRoleSelectionPopup")
|
||||
app.SetFocus(textArea)
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
@@ -312,3 +337,235 @@ func showBotRoleSelectionPopup() {
|
||||
pages.AddPage("botRoleSelectionPopup", modal(roleListWidget, 80, 20), true, true)
|
||||
app.SetFocus(roleListWidget)
|
||||
}
|
||||
|
||||
func showShellFileCompletionPopup(filter string) {
|
||||
baseDir := cfg.FilePickerDir
|
||||
if baseDir == "" {
|
||||
baseDir = "."
|
||||
}
|
||||
complMatches := scanFiles(baseDir, filter)
|
||||
if len(complMatches) == 0 {
|
||||
return
|
||||
}
|
||||
if len(complMatches) == 1 {
|
||||
currentText := shellInput.GetText()
|
||||
atIdx := strings.LastIndex(currentText, "@")
|
||||
if atIdx >= 0 {
|
||||
before := currentText[:atIdx]
|
||||
shellInput.SetText(before + complMatches[0])
|
||||
}
|
||||
return
|
||||
}
|
||||
widget := tview.NewList().ShowSecondaryText(false).
|
||||
SetSelectedBackgroundColor(tcell.ColorGray)
|
||||
widget.SetTitle("file completion").SetBorder(true)
|
||||
for _, m := range complMatches {
|
||||
widget.AddItem(m, "", 0, nil)
|
||||
}
|
||||
widget.SetSelectedFunc(func(index int, mainText string, secondaryText string, shortcut rune) {
|
||||
currentText := shellInput.GetText()
|
||||
atIdx := strings.LastIndex(currentText, "@")
|
||||
if atIdx >= 0 {
|
||||
before := currentText[:atIdx]
|
||||
shellInput.SetText(before + mainText)
|
||||
}
|
||||
pages.RemovePage("shellFileCompletionPopup")
|
||||
app.SetFocus(shellInput)
|
||||
})
|
||||
widget.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
pages.RemovePage("shellFileCompletionPopup")
|
||||
app.SetFocus(shellInput)
|
||||
return nil
|
||||
}
|
||||
if event.Key() == tcell.KeyRune && event.Rune() == 'x' {
|
||||
pages.RemovePage("shellFileCompletionPopup")
|
||||
app.SetFocus(shellInput)
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
modal := func(p tview.Primitive, width, height int) tview.Primitive {
|
||||
return tview.NewFlex().
|
||||
AddItem(nil, 0, 1, false).
|
||||
AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
|
||||
AddItem(nil, 0, 1, false).
|
||||
AddItem(p, height, 1, true).
|
||||
AddItem(nil, 0, 1, false), width, 1, true).
|
||||
AddItem(nil, 0, 1, false)
|
||||
}
|
||||
pages.AddPage("shellFileCompletionPopup", modal(widget, 80, 20), true, true)
|
||||
app.SetFocus(widget)
|
||||
}
|
||||
|
||||
func showTextAreaFileCompletionPopup(filter string) {
|
||||
baseDir := cfg.FilePickerDir
|
||||
if baseDir == "" {
|
||||
baseDir = "."
|
||||
}
|
||||
complMatches := scanFiles(baseDir, filter)
|
||||
if len(complMatches) == 0 {
|
||||
return
|
||||
}
|
||||
if len(complMatches) == 1 {
|
||||
currentText := textArea.GetText()
|
||||
atIdx := strings.LastIndex(currentText, "@")
|
||||
if atIdx >= 0 {
|
||||
before := currentText[:atIdx]
|
||||
textArea.SetText(before+complMatches[0], true)
|
||||
}
|
||||
return
|
||||
}
|
||||
widget := tview.NewList().ShowSecondaryText(false).
|
||||
SetSelectedBackgroundColor(tcell.ColorGray)
|
||||
widget.SetTitle("file completion").SetBorder(true)
|
||||
for _, m := range complMatches {
|
||||
widget.AddItem(m, "", 0, nil)
|
||||
}
|
||||
widget.SetSelectedFunc(func(index int, mainText string, secondaryText string, shortcut rune) {
|
||||
currentText := textArea.GetText()
|
||||
atIdx := strings.LastIndex(currentText, "@")
|
||||
if atIdx >= 0 {
|
||||
before := currentText[:atIdx]
|
||||
textArea.SetText(before+mainText, true)
|
||||
}
|
||||
pages.RemovePage("textAreaFileCompletionPopup")
|
||||
app.SetFocus(textArea)
|
||||
})
|
||||
widget.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
pages.RemovePage("textAreaFileCompletionPopup")
|
||||
app.SetFocus(textArea)
|
||||
return nil
|
||||
}
|
||||
if event.Key() == tcell.KeyRune && event.Rune() == 'x' {
|
||||
pages.RemovePage("textAreaFileCompletionPopup")
|
||||
app.SetFocus(textArea)
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
modal := func(p tview.Primitive, width, height int) tview.Primitive {
|
||||
return tview.NewFlex().
|
||||
AddItem(nil, 0, 1, false).
|
||||
AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
|
||||
AddItem(nil, 0, 1, false).
|
||||
AddItem(p, height, 1, true).
|
||||
AddItem(nil, 0, 1, false), width, 1, true).
|
||||
AddItem(nil, 0, 1, false)
|
||||
}
|
||||
pages.AddPage("textAreaFileCompletionPopup", modal(widget, 80, 20), true, true)
|
||||
app.SetFocus(widget)
|
||||
}
|
||||
|
||||
func updateWidgetColors(theme *tview.Theme) {
|
||||
bgColor := theme.PrimitiveBackgroundColor
|
||||
fgColor := theme.PrimaryTextColor
|
||||
borderColor := theme.BorderColor
|
||||
titleColor := theme.TitleColor
|
||||
textView.SetBackgroundColor(bgColor)
|
||||
textView.SetTextColor(fgColor)
|
||||
textView.SetBorderColor(borderColor)
|
||||
textView.SetTitleColor(titleColor)
|
||||
textArea.SetBackgroundColor(bgColor)
|
||||
textArea.SetBorderColor(borderColor)
|
||||
textArea.SetTitleColor(titleColor)
|
||||
textArea.SetTextStyle(tcell.StyleDefault.Background(bgColor).Foreground(fgColor))
|
||||
textArea.SetPlaceholderStyle(tcell.StyleDefault.Background(bgColor).Foreground(fgColor))
|
||||
textArea.SetText(textArea.GetText(), true)
|
||||
editArea.SetBackgroundColor(bgColor)
|
||||
editArea.SetBorderColor(borderColor)
|
||||
editArea.SetTitleColor(titleColor)
|
||||
editArea.SetTextStyle(tcell.StyleDefault.Background(bgColor).Foreground(fgColor))
|
||||
editArea.SetPlaceholderStyle(tcell.StyleDefault.Background(bgColor).Foreground(fgColor))
|
||||
editArea.SetText(editArea.GetText(), true)
|
||||
statusLineWidget.SetBackgroundColor(bgColor)
|
||||
statusLineWidget.SetTextColor(fgColor)
|
||||
statusLineWidget.SetBorderColor(borderColor)
|
||||
statusLineWidget.SetTitleColor(titleColor)
|
||||
helpView.SetBackgroundColor(bgColor)
|
||||
helpView.SetTextColor(fgColor)
|
||||
helpView.SetBorderColor(borderColor)
|
||||
helpView.SetTitleColor(titleColor)
|
||||
searchField.SetBackgroundColor(bgColor)
|
||||
searchField.SetBorderColor(borderColor)
|
||||
searchField.SetTitleColor(titleColor)
|
||||
}
|
||||
|
||||
// showColorschemeSelectionPopup creates a modal popup to select a colorscheme
|
||||
func showColorschemeSelectionPopup() {
|
||||
// Get the list of available colorschemes
|
||||
schemeNames := make([]string, 0, len(colorschemes))
|
||||
for name := range colorschemes {
|
||||
schemeNames = append(schemeNames, name)
|
||||
}
|
||||
slices.Sort(schemeNames)
|
||||
// Check for empty options list
|
||||
if len(schemeNames) == 0 {
|
||||
logger.Warn("no colorschemes available for selection")
|
||||
message := "No colorschemes available."
|
||||
showToast("Empty list", message)
|
||||
return
|
||||
}
|
||||
// Create a list primitive
|
||||
schemeListWidget := tview.NewList().ShowSecondaryText(false).
|
||||
SetSelectedBackgroundColor(tcell.ColorGray)
|
||||
schemeListWidget.SetTitle("Select Colorscheme").SetBorder(true)
|
||||
currentScheme := "default"
|
||||
for name := range colorschemes {
|
||||
if tview.Styles == colorschemes[name] {
|
||||
currentScheme = name
|
||||
break
|
||||
}
|
||||
}
|
||||
currentSchemeIndex := -1
|
||||
for i, scheme := range schemeNames {
|
||||
if scheme == currentScheme {
|
||||
currentSchemeIndex = i
|
||||
}
|
||||
schemeListWidget.AddItem(scheme, "", 0, nil)
|
||||
}
|
||||
// Set the current selection if found
|
||||
if currentSchemeIndex != -1 {
|
||||
schemeListWidget.SetCurrentItem(currentSchemeIndex)
|
||||
}
|
||||
schemeListWidget.SetSelectedFunc(func(index int, mainText string, secondaryText string, shortcut rune) {
|
||||
// Update the colorscheme
|
||||
if theme, ok := colorschemes[mainText]; ok {
|
||||
tview.Styles = theme
|
||||
go func() {
|
||||
app.QueueUpdateDraw(func() {
|
||||
updateWidgetColors(&theme)
|
||||
})
|
||||
}()
|
||||
}
|
||||
// Remove the popup page
|
||||
pages.RemovePage("colorschemeSelectionPopup")
|
||||
app.SetFocus(textArea)
|
||||
})
|
||||
schemeListWidget.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
pages.RemovePage("colorschemeSelectionPopup")
|
||||
app.SetFocus(textArea)
|
||||
return nil
|
||||
}
|
||||
if event.Key() == tcell.KeyRune && event.Rune() == 'x' {
|
||||
pages.RemovePage("colorschemeSelectionPopup")
|
||||
app.SetFocus(textArea)
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
modal := func(p tview.Primitive, width, height int) tview.Primitive {
|
||||
return tview.NewFlex().
|
||||
AddItem(nil, 0, 1, false).
|
||||
AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
|
||||
AddItem(nil, 0, 1, false).
|
||||
AddItem(p, height, 1, true).
|
||||
AddItem(nil, 0, 1, false), width, 1, true).
|
||||
AddItem(nil, 0, 1, false)
|
||||
}
|
||||
// Add modal page and make it visible
|
||||
pages.AddPage("colorschemeSelectionPopup", modal(schemeListWidget, 40, len(schemeNames)+2), true, true)
|
||||
app.SetFocus(schemeListWidget)
|
||||
}
|
||||
|
||||
@@ -115,9 +115,6 @@ func makePropsTable(props map[string]float32) *tview.Table {
|
||||
row++
|
||||
}
|
||||
// Add checkboxes
|
||||
addCheckboxRow("Insert <think> tag (/completion only)", cfg.ThinkUse, func(checked bool) {
|
||||
cfg.ThinkUse = checked
|
||||
})
|
||||
addCheckboxRow("RAG use", cfg.RAGEnabled, func(checked bool) {
|
||||
cfg.RAGEnabled = checked
|
||||
})
|
||||
@@ -149,6 +146,11 @@ func makePropsTable(props map[string]float32) *tview.Table {
|
||||
addListPopupRow("Set log level", logLevels, GetLogLevel(), func(option string) {
|
||||
setLogLevel(option)
|
||||
})
|
||||
// Add reasoning effort dropdown (for OpenRouter and supported APIs)
|
||||
reasoningEfforts := []string{"", "none", "minimal", "low", "medium", "high", "xhigh"}
|
||||
addListPopupRow("Reasoning effort (OR)", reasoningEfforts, cfg.ReasoningEffort, func(option string) {
|
||||
cfg.ReasoningEffort = option
|
||||
})
|
||||
// Helper function to get model list for a given API
|
||||
getModelListForAPI := func(api string) []string {
|
||||
if strings.Contains(api, "api.deepseek.com/") {
|
||||
@@ -257,9 +259,7 @@ func makePropsTable(props map[string]float32) *tview.Table {
|
||||
// Handle nil options
|
||||
if data.Options == nil {
|
||||
logger.Error("options list is nil for", "label", label)
|
||||
if err := notifyUser("Configuration error", "Options list is nil for "+label); err != nil {
|
||||
logger.Error("failed to send notification", "error", err)
|
||||
}
|
||||
showToast("Configuration error", "Options list is nil for "+label)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -277,9 +277,7 @@ func makePropsTable(props map[string]float32) *tview.Table {
|
||||
message = "No llama.cpp models loaded. Ensure llama.cpp server is running with models."
|
||||
}
|
||||
}
|
||||
if err := notifyUser("Empty list", message); err != nil {
|
||||
logger.Error("failed to send notification", "error", err)
|
||||
}
|
||||
showToast("Empty list", message)
|
||||
return
|
||||
}
|
||||
// Create a list primitive
|
||||
|
||||
@@ -131,7 +131,6 @@ func (a *APIEmbedder) EmbedSlice(lines []string) ([][]float32, error) {
|
||||
}
|
||||
embeddings[data.Index] = data.Embedding
|
||||
}
|
||||
|
||||
return embeddings, nil
|
||||
}
|
||||
|
||||
|
||||
181
rag/extractors.go
Normal file
181
rag/extractors.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package rag
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"github.com/ledongthuc/pdf"
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
"github.com/yuin/goldmark/parser"
|
||||
"github.com/yuin/goldmark/renderer/html"
|
||||
)
|
||||
|
||||
func ExtractText(fpath string) (string, error) {
|
||||
ext := strings.ToLower(path.Ext(fpath))
|
||||
switch ext {
|
||||
case ".txt":
|
||||
return extractTextFromFile(fpath)
|
||||
case ".md", ".markdown":
|
||||
return extractTextFromMarkdown(fpath)
|
||||
case ".html", ".htm":
|
||||
return extractTextFromHtmlFile(fpath)
|
||||
case ".epub":
|
||||
return extractTextFromEpub(fpath)
|
||||
case ".pdf":
|
||||
return extractTextFromPdf(fpath)
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported file format: %s", ext)
|
||||
}
|
||||
}
|
||||
|
||||
func extractTextFromFile(fpath string) (string, error) {
|
||||
data, err := os.ReadFile(fpath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func extractTextFromHtmlFile(fpath string) (string, error) {
|
||||
data, err := os.ReadFile(fpath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return extractTextFromHtmlContent(data)
|
||||
}
|
||||
|
||||
// non utf-8 encoding?
|
||||
func extractTextFromHtmlContent(data []byte) (string, error) {
|
||||
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Remove script and style tags
|
||||
doc.Find("script, style, noscript").Each(func(i int, s *goquery.Selection) {
|
||||
s.Remove()
|
||||
})
|
||||
// Get text and clean it
|
||||
text := doc.Text()
|
||||
// Collapse all whitespace (newlines, tabs, multiple spaces) into single spaces
|
||||
cleaned := strings.Join(strings.Fields(text), " ")
|
||||
return cleaned, nil
|
||||
}
|
||||
|
||||
func extractTextFromMarkdown(fpath string) (string, error) {
|
||||
data, err := os.ReadFile(fpath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Convert markdown to HTML
|
||||
md := goldmark.New(
|
||||
goldmark.WithExtensions(extension.GFM),
|
||||
goldmark.WithParserOptions(parser.WithAutoHeadingID()),
|
||||
goldmark.WithRendererOptions(html.WithUnsafe()), // allow raw HTML if needed
|
||||
)
|
||||
var buf bytes.Buffer
|
||||
if err := md.Convert(data, &buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Now extract text from the resulting HTML (using goquery or similar)
|
||||
return extractTextFromHtmlContent(buf.Bytes())
|
||||
}
|
||||
|
||||
func extractTextFromEpub(fpath string) (string, error) {
|
||||
r, err := zip.OpenReader(fpath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to open epub: %w", err)
|
||||
}
|
||||
defer r.Close()
|
||||
var sb strings.Builder
|
||||
for _, f := range r.File {
|
||||
ext := strings.ToLower(path.Ext(f.Name))
|
||||
if ext != ".xhtml" && ext != ".html" && ext != ".htm" && ext != ".xml" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip manifest, toc, ncx files - they don't contain book content
|
||||
nameLower := strings.ToLower(f.Name)
|
||||
if strings.Contains(nameLower, "toc") || strings.Contains(nameLower, "nav") ||
|
||||
strings.Contains(nameLower, "manifest") || strings.Contains(nameLower, ".opf") ||
|
||||
strings.HasSuffix(nameLower, ".ncx") {
|
||||
continue
|
||||
}
|
||||
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if sb.Len() > 0 {
|
||||
sb.WriteString("\n\n")
|
||||
}
|
||||
sb.WriteString(f.Name)
|
||||
sb.WriteString("\n")
|
||||
|
||||
buf, readErr := io.ReadAll(rc)
|
||||
rc.Close()
|
||||
if readErr == nil {
|
||||
sb.WriteString(stripHTML(string(buf)))
|
||||
}
|
||||
}
|
||||
if sb.Len() == 0 {
|
||||
return "", errors.New("no content extracted from epub")
|
||||
}
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
func stripHTML(html string) string {
|
||||
var sb strings.Builder
|
||||
inTag := false
|
||||
for _, r := range html {
|
||||
switch r {
|
||||
case '<':
|
||||
inTag = true
|
||||
case '>':
|
||||
inTag = false
|
||||
default:
|
||||
if !inTag {
|
||||
sb.WriteRune(r)
|
||||
}
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func extractTextFromPdf(fpath string) (string, error) {
|
||||
_, err := exec.LookPath("pdftotext")
|
||||
if err == nil {
|
||||
out, err := exec.Command("pdftotext", "-layout", fpath, "-").Output()
|
||||
if err == nil && len(out) > 0 {
|
||||
return string(out), nil
|
||||
}
|
||||
}
|
||||
return extractTextFromPdfPureGo(fpath)
|
||||
}
|
||||
|
||||
func extractTextFromPdfPureGo(fpath string) (string, error) {
|
||||
df, r, err := pdf.Open(fpath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to open pdf: %w", err)
|
||||
}
|
||||
defer df.Close()
|
||||
textReader, err := r.GetPlainText()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to extract text from pdf: %w", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
_, err = io.Copy(&buf, textReader)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read pdf text: %w", err)
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
504
rag/rag.go
504
rag/rag.go
@@ -7,8 +7,9 @@ import (
|
||||
"gf-lt/models"
|
||||
"gf-lt/storage"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
@@ -23,19 +24,18 @@ var (
|
||||
ErrRAGStatus = "some error occurred; failed to transfer data to vector db"
|
||||
)
|
||||
|
||||
|
||||
type RAG struct {
|
||||
logger *slog.Logger
|
||||
store storage.FullRepo
|
||||
cfg *config.Config
|
||||
embedder Embedder
|
||||
storage *VectorStorage
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func New(l *slog.Logger, s storage.FullRepo, cfg *config.Config) *RAG {
|
||||
// Initialize with API embedder by default, could be configurable later
|
||||
embedder := NewAPIEmbedder(l, cfg)
|
||||
|
||||
rag := &RAG{
|
||||
logger: l,
|
||||
store: s,
|
||||
@@ -54,7 +54,9 @@ func wordCounter(sentence string) int {
|
||||
}
|
||||
|
||||
func (r *RAG) LoadRAG(fpath string) error {
|
||||
data, err := os.ReadFile(fpath)
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
fileText, err := ExtractText(fpath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -63,10 +65,7 @@ func (r *RAG) LoadRAG(fpath string) error {
|
||||
case LongJobStatusCh <- LoadedFileRAGStatus:
|
||||
default:
|
||||
r.logger.Warn("LongJobStatusCh channel is full or closed, dropping status message", "message", LoadedFileRAGStatus)
|
||||
// Channel is full or closed, ignore the message to prevent panic
|
||||
}
|
||||
|
||||
fileText := string(data)
|
||||
tokenizer, err := english.NewSentenceTokenizer(nil)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -76,19 +75,16 @@ func (r *RAG) LoadRAG(fpath string) error {
|
||||
for i, s := range sentences {
|
||||
sents[i] = s.Text
|
||||
}
|
||||
|
||||
// Group sentences into paragraphs based on word limit
|
||||
paragraphs := []string{}
|
||||
par := strings.Builder{}
|
||||
for i := 0; i < len(sents); i++ {
|
||||
// Only add sentences that aren't empty
|
||||
if strings.TrimSpace(sents[i]) != "" {
|
||||
if par.Len() > 0 {
|
||||
par.WriteString(" ") // Add space between sentences
|
||||
par.WriteString(" ")
|
||||
}
|
||||
par.WriteString(sents[i])
|
||||
}
|
||||
|
||||
if wordCounter(par.String()) > int(r.cfg.RAGWordLimit) {
|
||||
paragraph := strings.TrimSpace(par.String())
|
||||
if paragraph != "" {
|
||||
@@ -97,7 +93,6 @@ func (r *RAG) LoadRAG(fpath string) error {
|
||||
par.Reset()
|
||||
}
|
||||
}
|
||||
|
||||
// Handle any remaining content in the paragraph buffer
|
||||
if par.Len() > 0 {
|
||||
paragraph := strings.TrimSpace(par.String())
|
||||
@@ -105,217 +100,84 @@ func (r *RAG) LoadRAG(fpath string) error {
|
||||
paragraphs = append(paragraphs, paragraph)
|
||||
}
|
||||
}
|
||||
|
||||
// Adjust batch size if needed
|
||||
if len(paragraphs) < r.cfg.RAGBatchSize && len(paragraphs) > 0 {
|
||||
r.cfg.RAGBatchSize = len(paragraphs)
|
||||
}
|
||||
|
||||
if len(paragraphs) == 0 {
|
||||
return errors.New("no valid paragraphs found in file")
|
||||
}
|
||||
|
||||
var (
|
||||
maxChSize = 100
|
||||
left = 0
|
||||
right = r.cfg.RAGBatchSize
|
||||
batchCh = make(chan map[int][]string, maxChSize)
|
||||
vectorCh = make(chan []models.VectorRow, maxChSize)
|
||||
errCh = make(chan error, 1)
|
||||
wg = new(sync.WaitGroup)
|
||||
lock = new(sync.Mutex)
|
||||
)
|
||||
|
||||
defer close(errCh)
|
||||
defer close(batchCh)
|
||||
|
||||
// Fill input channel with batches
|
||||
ctn := 0
|
||||
totalParagraphs := len(paragraphs)
|
||||
for {
|
||||
if right > totalParagraphs {
|
||||
batchCh <- map[int][]string{left: paragraphs[left:]}
|
||||
break
|
||||
// Process paragraphs in batches synchronously
|
||||
batchCount := 0
|
||||
for i := 0; i < len(paragraphs); i += r.cfg.RAGBatchSize {
|
||||
end := i + r.cfg.RAGBatchSize
|
||||
if end > len(paragraphs) {
|
||||
end = len(paragraphs)
|
||||
}
|
||||
batchCh <- map[int][]string{left: paragraphs[left:right]}
|
||||
left, right = right, right+r.cfg.RAGBatchSize
|
||||
ctn++
|
||||
batch := paragraphs[i:end]
|
||||
batchCount++
|
||||
// Filter empty paragraphs
|
||||
nonEmptyBatch := make([]string, 0, len(batch))
|
||||
for _, p := range batch {
|
||||
if strings.TrimSpace(p) != "" {
|
||||
nonEmptyBatch = append(nonEmptyBatch, strings.TrimSpace(p))
|
||||
}
|
||||
|
||||
finishedBatchesMsg := fmt.Sprintf("finished batching batches#: %d; paragraphs: %d; sentences: %d\n", ctn+1, len(paragraphs), len(sents))
|
||||
r.logger.Debug(finishedBatchesMsg)
|
||||
select {
|
||||
case LongJobStatusCh <- finishedBatchesMsg:
|
||||
default:
|
||||
r.logger.Warn("LongJobStatusCh channel is full or closed, dropping status message", "message", finishedBatchesMsg)
|
||||
// Channel is full or closed, ignore the message to prevent panic
|
||||
}
|
||||
|
||||
// Start worker goroutines with WaitGroup
|
||||
wg.Add(int(r.cfg.RAGWorkers))
|
||||
for w := 0; w < int(r.cfg.RAGWorkers); w++ {
|
||||
go func(workerID int) {
|
||||
defer wg.Done()
|
||||
r.batchToVectorAsync(lock, workerID, batchCh, vectorCh, errCh, path.Base(fpath))
|
||||
}(w)
|
||||
if len(nonEmptyBatch) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Use a goroutine to close the batchCh when all batches are sent
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(vectorCh) // Close vectorCh when all workers are done
|
||||
}()
|
||||
|
||||
// Check for errors from workers
|
||||
// Use a non-blocking check for errors
|
||||
select {
|
||||
case err := <-errCh:
|
||||
// Embed the batch
|
||||
embeddings, err := r.embedder.EmbedSlice(nonEmptyBatch)
|
||||
if err != nil {
|
||||
r.logger.Error("error during RAG processing", "error", err)
|
||||
r.logger.Error("failed to embed batch", "error", err, "batch", batchCount)
|
||||
select {
|
||||
case LongJobStatusCh <- ErrRAGStatus:
|
||||
default:
|
||||
r.logger.Warn("LongJobStatusCh channel full, dropping message")
|
||||
}
|
||||
return fmt.Errorf("failed to embed batch %d: %w", batchCount, err)
|
||||
}
|
||||
if len(embeddings) != len(nonEmptyBatch) {
|
||||
err := errors.New("embedding count mismatch")
|
||||
r.logger.Error("embedding mismatch", "expected", len(nonEmptyBatch), "got", len(embeddings))
|
||||
return err
|
||||
}
|
||||
default:
|
||||
// No immediate error, continue
|
||||
// Write vectors to storage
|
||||
filename := path.Base(fpath)
|
||||
for j, text := range nonEmptyBatch {
|
||||
vector := models.VectorRow{
|
||||
Embeddings: embeddings[j],
|
||||
RawText: text,
|
||||
Slug: fmt.Sprintf("%s_%d_%d", filename, batchCount, j),
|
||||
FileName: filename,
|
||||
}
|
||||
|
||||
// Write vectors to storage - this will block until vectorCh is closed
|
||||
return r.writeVectors(vectorCh)
|
||||
}
|
||||
|
||||
func (r *RAG) writeVectors(vectorCh chan []models.VectorRow) error {
|
||||
for {
|
||||
for batch := range vectorCh {
|
||||
for _, vector := range batch {
|
||||
if err := r.storage.WriteVector(&vector); err != nil {
|
||||
r.logger.Error("failed to write vector to DB", "error", err, "slug", vector.Slug)
|
||||
select {
|
||||
case LongJobStatusCh <- ErrRAGStatus:
|
||||
default:
|
||||
r.logger.Warn("LongJobStatusCh channel is full or closed, dropping status message", "message", ErrRAGStatus)
|
||||
// Channel is full or closed, ignore the message to prevent panic
|
||||
r.logger.Warn("LongJobStatusCh channel full, dropping message")
|
||||
}
|
||||
return err // Stop the entire RAG operation on DB error
|
||||
return fmt.Errorf("failed to write vector: %w", err)
|
||||
}
|
||||
}
|
||||
r.logger.Debug("wrote batch to db", "size", len(batch), "vector_chan_len", len(vectorCh))
|
||||
if len(vectorCh) == 0 {
|
||||
r.logger.Debug("finished writing vectors")
|
||||
r.logger.Debug("wrote batch to db", "batch", batchCount, "size", len(nonEmptyBatch))
|
||||
// Send progress status
|
||||
statusMsg := fmt.Sprintf("processed batch %d/%d", batchCount, (len(paragraphs)+r.cfg.RAGBatchSize-1)/r.cfg.RAGBatchSize)
|
||||
select {
|
||||
case LongJobStatusCh <- statusMsg:
|
||||
default:
|
||||
r.logger.Warn("LongJobStatusCh channel full, dropping message")
|
||||
}
|
||||
}
|
||||
r.logger.Debug("finished writing vectors", "batches", batchCount)
|
||||
select {
|
||||
case LongJobStatusCh <- FinishedRAGStatus:
|
||||
default:
|
||||
r.logger.Warn("LongJobStatusCh channel is full or closed, dropping status message", "message", FinishedRAGStatus)
|
||||
// Channel is full or closed, ignore the message to prevent panic
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *RAG) batchToVectorAsync(lock *sync.Mutex, id int, inputCh <-chan map[int][]string,
|
||||
vectorCh chan<- []models.VectorRow, errCh chan error, filename string) {
|
||||
var err error
|
||||
|
||||
defer func() {
|
||||
// For errCh, make sure we only send if there's actually an error and the channel can accept it
|
||||
if err != nil {
|
||||
select {
|
||||
case errCh <- err:
|
||||
default:
|
||||
// errCh might be full or closed, log but don't panic
|
||||
r.logger.Warn("errCh channel full or closed, skipping error propagation", "worker", id, "error", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
lock.Lock()
|
||||
if len(inputCh) == 0 {
|
||||
lock.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
select {
|
||||
case linesMap := <-inputCh:
|
||||
for leftI, lines := range linesMap {
|
||||
if err := r.fetchEmb(lines, errCh, vectorCh, fmt.Sprintf("%s_%d", filename, leftI), filename); err != nil {
|
||||
r.logger.Error("error fetching embeddings", "error", err, "worker", id)
|
||||
lock.Unlock()
|
||||
return
|
||||
}
|
||||
}
|
||||
lock.Unlock()
|
||||
case err = <-errCh:
|
||||
r.logger.Error("got an error from error channel", "error", err)
|
||||
lock.Unlock()
|
||||
return
|
||||
default:
|
||||
lock.Unlock()
|
||||
}
|
||||
|
||||
r.logger.Debug("processed batch", "batches#", len(inputCh), "worker#", id)
|
||||
statusMsg := fmt.Sprintf("converted to vector; batches: %d, worker#: %d", len(inputCh), id)
|
||||
select {
|
||||
case LongJobStatusCh <- statusMsg:
|
||||
default:
|
||||
r.logger.Warn("LongJobStatusCh channel full or closed, dropping status message", "message", statusMsg)
|
||||
// Channel is full or closed, ignore the message to prevent panic
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *RAG) fetchEmb(lines []string, errCh chan error, vectorCh chan<- []models.VectorRow, slug, filename string) error {
|
||||
// Filter out empty lines before sending to embedder
|
||||
nonEmptyLines := make([]string, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed != "" {
|
||||
nonEmptyLines = append(nonEmptyLines, trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
// Skip if no non-empty lines
|
||||
if len(nonEmptyLines) == 0 {
|
||||
// Send empty result but don't error
|
||||
vectorCh <- []models.VectorRow{}
|
||||
return nil
|
||||
}
|
||||
|
||||
embeddings, err := r.embedder.EmbedSlice(nonEmptyLines)
|
||||
if err != nil {
|
||||
r.logger.Error("failed to embed lines", "err", err.Error())
|
||||
errCh <- err
|
||||
return err
|
||||
}
|
||||
|
||||
if len(embeddings) == 0 {
|
||||
err := errors.New("no embeddings returned")
|
||||
r.logger.Error("empty embeddings")
|
||||
errCh <- err
|
||||
return err
|
||||
}
|
||||
|
||||
if len(embeddings) != len(nonEmptyLines) {
|
||||
err := errors.New("mismatch between number of lines and embeddings returned")
|
||||
r.logger.Error("embedding mismatch", "err", err.Error())
|
||||
errCh <- err
|
||||
return err
|
||||
}
|
||||
|
||||
// Create a VectorRow for each line in the batch
|
||||
vectors := make([]models.VectorRow, len(nonEmptyLines))
|
||||
for i, line := range nonEmptyLines {
|
||||
vectors[i] = models.VectorRow{
|
||||
Embeddings: embeddings[i],
|
||||
RawText: line,
|
||||
Slug: fmt.Sprintf("%s_%d", slug, i),
|
||||
FileName: filename,
|
||||
}
|
||||
}
|
||||
|
||||
vectorCh <- vectors
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *RAG) LineToVector(line string) ([]float32, error) {
|
||||
return r.embedder.Embed(line)
|
||||
@@ -332,3 +194,259 @@ func (r *RAG) ListLoaded() ([]string, error) {
|
||||
func (r *RAG) RemoveFile(filename string) error {
|
||||
return r.storage.RemoveEmbByFileName(filename)
|
||||
}
|
||||
|
||||
var (
|
||||
queryRefinementPattern = regexp.MustCompile(`(?i)(based on my (vector db|vector db|vector database|rags?|past (conversations?|chat|messages?))|from my (files?|documents?|data|information|memory)|search (in|my) (vector db|database|rags?)|rag search for)`)
|
||||
importantKeywords = []string{"project", "architecture", "code", "file", "chat", "conversation", "topic", "summary", "details", "history", "previous", "my", "user", "me"}
|
||||
stopWords = []string{"the", "a", "an", "and", "or", "but", "in", "on", "at", "to", "for", "of", "with", "by", "from", "up", "down", "left", "right"}
|
||||
)
|
||||
|
||||
func (r *RAG) RefineQuery(query string) string {
|
||||
original := query
|
||||
query = strings.TrimSpace(query)
|
||||
if len(query) == 0 {
|
||||
return original
|
||||
}
|
||||
if len(query) <= 3 {
|
||||
return original
|
||||
}
|
||||
query = strings.ToLower(query)
|
||||
for _, stopWord := range stopWords {
|
||||
wordPattern := `\b` + stopWord + `\b`
|
||||
re := regexp.MustCompile(wordPattern)
|
||||
query = re.ReplaceAllString(query, "")
|
||||
}
|
||||
query = strings.TrimSpace(query)
|
||||
if len(query) < 5 {
|
||||
return original
|
||||
}
|
||||
if queryRefinementPattern.MatchString(original) {
|
||||
cleaned := queryRefinementPattern.ReplaceAllString(original, "")
|
||||
cleaned = strings.TrimSpace(cleaned)
|
||||
if len(cleaned) >= 5 {
|
||||
return cleaned
|
||||
}
|
||||
}
|
||||
query = r.extractImportantPhrases(query)
|
||||
if len(query) < 5 {
|
||||
return original
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
func (r *RAG) extractImportantPhrases(query string) string {
|
||||
words := strings.Fields(query)
|
||||
var important []string
|
||||
for _, word := range words {
|
||||
word = strings.Trim(word, ".,!?;:'\"()[]{}")
|
||||
isImportant := false
|
||||
for _, kw := range importantKeywords {
|
||||
if strings.Contains(strings.ToLower(word), kw) {
|
||||
isImportant = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if isImportant || len(word) > 3 {
|
||||
important = append(important, word)
|
||||
}
|
||||
}
|
||||
if len(important) == 0 {
|
||||
return query
|
||||
}
|
||||
return strings.Join(important, " ")
|
||||
}
|
||||
|
||||
func (r *RAG) GenerateQueryVariations(query string) []string {
|
||||
variations := []string{query}
|
||||
if len(query) < 5 {
|
||||
return variations
|
||||
}
|
||||
parts := strings.Fields(query)
|
||||
if len(parts) == 0 {
|
||||
return variations
|
||||
}
|
||||
if len(parts) >= 2 {
|
||||
trimmed := strings.Join(parts[:len(parts)-1], " ")
|
||||
if len(trimmed) >= 5 {
|
||||
variations = append(variations, trimmed)
|
||||
}
|
||||
}
|
||||
if len(parts) >= 2 {
|
||||
trimmed := strings.Join(parts[1:], " ")
|
||||
if len(trimmed) >= 5 {
|
||||
variations = append(variations, trimmed)
|
||||
}
|
||||
}
|
||||
if !strings.HasSuffix(query, " explanation") {
|
||||
variations = append(variations, query+" explanation")
|
||||
}
|
||||
if !strings.HasPrefix(query, "what is ") {
|
||||
variations = append(variations, "what is "+query)
|
||||
}
|
||||
if !strings.HasSuffix(query, " details") {
|
||||
variations = append(variations, query+" details")
|
||||
}
|
||||
if !strings.HasSuffix(query, " summary") {
|
||||
variations = append(variations, query+" summary")
|
||||
}
|
||||
return variations
|
||||
}
|
||||
|
||||
func (r *RAG) RerankResults(results []models.VectorRow, query string) []models.VectorRow {
|
||||
type scoredResult struct {
|
||||
row models.VectorRow
|
||||
distance float32
|
||||
}
|
||||
scored := make([]scoredResult, 0, len(results))
|
||||
for i := range results {
|
||||
row := results[i]
|
||||
|
||||
score := float32(0)
|
||||
rawTextLower := strings.ToLower(row.RawText)
|
||||
queryLower := strings.ToLower(query)
|
||||
if strings.Contains(rawTextLower, queryLower) {
|
||||
score += 10
|
||||
}
|
||||
queryWords := strings.Fields(queryLower)
|
||||
matchCount := 0
|
||||
for _, word := range queryWords {
|
||||
if len(word) > 2 && strings.Contains(rawTextLower, word) {
|
||||
matchCount++
|
||||
}
|
||||
}
|
||||
if len(queryWords) > 0 {
|
||||
score += float32(matchCount) / float32(len(queryWords)) * 5
|
||||
}
|
||||
if row.FileName == "chat" || strings.Contains(strings.ToLower(row.FileName), "conversation") {
|
||||
score += 3
|
||||
}
|
||||
distance := row.Distance - score/100
|
||||
scored = append(scored, scoredResult{row: row, distance: distance})
|
||||
}
|
||||
sort.Slice(scored, func(i, j int) bool {
|
||||
return scored[i].distance < scored[j].distance
|
||||
})
|
||||
unique := make([]models.VectorRow, 0)
|
||||
seen := make(map[string]bool)
|
||||
for i := range scored {
|
||||
if !seen[scored[i].row.Slug] {
|
||||
seen[scored[i].row.Slug] = true
|
||||
unique = append(unique, scored[i].row)
|
||||
}
|
||||
}
|
||||
if len(unique) > 10 {
|
||||
unique = unique[:10]
|
||||
}
|
||||
return unique
|
||||
}
|
||||
|
||||
func (r *RAG) SynthesizeAnswer(results []models.VectorRow, query string) (string, error) {
|
||||
if len(results) == 0 {
|
||||
return "No relevant information found in the vector database.", nil
|
||||
}
|
||||
var contextBuilder strings.Builder
|
||||
contextBuilder.WriteString("User Query: ")
|
||||
contextBuilder.WriteString(query)
|
||||
contextBuilder.WriteString("\n\nRetrieved Context:\n")
|
||||
for i, row := range results {
|
||||
fmt.Fprintf(&contextBuilder, "[Source %d: %s]\n", i+1, row.FileName)
|
||||
contextBuilder.WriteString(row.RawText)
|
||||
contextBuilder.WriteString("\n\n")
|
||||
}
|
||||
contextBuilder.WriteString("Instructions: ")
|
||||
contextBuilder.WriteString("Based on the retrieved context above, provide a concise, coherent answer to the user's query. ")
|
||||
contextBuilder.WriteString("Extract only the most relevant information. ")
|
||||
contextBuilder.WriteString("If no relevant information is found, state that clearly. ")
|
||||
contextBuilder.WriteString("Cite sources by filename when relevant. ")
|
||||
contextBuilder.WriteString("Do not include unnecessary preamble or explanations.")
|
||||
synthesisPrompt := contextBuilder.String()
|
||||
emb, err := r.LineToVector(synthesisPrompt)
|
||||
if err != nil {
|
||||
r.logger.Error("failed to embed synthesis prompt", "error", err)
|
||||
return "", err
|
||||
}
|
||||
embResp := &models.EmbeddingResp{
|
||||
Embedding: emb,
|
||||
Index: 0,
|
||||
}
|
||||
topResults, err := r.SearchEmb(embResp)
|
||||
if err != nil {
|
||||
r.logger.Error("failed to search for synthesis context", "error", err)
|
||||
return "", err
|
||||
}
|
||||
if len(topResults) > 0 && topResults[0].RawText != synthesisPrompt {
|
||||
return topResults[0].RawText, nil
|
||||
}
|
||||
var finalAnswer strings.Builder
|
||||
finalAnswer.WriteString("Based on the retrieved context:\n\n")
|
||||
for i, row := range results {
|
||||
if i >= 5 {
|
||||
break
|
||||
}
|
||||
fmt.Fprintf(&finalAnswer, "- From %s: %s\n", row.FileName, truncateString(row.RawText, 200))
|
||||
}
|
||||
return finalAnswer.String(), nil
|
||||
}
|
||||
|
||||
func truncateString(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen] + "..."
|
||||
}
|
||||
|
||||
func (r *RAG) Search(query string, limit int) ([]models.VectorRow, error) {
|
||||
refined := r.RefineQuery(query)
|
||||
variations := r.GenerateQueryVariations(refined)
|
||||
allResults := make([]models.VectorRow, 0)
|
||||
seen := make(map[string]bool)
|
||||
for _, q := range variations {
|
||||
emb, err := r.LineToVector(q)
|
||||
if err != nil {
|
||||
r.logger.Error("failed to embed query variation", "error", err, "query", q)
|
||||
continue
|
||||
}
|
||||
|
||||
embResp := &models.EmbeddingResp{
|
||||
Embedding: emb,
|
||||
Index: 0,
|
||||
}
|
||||
|
||||
results, err := r.SearchEmb(embResp)
|
||||
if err != nil {
|
||||
r.logger.Error("failed to search embeddings", "error", err, "query", q)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, row := range results {
|
||||
if !seen[row.Slug] {
|
||||
seen[row.Slug] = true
|
||||
allResults = append(allResults, row)
|
||||
}
|
||||
}
|
||||
}
|
||||
reranked := r.RerankResults(allResults, query)
|
||||
if len(reranked) > limit {
|
||||
reranked = reranked[:limit]
|
||||
}
|
||||
return reranked, nil
|
||||
}
|
||||
|
||||
var (
|
||||
ragInstance *RAG
|
||||
ragOnce sync.Once
|
||||
)
|
||||
|
||||
func Init(c *config.Config, l *slog.Logger, s storage.FullRepo) error {
|
||||
ragOnce.Do(func() {
|
||||
if c == nil || l == nil || s == nil {
|
||||
return
|
||||
}
|
||||
ragInstance = New(l, s, c)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetInstance() *RAG {
|
||||
return ragInstance
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ func NewVectorStorage(logger *slog.Logger, store storage.FullRepo) *VectorStorag
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// SerializeVector converts []float32 to binary blob
|
||||
func SerializeVector(vec []float32) []byte {
|
||||
buf := make([]byte, len(vec)*4) // 4 bytes per float32
|
||||
@@ -66,17 +65,14 @@ func (vs *VectorStorage) WriteVector(row *models.VectorRow) error {
|
||||
|
||||
// Serialize the embeddings to binary
|
||||
serializedEmbeddings := SerializeVector(row.Embeddings)
|
||||
|
||||
query := fmt.Sprintf(
|
||||
"INSERT INTO %s (embeddings, slug, raw_text, filename) VALUES (?, ?, ?, ?)",
|
||||
tableName,
|
||||
)
|
||||
|
||||
if _, err := vs.sqlxDB.Exec(query, serializedEmbeddings, row.Slug, row.RawText, row.FileName); err != nil {
|
||||
vs.logger.Error("failed to write vector", "error", err, "slug", row.Slug)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -95,11 +91,9 @@ func (vs *VectorStorage) getTableName(emb []float32) (string, error) {
|
||||
4096: true,
|
||||
5120: true,
|
||||
}
|
||||
|
||||
if supportedSizes[size] {
|
||||
return fmt.Sprintf("embeddings_%d", size), nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no table for embedding size of %d", size)
|
||||
}
|
||||
|
||||
@@ -126,9 +120,7 @@ func (vs *VectorStorage) SearchClosest(query []float32) ([]models.VectorRow, err
|
||||
vector models.VectorRow
|
||||
distance float32
|
||||
}
|
||||
|
||||
var topResults []SearchResult
|
||||
|
||||
// Process vectors one by one to avoid loading everything into memory
|
||||
for rows.Next() {
|
||||
var (
|
||||
@@ -176,14 +168,12 @@ func (vs *VectorStorage) SearchClosest(query []float32) ([]models.VectorRow, err
|
||||
result.vector.Distance = result.distance
|
||||
results = append(results, result.vector)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// ListFiles returns a list of all loaded files
|
||||
func (vs *VectorStorage) ListFiles() ([]string, error) {
|
||||
fileLists := make([][]string, 0)
|
||||
|
||||
// Query all supported tables and combine results
|
||||
embeddingSizes := []int{384, 768, 1024, 1536, 2048, 3072, 4096, 5120}
|
||||
for _, size := range embeddingSizes {
|
||||
@@ -219,14 +209,12 @@ func (vs *VectorStorage) ListFiles() ([]string, error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return allFiles, nil
|
||||
}
|
||||
|
||||
// RemoveEmbByFileName removes all embeddings associated with a specific filename
|
||||
func (vs *VectorStorage) RemoveEmbByFileName(filename string) error {
|
||||
var errors []string
|
||||
|
||||
embeddingSizes := []int{384, 768, 1024, 1536, 2048, 3072, 4096, 5120}
|
||||
for _, size := range embeddingSizes {
|
||||
table := fmt.Sprintf("embeddings_%d", size)
|
||||
@@ -235,11 +223,9 @@ func (vs *VectorStorage) RemoveEmbByFileName(filename string) error {
|
||||
errors = append(errors, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
if len(errors) > 0 {
|
||||
return fmt.Errorf("errors occurred: %s", strings.Join(errors, "; "))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -248,18 +234,15 @@ func cosineSimilarity(a, b []float32) float32 {
|
||||
if len(a) != len(b) {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
var dotProduct, normA, normB float32
|
||||
for i := 0; i < len(a); i++ {
|
||||
dotProduct += a[i] * b[i]
|
||||
normA += a[i] * a[i]
|
||||
normB += b[i] * b[i]
|
||||
}
|
||||
|
||||
if normA == 0 || normB == 0 {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
return dotProduct / (sqrt(normA) * sqrt(normB))
|
||||
}
|
||||
|
||||
@@ -275,4 +258,3 @@ func sqrt(f float32) float32 {
|
||||
}
|
||||
return guess
|
||||
}
|
||||
|
||||
|
||||
18
session.go
18
session.go
@@ -131,13 +131,18 @@ func loadOldChatOrGetNew() []models.RoleMsg {
|
||||
chat, err := store.GetLastChat()
|
||||
if err != nil {
|
||||
logger.Warn("failed to load history chat", "error", err)
|
||||
maxID, err := store.ChatGetMaxID()
|
||||
if err != nil {
|
||||
logger.Error("failed to fetch max chat id", "error", err)
|
||||
}
|
||||
maxID++
|
||||
chat := &models.Chat{
|
||||
ID: 0,
|
||||
ID: maxID,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
Agent: cfg.AssistantRole,
|
||||
}
|
||||
chat.Name = fmt.Sprintf("%s_%v", chat.Agent, chat.CreatedAt.Unix())
|
||||
chat.Name = fmt.Sprintf("%s_%v", chat.Agent, chat.ID)
|
||||
activeChatName = chat.Name
|
||||
chatMap[chat.Name] = chat
|
||||
return defaultStarter
|
||||
@@ -149,10 +154,6 @@ func loadOldChatOrGetNew() []models.RoleMsg {
|
||||
chatMap[chat.Name] = chat
|
||||
return defaultStarter
|
||||
}
|
||||
// if chat.Name == "" {
|
||||
// logger.Warn("empty chat name", "id", chat.ID)
|
||||
// chat.Name = fmt.Sprintf("%s_%v", chat.Agent, chat.CreatedAt.Unix())
|
||||
// }
|
||||
chatMap[chat.Name] = chat
|
||||
activeChatName = chat.Name
|
||||
cfg.AssistantRole = chat.Agent
|
||||
@@ -167,8 +168,3 @@ func copyToClipboard(text string) error {
|
||||
cmd.Stdin = strings.NewReader(text)
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func notifyUser(topic, message string) error {
|
||||
cmd := exec.Command("notify-send", topic, message)
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
@@ -10,16 +10,18 @@ import (
|
||||
//go:embed migrations/*
|
||||
var migrationsFS embed.FS
|
||||
|
||||
func (p *ProviderSQL) Migrate() {
|
||||
func (p *ProviderSQL) Migrate() error {
|
||||
// Get the embedded filesystem
|
||||
migrationsDir, err := fs.Sub(migrationsFS, "migrations")
|
||||
if err != nil {
|
||||
p.logger.Error("Failed to get embedded migrations directory;", "error", err)
|
||||
return fmt.Errorf("failed to get embedded migrations directory: %w", err)
|
||||
}
|
||||
// List all .up.sql files
|
||||
files, err := migrationsFS.ReadDir("migrations")
|
||||
if err != nil {
|
||||
p.logger.Error("Failed to read migrations directory;", "error", err)
|
||||
return fmt.Errorf("failed to read migrations directory: %w", err)
|
||||
}
|
||||
// Execute each .up.sql file
|
||||
for _, file := range files {
|
||||
@@ -27,11 +29,12 @@ func (p *ProviderSQL) Migrate() {
|
||||
err := p.executeMigration(migrationsDir, file.Name())
|
||||
if err != nil {
|
||||
p.logger.Error("Failed to execute migration %s: %v", file.Name(), err)
|
||||
panic(err)
|
||||
return fmt.Errorf("failed to execute migration %s: %w", file.Name(), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
p.logger.Debug("All migrations executed successfully!")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *ProviderSQL) executeMigration(migrationsDir fs.FS, fileName string) error {
|
||||
|
||||
@@ -103,8 +103,10 @@ func NewProviderSQL(dbPath string, logger *slog.Logger) FullRepo {
|
||||
return nil
|
||||
}
|
||||
p := ProviderSQL{db: db, logger: logger}
|
||||
|
||||
p.Migrate()
|
||||
if err := p.Migrate(); err != nil {
|
||||
logger.Error("migration failed, app cannot start", "error", err)
|
||||
return nil
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
|
||||
@@ -73,12 +73,9 @@ func (p ProviderSQL) WriteVector(row *models.VectorRow) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
serializedEmbeddings := SerializeVector(row.Embeddings)
|
||||
|
||||
query := fmt.Sprintf("INSERT INTO %s(embeddings, slug, raw_text, filename) VALUES (?, ?, ?, ?)", tableName)
|
||||
_, err = p.db.Exec(query, serializedEmbeddings, row.Slug, row.RawText, row.FileName)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -87,27 +84,22 @@ func (p ProviderSQL) SearchClosest(q []float32) ([]models.VectorRow, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
querySQL := "SELECT embeddings, slug, raw_text, filename FROM " + tableName
|
||||
rows, err := p.db.Query(querySQL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type SearchResult struct {
|
||||
vector models.VectorRow
|
||||
distance float32
|
||||
}
|
||||
|
||||
var topResults []SearchResult
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
embeddingsBlob []byte
|
||||
slug, rawText, fileName string
|
||||
)
|
||||
|
||||
if err := rows.Scan(&embeddingsBlob, &slug, &rawText, &fileName); err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -152,7 +144,6 @@ func (p ProviderSQL) SearchClosest(q []float32) ([]models.VectorRow, error) {
|
||||
result.vector.Distance = result.distance
|
||||
results[i] = result.vector
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
@@ -161,18 +152,15 @@ func cosineSimilarity(a, b []float32) float32 {
|
||||
if len(a) != len(b) {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
var dotProduct, normA, normB float32
|
||||
for i := 0; i < len(a); i++ {
|
||||
dotProduct += a[i] * b[i]
|
||||
normA += a[i] * a[i]
|
||||
normB += b[i] * b[i]
|
||||
}
|
||||
|
||||
if normA == 0 || normB == 0 {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
return dotProduct / (sqrt(normA) * sqrt(normB))
|
||||
}
|
||||
|
||||
@@ -229,13 +217,11 @@ func (p ProviderSQL) ListFiles() ([]string, error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return allFiles, nil
|
||||
}
|
||||
|
||||
func (p ProviderSQL) RemoveEmbByFileName(filename string) error {
|
||||
var errors []string
|
||||
|
||||
tableNames := []string{
|
||||
"embeddings_384", "embeddings_768", "embeddings_1024", "embeddings_1536",
|
||||
"embeddings_2048", "embeddings_3072", "embeddings_4096", "embeddings_5120",
|
||||
@@ -246,10 +232,8 @@ func (p ProviderSQL) RemoveEmbByFileName(filename string) error {
|
||||
errors = append(errors, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
if len(errors) > 0 {
|
||||
return fmt.Errorf("errors occurred: %v", errors)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"sys_prompt": "A game of cluedo. Players are {{user}}, {{char}}, {{char2}};\n\nrooms: hall, lounge, dinning room kitchen, ballroom, conservatory, billiard room, library, study;\nweapons: candlestick, dagger, lead pipe, revolver, rope, spanner;\npeople: miss Scarlett, colonel Mustard, mrs. White, reverend Green, mrs. Peacock, professor Plum;\n\nA murder happened in a mansion with 9 rooms. Victim is dr. Black.\nPlayers goal is to find out who commited a murder, in what room and with what weapon.\nWeapons, people and rooms not involved in murder are distributed between players (as cards) by tool agent.\nThe objective of the game is to deduce the details of the murder. There are six characters, six murder weapons, and nine rooms, leaving the players with 324 possibilities. As soon as a player enters a room, they may make a suggestion as to the details, naming a suspect, the room they are in, and the weapon. For example: \"I suspect Professor Plum, in the Dining Room, with the candlestick\".\nOnce a player makes a suggestion, the others are called upon to disprove it.\nBefore the player's move, tool agent will remind that players their cards. There are two types of moves: making a suggestion (suggestion_move) and disproving other player suggestion (evidence_move);\nIn this version player wins when the correct details are named in the suggestion_move.\n\n<example_game>\n{{user}}:\nlet's start a game of cluedo!\ntool: cards of {{char}} are 'LEAD PIPE', 'BALLROOM', 'CONSERVATORY', 'STUDY', 'Mrs. White'; suggestion_move;\n{{char}}:\n(putting miss Scarlet into the Hall with the Revolver) \"I suspect miss Scarlett, in the Hall, with the revolver.\"\ntool: cards of {{char2}} are 'SPANNER', 'DAGGER', 'Professor Plum', 'LIBRARY', 'Mrs. Peacock'; evidence_move;\n{{char2}}:\n\"No objections.\" (no cards matching the suspicion of {{char}})\ntool: cards of {{user}} are 'Colonel Mustard', 'Miss Scarlett', 'DINNING ROOM', 'CANDLESTICK', 'HALL'; evidence_move;\n{{user}}:\n\"I object. Miss Scarlett is innocent.\" (shows card with 'Miss Scarlett')\ntool: cards of {{char2}} are 'SPANNER', 'DAGGER', 'Professor Plum', 'LIBRARY', 'Mrs. Peacock'; suggestion_move;\n{{char2}}:\n*So it was not Miss Scarlett, good to know.*\n(moves Mrs. White to the Billiard Room) \"It might have been Mrs. White, in the Billiard Room, with the Revolver.\"\ntool: cards of {{user}} are 'Colonel Mustard', 'Miss Scarlett', 'DINNING ROOM', 'CANDLESTICK', 'HALL'; evidence_move;\n{{user}}:\n(no matching cards for the assumption of {{char2}}) \"Sounds possible to me.\"\ntool: cards of {{char}} are 'LEAD PIPE', 'BALLROOM', 'CONSERVATORY', 'STUDY', 'Mrs. White'; evidence_move;\n{{char}}:\n(shows Mrs. White card) \"No. Was not Mrs. White\"\ntool: cards of {{user}} are 'Colonel Mustard', 'Miss Scarlett', 'DINNING ROOM', 'CANDLESTICK', 'HALL'; suggestion_move;\n{{user}}:\n*So not Mrs. White...* (moves Reverend Green into the Billiard Room) \"I suspect Reverend Green, in the Billiard Room, with the Revolver.\"\ntool: Correct. It was Reverend Green in the Billiard Room, with the revolver. {{user}} wins.\n</example_game>",
|
||||
"role": "CluedoPlayer",
|
||||
"role2": "CluedoEnjoyer",
|
||||
"filepath": "sysprompts/cluedo.json",
|
||||
"first_msg": "Hey guys! Want to play cluedo?"
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
473
tables.go
473
tables.go
@@ -147,9 +147,7 @@ func makeChatTable(chatMap map[string]models.Chat) *tview.Table {
|
||||
if err := store.RemoveChat(sc.ID); err != nil {
|
||||
logger.Error("failed to remove chat from db", "chat_id", sc.ID, "chat_name", sc.Name)
|
||||
}
|
||||
if err := notifyUser("chat deleted", selectedChat+" was deleted"); err != nil {
|
||||
logger.Error("failed to send notification", "error", err)
|
||||
}
|
||||
showToast("chat deleted", selectedChat+" was deleted")
|
||||
// load last chat
|
||||
chatBody.Messages = loadOldChatOrGetNew()
|
||||
textView.SetText(chatToText(chatBody.Messages, cfg.ShowSys))
|
||||
@@ -159,27 +157,16 @@ func makeChatTable(chatMap map[string]models.Chat) *tview.Table {
|
||||
// save updated card
|
||||
fi := strings.Index(selectedChat, "_")
|
||||
agentName := selectedChat[fi+1:]
|
||||
cc, ok := sysMap[agentName]
|
||||
if !ok {
|
||||
cc := GetCardByRole(agentName)
|
||||
if cc == nil {
|
||||
logger.Warn("no such card", "agent", agentName)
|
||||
//no:lint
|
||||
if err := notifyUser("error", "no such card: "+agentName); err != nil {
|
||||
logger.Warn("failed ot notify", "error", err)
|
||||
}
|
||||
showToast("error", "no such card: "+agentName)
|
||||
return
|
||||
}
|
||||
// if chatBody.Messages[0].Role != "system" || chatBody.Messages[1].Role != agentName {
|
||||
// if err := notifyUser("error", "unexpected chat structure; card: "+agentName); err != nil {
|
||||
// logger.Warn("failed ot notify", "error", err)
|
||||
// }
|
||||
// return
|
||||
// }
|
||||
// change sys_prompt + first msg
|
||||
cc.SysPrompt = chatBody.Messages[0].Content
|
||||
cc.FirstMsg = chatBody.Messages[1].Content
|
||||
if err := pngmeta.WriteToPng(cc.ToSpec(cfg.UserRole), cc.FilePath, cc.FilePath); err != nil {
|
||||
logger.Error("failed to write charcard",
|
||||
"error", err)
|
||||
logger.Error("failed to write charcard", "error", err)
|
||||
}
|
||||
return
|
||||
case "move sysprompt onto 1st msg":
|
||||
@@ -190,33 +177,29 @@ func makeChatTable(chatMap map[string]models.Chat) *tview.Table {
|
||||
pages.RemovePage(historyPage)
|
||||
return
|
||||
case "new_chat_from_card":
|
||||
// Reread card from file and start fresh chat
|
||||
fi := strings.Index(selectedChat, "_")
|
||||
agentName := selectedChat[fi+1:]
|
||||
cc, ok := sysMap[agentName]
|
||||
if !ok {
|
||||
cc := GetCardByRole(agentName)
|
||||
if cc == nil {
|
||||
logger.Warn("no such card", "agent", agentName)
|
||||
if err := notifyUser("error", "no such card: "+agentName); err != nil {
|
||||
logger.Warn("failed to notify", "error", err)
|
||||
}
|
||||
showToast("error", "no such card: "+agentName)
|
||||
return
|
||||
}
|
||||
// Reload card from disk
|
||||
newCard, err := pngmeta.ReadCard(cc.FilePath, cfg.UserRole)
|
||||
if err != nil {
|
||||
logger.Error("failed to reload charcard", "path", cc.FilePath, "error", err)
|
||||
newCard, err = pngmeta.ReadCardJson(cc.FilePath)
|
||||
if err != nil {
|
||||
logger.Error("failed to reload charcard", "path", cc.FilePath, "error", err)
|
||||
if err := notifyUser("error", "failed to reload card: "+cc.FilePath); err != nil {
|
||||
logger.Warn("failed to notify", "error", err)
|
||||
}
|
||||
showToast("error", "failed to reload card: "+cc.FilePath)
|
||||
return
|
||||
}
|
||||
}
|
||||
// Update sysMap with fresh card data
|
||||
sysMap[agentName] = newCard
|
||||
// fetching sysprompt and first message anew from the card
|
||||
if newCard.ID == "" {
|
||||
newCard.ID = models.ComputeCardID(newCard.Role, newCard.FilePath)
|
||||
}
|
||||
sysMap[newCard.ID] = newCard
|
||||
roleToID[newCard.Role] = newCard.ID
|
||||
startNewChat(false)
|
||||
pages.RemovePage(historyPage)
|
||||
return
|
||||
@@ -236,9 +219,59 @@ func makeChatTable(chatMap map[string]models.Chat) *tview.Table {
|
||||
}
|
||||
|
||||
// nolint:unused
|
||||
func makeRAGTable(fileList []string) *tview.Flex {
|
||||
actions := []string{"load", "delete"}
|
||||
rows, cols := len(fileList), len(actions)+1
|
||||
func formatSize(size int64) string {
|
||||
units := []string{"B", "KB", "MB", "GB", "TB"}
|
||||
i := 0
|
||||
s := float64(size)
|
||||
for s >= 1024 && i < len(units)-1 {
|
||||
s /= 1024
|
||||
i++
|
||||
}
|
||||
return fmt.Sprintf("%.1f%s", s, units[i])
|
||||
}
|
||||
|
||||
type ragFileInfo struct {
|
||||
name string
|
||||
inRAGDir bool
|
||||
isLoaded bool
|
||||
fullPath string
|
||||
}
|
||||
|
||||
func makeRAGTable(fileList []string, loadedFiles []string) *tview.Flex {
|
||||
// Build set of loaded files for quick lookup
|
||||
loadedSet := make(map[string]bool)
|
||||
for _, f := range loadedFiles {
|
||||
loadedSet[f] = true
|
||||
}
|
||||
|
||||
// Build merged list: files from ragdir + orphaned files from DB
|
||||
ragFiles := make([]ragFileInfo, 0, len(fileList)+len(loadedFiles))
|
||||
seen := make(map[string]bool)
|
||||
|
||||
// Add files from ragdir
|
||||
for _, f := range fileList {
|
||||
ragFiles = append(ragFiles, ragFileInfo{
|
||||
name: f,
|
||||
inRAGDir: true,
|
||||
isLoaded: loadedSet[f],
|
||||
fullPath: path.Join(cfg.RAGDir, f),
|
||||
})
|
||||
seen[f] = true
|
||||
}
|
||||
|
||||
// Add orphaned files (in DB but not in ragdir)
|
||||
for _, f := range loadedFiles {
|
||||
if !seen[f] {
|
||||
ragFiles = append(ragFiles, ragFileInfo{
|
||||
name: f,
|
||||
inRAGDir: false,
|
||||
isLoaded: true,
|
||||
fullPath: "",
|
||||
})
|
||||
}
|
||||
}
|
||||
rows := len(ragFiles)
|
||||
cols := 4 // File Name | Preview | Action | Delete
|
||||
fileTable := tview.NewTable().
|
||||
SetBorders(true)
|
||||
longStatusView := tview.NewTextView()
|
||||
@@ -252,41 +285,92 @@ func makeRAGTable(fileList []string) *tview.Flex {
|
||||
AddItem(fileTable, 0, 60, true)
|
||||
// Add the exit option as the first row (row 0)
|
||||
fileTable.SetCell(0, 0,
|
||||
tview.NewTableCell("Exit RAG manager").
|
||||
tview.NewTableCell("File Name").
|
||||
SetTextColor(tcell.ColorWhite).
|
||||
SetAlign(tview.AlignCenter).
|
||||
SetSelectable(false))
|
||||
fileTable.SetCell(0, 1,
|
||||
tview.NewTableCell("(Close without action)").
|
||||
SetTextColor(tcell.ColorGray).
|
||||
tview.NewTableCell("Preview").
|
||||
SetTextColor(tcell.ColorWhite).
|
||||
SetAlign(tview.AlignCenter).
|
||||
SetSelectable(false))
|
||||
fileTable.SetCell(0, 2,
|
||||
tview.NewTableCell("exit").
|
||||
SetTextColor(tcell.ColorGray).
|
||||
SetAlign(tview.AlignCenter))
|
||||
tview.NewTableCell("Load/Unload").
|
||||
SetTextColor(tcell.ColorWhite).
|
||||
SetAlign(tview.AlignCenter).
|
||||
SetSelectable(false))
|
||||
fileTable.SetCell(0, 3,
|
||||
tview.NewTableCell("Delete").
|
||||
SetTextColor(tcell.ColorWhite).
|
||||
SetAlign(tview.AlignCenter).
|
||||
SetSelectable(false))
|
||||
// Add the file rows starting from row 1
|
||||
for r := 0; r < rows; r++ {
|
||||
f := ragFiles[r]
|
||||
for c := 0; c < cols; c++ {
|
||||
color := tcell.ColorWhite
|
||||
switch {
|
||||
case c < 1:
|
||||
fileTable.SetCell(r+1, c, // +1 to account for the exit row at index 0
|
||||
tview.NewTableCell(fileList[r]).
|
||||
switch c {
|
||||
case 0:
|
||||
displayName := f.name
|
||||
if !f.inRAGDir {
|
||||
displayName = f.name + " (orphaned)"
|
||||
}
|
||||
fileTable.SetCell(r+1, c,
|
||||
tview.NewTableCell(displayName).
|
||||
SetTextColor(color).
|
||||
SetAlign(tview.AlignCenter).
|
||||
SetSelectable(false))
|
||||
case c == 1: // Action description column - not selectable
|
||||
fileTable.SetCell(r+1, c, // +1 to account for the exit row at index 0
|
||||
tview.NewTableCell("(Action)").
|
||||
case 1:
|
||||
if !f.inRAGDir {
|
||||
// Orphaned file - no preview available
|
||||
fileTable.SetCell(r+1, c,
|
||||
tview.NewTableCell("not in ragdir").
|
||||
SetTextColor(tcell.ColorYellow).
|
||||
SetAlign(tview.AlignCenter).
|
||||
SetSelectable(false))
|
||||
} else if fi, err := os.Stat(f.fullPath); err == nil {
|
||||
size := fi.Size()
|
||||
modTime := fi.ModTime()
|
||||
preview := fmt.Sprintf("%s | %s", formatSize(size), modTime.Format("2006-01-02 15:04"))
|
||||
fileTable.SetCell(r+1, c,
|
||||
tview.NewTableCell(preview).
|
||||
SetTextColor(color).
|
||||
SetAlign(tview.AlignCenter).
|
||||
SetSelectable(false))
|
||||
default: // Action button column - selectable
|
||||
fileTable.SetCell(r+1, c, // +1 to account for the exit row at index 0
|
||||
tview.NewTableCell(actions[c-1]).
|
||||
} else {
|
||||
fileTable.SetCell(r+1, c,
|
||||
tview.NewTableCell("error").
|
||||
SetTextColor(color).
|
||||
SetAlign(tview.AlignCenter).
|
||||
SetSelectable(false))
|
||||
}
|
||||
case 2:
|
||||
actionText := "load"
|
||||
if f.isLoaded {
|
||||
actionText = "unload"
|
||||
}
|
||||
if !f.inRAGDir {
|
||||
// Orphaned file - can only unload
|
||||
actionText = "unload"
|
||||
}
|
||||
fileTable.SetCell(r+1, c,
|
||||
tview.NewTableCell(actionText).
|
||||
SetTextColor(color).
|
||||
SetAlign(tview.AlignCenter))
|
||||
case 3:
|
||||
if !f.inRAGDir {
|
||||
// Orphaned file - cannot delete from ragdir (not there)
|
||||
fileTable.SetCell(r+1, c,
|
||||
tview.NewTableCell("-").
|
||||
SetTextColor(tcell.ColorDarkGray).
|
||||
SetAlign(tview.AlignCenter).
|
||||
SetSelectable(false))
|
||||
} else {
|
||||
fileTable.SetCell(r+1, c,
|
||||
tview.NewTableCell("delete").
|
||||
SetTextColor(color).
|
||||
SetAlign(tview.AlignCenter))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -318,7 +402,7 @@ func makeRAGTable(fileList []string) *tview.Flex {
|
||||
}()
|
||||
fileTable.Select(0, 0).
|
||||
SetFixed(1, 1).
|
||||
SetSelectable(true, false).
|
||||
SetSelectable(true, true).
|
||||
SetSelectedStyle(tcell.StyleDefault.Background(tcell.ColorGray).Foreground(tcell.ColorWhite)).
|
||||
SetDoneFunc(func(key tcell.Key) {
|
||||
if key == tcell.KeyEsc || key == tcell.KeyF1 || key == tcell.Key('x') || key == tcell.KeyCtrlX {
|
||||
@@ -335,37 +419,63 @@ func makeRAGTable(fileList []string) *tview.Flex {
|
||||
}
|
||||
// defer pages.RemovePage(RAGPage)
|
||||
tc := fileTable.GetCell(row, column)
|
||||
tc.SetTextColor(tcell.ColorRed)
|
||||
fileTable.SetSelectable(false, false)
|
||||
// Check if the selected row is the exit row (row 0) - do this first to avoid index issues
|
||||
if row == 0 {
|
||||
pages.RemovePage(RAGPage)
|
||||
return
|
||||
}
|
||||
// For file rows, get the filename (row index - 1 because of the exit row at index 0)
|
||||
fpath := fileList[row-1] // -1 to account for the exit row at index 0
|
||||
// notification := fmt.Sprintf("chat: %s; action: %s", fpath, tc.Text)
|
||||
// For file rows, get the file info (row index - 1 because of the exit row at index 0)
|
||||
f := ragFiles[row-1]
|
||||
// Handle "-" case (orphaned file with no delete option)
|
||||
if tc.Text == "-" {
|
||||
pages.RemovePage(RAGPage)
|
||||
return
|
||||
}
|
||||
switch tc.Text {
|
||||
case "load":
|
||||
fpath = path.Join(cfg.RAGDir, fpath)
|
||||
fpath := path.Join(cfg.RAGDir, f.name)
|
||||
longStatusView.SetText("clicked load")
|
||||
go func() {
|
||||
if err := ragger.LoadRAG(fpath); err != nil {
|
||||
logger.Error("failed to embed file", "chat", fpath, "error", err)
|
||||
_ = notifyUser("RAG", "failed to embed file; error: "+err.Error())
|
||||
errCh <- err
|
||||
// pages.RemovePage(RAGPage)
|
||||
showToast("RAG", "failed to embed file; error: "+err.Error())
|
||||
app.QueueUpdate(func() {
|
||||
pages.RemovePage(RAGPage)
|
||||
})
|
||||
return
|
||||
}
|
||||
showToast("RAG", "file loaded successfully")
|
||||
app.QueueUpdate(func() {
|
||||
pages.RemovePage(RAGPage)
|
||||
})
|
||||
}()
|
||||
return
|
||||
case "unload":
|
||||
longStatusView.SetText("clicked unload")
|
||||
go func() {
|
||||
if err := ragger.RemoveFile(f.name); err != nil {
|
||||
logger.Error("failed to unload file from RAG", "filename", f.name, "error", err)
|
||||
showToast("RAG", "failed to unload file; error: "+err.Error())
|
||||
app.QueueUpdate(func() {
|
||||
pages.RemovePage(RAGPage)
|
||||
})
|
||||
return
|
||||
}
|
||||
showToast("RAG", "file unloaded successfully")
|
||||
app.QueueUpdate(func() {
|
||||
pages.RemovePage(RAGPage)
|
||||
})
|
||||
}()
|
||||
return
|
||||
case "delete":
|
||||
fpath = path.Join(cfg.RAGDir, fpath)
|
||||
fpath := path.Join(cfg.RAGDir, f.name)
|
||||
if err := os.Remove(fpath); err != nil {
|
||||
logger.Error("failed to delete file", "filename", fpath, "error", err)
|
||||
return
|
||||
}
|
||||
if err := notifyUser("chat deleted", fpath+" was deleted"); err != nil {
|
||||
logger.Error("failed to send notification", "error", err)
|
||||
}
|
||||
showToast("chat deleted", fpath+" was deleted")
|
||||
return
|
||||
default:
|
||||
pages.RemovePage(RAGPage)
|
||||
@@ -383,114 +493,6 @@ func makeRAGTable(fileList []string) *tview.Flex {
|
||||
return ragflex
|
||||
}
|
||||
|
||||
func makeLoadedRAGTable(fileList []string) *tview.Flex {
|
||||
actions := []string{"delete"}
|
||||
rows, cols := len(fileList), len(actions)+1
|
||||
// Add 1 extra row for the "exit" option at the top
|
||||
fileTable := tview.NewTable().
|
||||
SetBorders(true)
|
||||
longStatusView := tview.NewTextView()
|
||||
longStatusView.SetText("Loaded RAG files list")
|
||||
longStatusView.SetBorder(true).SetTitle("status")
|
||||
longStatusView.SetChangedFunc(func() {
|
||||
app.Draw()
|
||||
})
|
||||
ragflex := tview.NewFlex().SetDirection(tview.FlexRow).
|
||||
AddItem(longStatusView, 0, 10, false).
|
||||
AddItem(fileTable, 0, 60, true)
|
||||
// Add the exit option as the first row (row 0)
|
||||
fileTable.SetCell(0, 0,
|
||||
tview.NewTableCell("Exit Loaded Files manager").
|
||||
SetTextColor(tcell.ColorWhite).
|
||||
SetAlign(tview.AlignCenter).
|
||||
SetSelectable(false))
|
||||
fileTable.SetCell(0, 1,
|
||||
tview.NewTableCell("(Close without action)").
|
||||
SetTextColor(tcell.ColorGray).
|
||||
SetAlign(tview.AlignCenter).
|
||||
SetSelectable(false))
|
||||
fileTable.SetCell(0, 2,
|
||||
tview.NewTableCell("exit").
|
||||
SetTextColor(tcell.ColorGray).
|
||||
SetAlign(tview.AlignCenter))
|
||||
// Add the file rows starting from row 1
|
||||
for r := 0; r < rows; r++ {
|
||||
for c := 0; c < cols; c++ {
|
||||
color := tcell.ColorWhite
|
||||
switch {
|
||||
case c < 1:
|
||||
fileTable.SetCell(r+1, c, // +1 to account for the exit row at index 0
|
||||
tview.NewTableCell(fileList[r]).
|
||||
SetTextColor(color).
|
||||
SetAlign(tview.AlignCenter).
|
||||
SetSelectable(false))
|
||||
case c == 1: // Action description column - not selectable
|
||||
fileTable.SetCell(r+1, c, // +1 to account for the exit row at index 0
|
||||
tview.NewTableCell("(Action)").
|
||||
SetTextColor(color).
|
||||
SetAlign(tview.AlignCenter).
|
||||
SetSelectable(false))
|
||||
default: // Action button column - selectable
|
||||
fileTable.SetCell(r+1, c, // +1 to account for the exit row at index 0
|
||||
tview.NewTableCell(actions[c-1]).
|
||||
SetTextColor(color).
|
||||
SetAlign(tview.AlignCenter))
|
||||
}
|
||||
}
|
||||
}
|
||||
fileTable.Select(0, 0).
|
||||
SetFixed(1, 1).
|
||||
SetSelectable(true, false).
|
||||
SetSelectedStyle(tcell.StyleDefault.Background(tcell.ColorGray).Foreground(tcell.ColorWhite)).
|
||||
SetDoneFunc(func(key tcell.Key) {
|
||||
if key == tcell.KeyEsc || key == tcell.KeyF1 || key == tcell.Key('x') || key == tcell.KeyCtrlX {
|
||||
pages.RemovePage(RAGLoadedPage)
|
||||
return
|
||||
}
|
||||
}).SetSelectedFunc(func(row int, column int) {
|
||||
// If user selects a non-actionable column (0 or 1), move to first action column (2)
|
||||
if column <= 1 {
|
||||
if fileTable.GetColumnCount() > 2 {
|
||||
fileTable.Select(row, 2) // Select first action column
|
||||
}
|
||||
return
|
||||
}
|
||||
tc := fileTable.GetCell(row, column)
|
||||
// Check if the selected row is the exit row (row 0) - do this first to avoid index issues
|
||||
if row == 0 {
|
||||
pages.RemovePage(RAGLoadedPage)
|
||||
return
|
||||
}
|
||||
// For file rows, get the filename (row index - 1 because of the exit row at index 0)
|
||||
fpath := fileList[row-1] // -1 to account for the exit row at index 0
|
||||
switch tc.Text {
|
||||
case "delete":
|
||||
if err := ragger.RemoveFile(fpath); err != nil {
|
||||
logger.Error("failed to delete file from RAG", "filename", fpath, "error", err)
|
||||
longStatusView.SetText(fmt.Sprintf("Error deleting file: %v", err))
|
||||
return
|
||||
}
|
||||
if err := notifyUser("RAG file deleted", fpath+" was deleted from RAG system"); err != nil {
|
||||
logger.Error("failed to send notification", "error", err)
|
||||
}
|
||||
longStatusView.SetText(fpath + " was deleted from RAG system")
|
||||
return
|
||||
default:
|
||||
pages.RemovePage(RAGLoadedPage)
|
||||
return
|
||||
}
|
||||
})
|
||||
// Add input capture to the flex container to handle 'x' key for closing
|
||||
ragflex.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyRune && event.Rune() == 'x' {
|
||||
pages.RemovePage(RAGLoadedPage)
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
return ragflex
|
||||
}
|
||||
|
||||
func makeAgentTable(agentList []string) *tview.Table {
|
||||
actions := []string{"filepath", "load"}
|
||||
rows, cols := len(agentList), len(actions)+1
|
||||
@@ -499,17 +501,17 @@ func makeAgentTable(agentList []string) *tview.Table {
|
||||
for r := 0; r < rows; r++ {
|
||||
for c := 0; c < cols; c++ {
|
||||
color := tcell.ColorWhite
|
||||
switch {
|
||||
case c < 1:
|
||||
switch c {
|
||||
case 0:
|
||||
chatActTable.SetCell(r, c,
|
||||
tview.NewTableCell(agentList[r]).
|
||||
SetTextColor(color).
|
||||
SetAlign(tview.AlignCenter).
|
||||
SetSelectable(false))
|
||||
case c == 1:
|
||||
case 1:
|
||||
if actions[c-1] == "filepath" {
|
||||
cc, ok := sysMap[agentList[r]]
|
||||
if !ok {
|
||||
cc := GetCardByRole(agentList[r])
|
||||
if cc == nil {
|
||||
continue
|
||||
}
|
||||
chatActTable.SetCell(r, c,
|
||||
@@ -533,7 +535,7 @@ func makeAgentTable(agentList []string) *tview.Table {
|
||||
}
|
||||
chatActTable.Select(0, 0).
|
||||
SetFixed(1, 1).
|
||||
SetSelectable(true, false).
|
||||
SetSelectable(true, true).
|
||||
SetSelectedStyle(tcell.StyleDefault.Background(tcell.ColorGray).Foreground(tcell.ColorWhite)).
|
||||
SetDoneFunc(func(key tcell.Key) {
|
||||
if key == tcell.KeyEsc || key == tcell.KeyF1 || key == tcell.Key('x') {
|
||||
@@ -549,6 +551,8 @@ func makeAgentTable(agentList []string) *tview.Table {
|
||||
return
|
||||
}
|
||||
tc := chatActTable.GetCell(row, column)
|
||||
tc.SetTextColor(tcell.ColorRed)
|
||||
chatActTable.SetSelectable(false, false)
|
||||
selected := agentList[row]
|
||||
// notification := fmt.Sprintf("chat: %s; action: %s", selectedChat, tc.Text)
|
||||
switch tc.Text {
|
||||
@@ -580,9 +584,7 @@ func makeAgentTable(agentList []string) *tview.Table {
|
||||
if err := store.RemoveChat(sc.ID); err != nil {
|
||||
logger.Error("failed to remove chat from db", "chat_id", sc.ID, "chat_name", sc.Name)
|
||||
}
|
||||
if err := notifyUser("chat deleted", selected+" was deleted"); err != nil {
|
||||
logger.Error("failed to send notification", "error", err)
|
||||
}
|
||||
showToast("chat deleted", selected+" was deleted")
|
||||
pages.RemovePage(agentPage)
|
||||
return
|
||||
default:
|
||||
@@ -630,7 +632,7 @@ func makeCodeBlockTable(codeBlocks []string) *tview.Table {
|
||||
}
|
||||
table.Select(0, 0).
|
||||
SetFixed(1, 1).
|
||||
SetSelectable(true, false).
|
||||
SetSelectable(true, true).
|
||||
SetSelectedStyle(tcell.StyleDefault.Background(tcell.ColorGray).Foreground(tcell.ColorWhite)).
|
||||
SetDoneFunc(func(key tcell.Key) {
|
||||
if key == tcell.KeyEsc || key == tcell.KeyF1 || key == tcell.Key('x') {
|
||||
@@ -646,18 +648,16 @@ func makeCodeBlockTable(codeBlocks []string) *tview.Table {
|
||||
return
|
||||
}
|
||||
tc := table.GetCell(row, column)
|
||||
tc.SetTextColor(tcell.ColorRed)
|
||||
table.SetSelectable(false, false)
|
||||
selected := codeBlocks[row]
|
||||
// notification := fmt.Sprintf("chat: %s; action: %s", selectedChat, tc.Text)
|
||||
switch tc.Text {
|
||||
case "copy":
|
||||
if err := copyToClipboard(selected); err != nil {
|
||||
if err := notifyUser("error", err.Error()); err != nil {
|
||||
logger.Error("failed to send notification", "error", err)
|
||||
}
|
||||
}
|
||||
if err := notifyUser("copied", selected); err != nil {
|
||||
logger.Error("failed to send notification", "error", err)
|
||||
showToast("error", err.Error())
|
||||
}
|
||||
showToast("copied", selected)
|
||||
pages.RemovePage(codeBlockPage)
|
||||
app.SetFocus(textArea)
|
||||
return
|
||||
@@ -702,7 +702,7 @@ func makeImportChatTable(filenames []string) *tview.Table {
|
||||
}
|
||||
chatActTable.Select(0, 0).
|
||||
SetFixed(1, 1).
|
||||
SetSelectable(true, false).
|
||||
SetSelectable(true, true).
|
||||
SetSelectedStyle(tcell.StyleDefault.Background(tcell.ColorGray).Foreground(tcell.ColorWhite)).
|
||||
SetDoneFunc(func(key tcell.Key) {
|
||||
if key == tcell.KeyEsc || key == tcell.KeyF1 || key == tcell.Key('x') {
|
||||
@@ -718,6 +718,8 @@ func makeImportChatTable(filenames []string) *tview.Table {
|
||||
return
|
||||
}
|
||||
tc := chatActTable.GetCell(row, column)
|
||||
tc.SetTextColor(tcell.ColorRed)
|
||||
chatActTable.SetSelectable(false, false)
|
||||
selected := filenames[row]
|
||||
// notification := fmt.Sprintf("chat: %s; action: %s", selectedChat, tc.Text)
|
||||
switch tc.Text {
|
||||
@@ -748,9 +750,7 @@ func makeImportChatTable(filenames []string) *tview.Table {
|
||||
if err := store.RemoveChat(sc.ID); err != nil {
|
||||
logger.Error("failed to remove chat from db", "chat_id", sc.ID, "chat_name", sc.Name)
|
||||
}
|
||||
if err := notifyUser("chat deleted", selected+" was deleted"); err != nil {
|
||||
logger.Error("failed to send notification", "error", err)
|
||||
}
|
||||
showToast("chat deleted", selected+" was deleted")
|
||||
pages.RemovePage(historyPage)
|
||||
return
|
||||
default:
|
||||
@@ -792,6 +792,7 @@ func makeFilePicker() *tview.Flex {
|
||||
// --- NEW: search state ---
|
||||
searching := false
|
||||
searchQuery := ""
|
||||
searchInputMode := false
|
||||
// Helper function to check if a file has an allowed extension from config
|
||||
hasAllowedExtension := func(filename string) bool {
|
||||
if cfg.FilePickerExts == "" {
|
||||
@@ -820,7 +821,9 @@ func makeFilePicker() *tview.Flex {
|
||||
}
|
||||
// Create UI elements
|
||||
listView := tview.NewList()
|
||||
listView.SetBorder(true).SetTitle("Files & Directories").SetTitleAlign(tview.AlignLeft)
|
||||
listView.SetBorder(true).
|
||||
SetTitle("Files & Directories [s: set FilePickerDir]. Current base dir: " + cfg.FilePickerDir).
|
||||
SetTitleAlign(tview.AlignLeft)
|
||||
// Status view for selected file information
|
||||
statusView := tview.NewTextView()
|
||||
statusView.SetBorder(true).SetTitle("Selected File").SetTitleAlign(tview.AlignLeft)
|
||||
@@ -982,6 +985,7 @@ func makeFilePicker() *tview.Flex {
|
||||
case tcell.KeyEsc:
|
||||
// Exit search, clear filter
|
||||
searching = false
|
||||
searchInputMode = false
|
||||
searchQuery = ""
|
||||
refreshList(currentDisplayDir, "")
|
||||
return nil
|
||||
@@ -991,16 +995,80 @@ func makeFilePicker() *tview.Flex {
|
||||
refreshList(currentDisplayDir, searchQuery)
|
||||
}
|
||||
return nil
|
||||
case tcell.KeyRune:
|
||||
r := event.Rune()
|
||||
if r != 0 {
|
||||
searchQuery += string(r)
|
||||
refreshList(currentDisplayDir, searchQuery)
|
||||
case tcell.KeyEnter:
|
||||
// Exit search input mode and let normal processing handle selection
|
||||
searchInputMode = false
|
||||
// Get the currently highlighted item in the list
|
||||
itemIndex := listView.GetCurrentItem()
|
||||
if itemIndex >= 0 && itemIndex < listView.GetItemCount() {
|
||||
itemText, _ := listView.GetItemText(itemIndex)
|
||||
// Check for the exit option first
|
||||
if strings.HasPrefix(itemText, "Exit file picker") {
|
||||
pages.RemovePage(filePickerPage)
|
||||
return nil
|
||||
}
|
||||
// Extract the actual filename/directory name by removing the type info
|
||||
actualItemName := itemText
|
||||
if bracketPos := strings.Index(itemText, " ["); bracketPos != -1 {
|
||||
actualItemName = itemText[:bracketPos]
|
||||
}
|
||||
// Check if it's a directory (ends with /)
|
||||
if strings.HasSuffix(actualItemName, "/") {
|
||||
var targetDir string
|
||||
if strings.HasPrefix(actualItemName, "../") {
|
||||
// Parent directory
|
||||
targetDir = path.Dir(currentDisplayDir)
|
||||
if targetDir == currentDisplayDir && currentDisplayDir == "/" {
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
// Regular subdirectory
|
||||
dirName := strings.TrimSuffix(actualItemName, "/")
|
||||
targetDir = path.Join(currentDisplayDir, dirName)
|
||||
}
|
||||
// Navigate – clear search
|
||||
if cfg.ImagePreview && imgPreview != nil {
|
||||
imgPreview.SetImage(nil)
|
||||
}
|
||||
searching = false
|
||||
searchInputMode = false
|
||||
searchQuery = ""
|
||||
refreshList(targetDir, "")
|
||||
dirStack = append(dirStack, targetDir)
|
||||
currentStackPos = len(dirStack) - 1
|
||||
statusView.SetText("Current: " + targetDir)
|
||||
return nil
|
||||
} else {
|
||||
// It's a file
|
||||
filePath := path.Join(currentDisplayDir, actualItemName)
|
||||
if info, err := os.Stat(filePath); err == nil && !info.IsDir() {
|
||||
if isImageFile(actualItemName) {
|
||||
SetImageAttachment(filePath)
|
||||
statusView.SetText("Image attached: " + filePath + " (will be sent with next message)")
|
||||
pages.RemovePage(filePickerPage)
|
||||
} else {
|
||||
textArea.SetText(filePath, true)
|
||||
app.SetFocus(textArea)
|
||||
pages.RemovePage(filePickerPage)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
case tcell.KeyRune:
|
||||
r := event.Rune()
|
||||
if searchInputMode && r != 0 {
|
||||
searchQuery += string(r)
|
||||
refreshList(currentDisplayDir, searchQuery)
|
||||
return nil
|
||||
}
|
||||
// If not in search input mode, pass through for navigation
|
||||
return event
|
||||
default:
|
||||
// Pass all other keys (arrows, Enter, etc.) to normal processing
|
||||
// This allows selecting items while still in search mode
|
||||
// Exit search input mode but keep filter active for navigation
|
||||
searchInputMode = false
|
||||
// Pass all other keys (arrows, etc.) to normal processing
|
||||
return event
|
||||
}
|
||||
}
|
||||
@@ -1028,10 +1096,19 @@ func makeFilePicker() *tview.Flex {
|
||||
if event.Rune() == '/' {
|
||||
// Enter search mode
|
||||
searching = true
|
||||
searchInputMode = true
|
||||
searchQuery = ""
|
||||
refreshList(currentDisplayDir, "")
|
||||
return nil
|
||||
}
|
||||
if event.Rune() == 's' {
|
||||
// Set FilePickerDir to current directory
|
||||
// Get the actual directory path
|
||||
cfg.FilePickerDir = currentDisplayDir
|
||||
listView.SetTitle("Files & Directories [s: set FilePickerDir]. Current base dir: " + cfg.FilePickerDir)
|
||||
// pages.RemovePage(filePickerPage)
|
||||
return nil
|
||||
}
|
||||
case tcell.KeyEnter:
|
||||
// Get the currently highlighted item in the list
|
||||
itemIndex := listView.GetCurrentItem()
|
||||
|
||||
653
tools_playwright.go
Normal file
653
tools_playwright.go
Normal file
@@ -0,0 +1,653 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gf-lt/models"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/playwright-community/playwright-go"
|
||||
)
|
||||
|
||||
var browserToolSysMsg = `
|
||||
Additional browser automation tools (Playwright):
|
||||
[
|
||||
{
|
||||
"name": "pw_start",
|
||||
"args": [],
|
||||
"when_to_use": "start a browser instance before doing any browser automation. Must be called first."
|
||||
},
|
||||
{
|
||||
"name": "pw_stop",
|
||||
"args": [],
|
||||
"when_to_use": "stop the browser instance when done with automation."
|
||||
},
|
||||
{
|
||||
"name": "pw_is_running",
|
||||
"args": [],
|
||||
"when_to_use": "check if browser is currently running."
|
||||
},
|
||||
{
|
||||
"name": "pw_navigate",
|
||||
"args": ["url"],
|
||||
"when_to_use": "open a specific URL in the web browser."
|
||||
},
|
||||
{
|
||||
"name": "pw_click",
|
||||
"args": ["selector", "index"],
|
||||
"when_to_use": "click on an element on the current webpage. Use 'index' for multiple matches (default 0)."
|
||||
},
|
||||
{
|
||||
"name": "pw_fill",
|
||||
"args": ["selector", "text", "index"],
|
||||
"when_to_use": "type text into an input field. Use 'index' for multiple matches (default 0)."
|
||||
},
|
||||
{
|
||||
"name": "pw_extract_text",
|
||||
"args": ["selector"],
|
||||
"when_to_use": "extract text content from the page or specific elements. Use selector 'body' for all page text."
|
||||
},
|
||||
{
|
||||
"name": "pw_screenshot",
|
||||
"args": ["selector", "full_page"],
|
||||
"when_to_use": "take a screenshot of the page or a specific element. Returns a file path to the image. Use to verify actions or inspect visual state."
|
||||
},
|
||||
{
|
||||
"name": "pw_screenshot_and_view",
|
||||
"args": ["selector", "full_page"],
|
||||
"when_to_use": "take a screenshot and return the image for viewing. Use to visually verify page state."
|
||||
},
|
||||
{
|
||||
"name": "pw_wait_for_selector",
|
||||
"args": ["selector", "timeout"],
|
||||
"when_to_use": "wait for an element to appear on the page before proceeding with further actions."
|
||||
},
|
||||
{
|
||||
"name": "pw_drag",
|
||||
"args": ["x1", "y1", "x2", "y2"],
|
||||
"when_to_use": "drag the mouse from point (x1,y1) to (x2,y2)."
|
||||
},
|
||||
{
|
||||
"name": "pw_click_at",
|
||||
"args": ["x", "y"],
|
||||
"when_to_use": "click at specific X,Y coordinates on the page. Use when you know the exact position."
|
||||
},
|
||||
{
|
||||
"name": "pw_get_html",
|
||||
"args": ["selector"],
|
||||
"when_to_use": "get the HTML content of the page or a specific element. Use to understand page structure or extract raw HTML."
|
||||
},
|
||||
{
|
||||
"name": "pw_get_dom",
|
||||
"args": ["selector"],
|
||||
"when_to_use": "get a structured DOM representation with tag, attributes, text, and children. Use to inspect element hierarchy and properties."
|
||||
},
|
||||
{
|
||||
"name": "pw_search_elements",
|
||||
"args": ["text", "selector"],
|
||||
"when_to_use": "search for elements by text content or CSS selector. Returns matching elements with their tags, text, and HTML."
|
||||
}
|
||||
]
|
||||
`
|
||||
|
||||
var (
|
||||
pw *playwright.Playwright
|
||||
browser playwright.Browser
|
||||
browserStarted bool
|
||||
browserStartMu sync.Mutex
|
||||
page playwright.Page
|
||||
)
|
||||
|
||||
func pwShutDown() error {
|
||||
if pw == nil {
|
||||
return nil
|
||||
}
|
||||
pwStop(nil)
|
||||
return pw.Stop()
|
||||
}
|
||||
|
||||
func installPW() error {
|
||||
err := playwright.Install(&playwright.RunOptions{Verbose: false})
|
||||
if err != nil {
|
||||
logger.Warn("playwright not available", "error", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkPlaywright() error {
|
||||
var err error
|
||||
pw, err = playwright.Run()
|
||||
if err != nil {
|
||||
logger.Warn("playwright not available", "error", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func pwStart(args map[string]string) []byte {
|
||||
browserStartMu.Lock()
|
||||
defer browserStartMu.Unlock()
|
||||
if browserStarted {
|
||||
return []byte(`{"error": "Browser already started"}`)
|
||||
}
|
||||
var err error
|
||||
browser, err = pw.Chromium.Launch(playwright.BrowserTypeLaunchOptions{
|
||||
Headless: playwright.Bool(!cfg.PlaywrightDebug),
|
||||
})
|
||||
if err != nil {
|
||||
return []byte(fmt.Sprintf(`{"error": "failed to launch browser: %s"}`, err.Error()))
|
||||
}
|
||||
page, err = browser.NewPage()
|
||||
if err != nil {
|
||||
browser.Close()
|
||||
return []byte(fmt.Sprintf(`{"error": "failed to create page: %s"}`, err.Error()))
|
||||
}
|
||||
browserStarted = true
|
||||
return []byte(`{"success": true, "message": "Browser started"}`)
|
||||
}
|
||||
|
||||
func pwStop(args map[string]string) []byte {
|
||||
browserStartMu.Lock()
|
||||
defer browserStartMu.Unlock()
|
||||
if !browserStarted {
|
||||
return []byte(`{"success": true, "message": "Browser was not running"}`)
|
||||
}
|
||||
if page != nil {
|
||||
page.Close()
|
||||
page = nil
|
||||
}
|
||||
if browser != nil {
|
||||
browser.Close()
|
||||
browser = nil
|
||||
}
|
||||
browserStarted = false
|
||||
return []byte(`{"success": true, "message": "Browser stopped"}`)
|
||||
}
|
||||
|
||||
func pwIsRunning(args map[string]string) []byte {
|
||||
if browserStarted {
|
||||
return []byte(`{"running": true, "message": "Browser is running"}`)
|
||||
}
|
||||
return []byte(`{"running": false, "message": "Browser is not running"}`)
|
||||
}
|
||||
|
||||
func pwNavigate(args map[string]string) []byte {
|
||||
url, ok := args["url"]
|
||||
if !ok || url == "" {
|
||||
return []byte(`{"error": "url not provided"}`)
|
||||
}
|
||||
if !browserStarted || page == nil {
|
||||
return []byte(`{"error": "Browser not started. Call pw_start first."}`)
|
||||
}
|
||||
_, err := page.Goto(url)
|
||||
if err != nil {
|
||||
return []byte(fmt.Sprintf(`{"error": "failed to navigate: %s"}`, err.Error()))
|
||||
}
|
||||
title, _ := page.Title()
|
||||
pageURL := page.URL()
|
||||
return []byte(fmt.Sprintf(`{"success": true, "title": "%s", "url": "%s"}`, title, pageURL))
|
||||
}
|
||||
|
||||
func pwClick(args map[string]string) []byte {
|
||||
selector, ok := args["selector"]
|
||||
if !ok || selector == "" {
|
||||
return []byte(`{"error": "selector not provided"}`)
|
||||
}
|
||||
if !browserStarted || page == nil {
|
||||
return []byte(`{"error": "Browser not started. Call pw_start first."}`)
|
||||
}
|
||||
index := 0
|
||||
if args["index"] != "" {
|
||||
if i, err := strconv.Atoi(args["index"]); err != nil {
|
||||
logger.Warn("failed to parse index", "value", args["index"], "error", err)
|
||||
} else {
|
||||
index = i
|
||||
}
|
||||
}
|
||||
locator := page.Locator(selector)
|
||||
count, err := locator.Count()
|
||||
if err != nil {
|
||||
return []byte(fmt.Sprintf(`{"error": "failed to find elements: %s"}`, err.Error()))
|
||||
}
|
||||
if index >= count {
|
||||
return []byte(fmt.Sprintf(`{"error": "Element not found at index %d (found %d elements)"}`, index, count))
|
||||
}
|
||||
err = locator.Nth(index).Click()
|
||||
if err != nil {
|
||||
return []byte(fmt.Sprintf(`{"error": "failed to click: %s"}`, err.Error()))
|
||||
}
|
||||
return []byte(`{"success": true, "message": "Clicked element"}`)
|
||||
}
|
||||
|
||||
func pwFill(args map[string]string) []byte {
|
||||
selector, ok := args["selector"]
|
||||
if !ok || selector == "" {
|
||||
return []byte(`{"error": "selector not provided"}`)
|
||||
}
|
||||
text := args["text"]
|
||||
if text == "" {
|
||||
text = ""
|
||||
}
|
||||
if !browserStarted || page == nil {
|
||||
return []byte(`{"error": "Browser not started. Call pw_start first."}`)
|
||||
}
|
||||
index := 0
|
||||
if args["index"] != "" {
|
||||
if i, err := strconv.Atoi(args["index"]); err != nil {
|
||||
logger.Warn("failed to parse index", "value", args["index"], "error", err)
|
||||
} else {
|
||||
index = i
|
||||
}
|
||||
}
|
||||
locator := page.Locator(selector)
|
||||
count, err := locator.Count()
|
||||
if err != nil {
|
||||
return []byte(fmt.Sprintf(`{"error": "failed to find elements: %s"}`, err.Error()))
|
||||
}
|
||||
if index >= count {
|
||||
return []byte(fmt.Sprintf(`{"error": "Element not found at index %d"}`, index))
|
||||
}
|
||||
err = locator.Nth(index).Fill(text)
|
||||
if err != nil {
|
||||
return []byte(fmt.Sprintf(`{"error": "failed to fill: %s"}`, err.Error()))
|
||||
}
|
||||
return []byte(`{"success": true, "message": "Filled input"}`)
|
||||
}
|
||||
|
||||
func pwExtractText(args map[string]string) []byte {
|
||||
selector := args["selector"]
|
||||
if selector == "" {
|
||||
selector = "body"
|
||||
}
|
||||
if !browserStarted || page == nil {
|
||||
return []byte(`{"error": "Browser not started. Call pw_start first."}`)
|
||||
}
|
||||
locator := page.Locator(selector)
|
||||
count, err := locator.Count()
|
||||
if err != nil {
|
||||
return []byte(fmt.Sprintf(`{"error": "failed to find elements: %s"}`, err.Error()))
|
||||
}
|
||||
if count == 0 {
|
||||
return []byte(`{"error": "No elements found"}`)
|
||||
}
|
||||
if selector == "body" {
|
||||
text, err := page.Locator("body").TextContent()
|
||||
if err != nil {
|
||||
return []byte(fmt.Sprintf(`{"error": "failed to get text: %s"}`, err.Error()))
|
||||
}
|
||||
return []byte(fmt.Sprintf(`{"text": "%s"}`, text))
|
||||
}
|
||||
var texts []string
|
||||
for i := 0; i < count; i++ {
|
||||
text, err := locator.Nth(i).TextContent()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
texts = append(texts, text)
|
||||
}
|
||||
return []byte(fmt.Sprintf(`{"text": "%s"}`, joinLines(texts)))
|
||||
}
|
||||
|
||||
func joinLines(lines []string) string {
|
||||
var sb strings.Builder
|
||||
for i, line := range lines {
|
||||
if i > 0 {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
sb.WriteString(line)
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func pwScreenshot(args map[string]string) []byte {
|
||||
selector := args["selector"]
|
||||
fullPage := args["full_page"] == "true"
|
||||
if !browserStarted || page == nil {
|
||||
return []byte(`{"error": "Browser not started. Call pw_start first."}`)
|
||||
}
|
||||
path := fmt.Sprintf("/tmp/pw_screenshot_%d.png", os.Getpid())
|
||||
var err error
|
||||
if selector != "" && selector != "body" {
|
||||
locator := page.Locator(selector)
|
||||
_, err = locator.Screenshot(playwright.LocatorScreenshotOptions{
|
||||
Path: playwright.String(path),
|
||||
})
|
||||
} else {
|
||||
_, err = page.Screenshot(playwright.PageScreenshotOptions{
|
||||
Path: playwright.String(path),
|
||||
FullPage: playwright.Bool(fullPage),
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
return []byte(fmt.Sprintf(`{"error": "failed to take screenshot: %s"}`, err.Error()))
|
||||
}
|
||||
return []byte(fmt.Sprintf(`{"path": "%s"}`, path))
|
||||
}
|
||||
|
||||
func pwScreenshotAndView(args map[string]string) []byte {
|
||||
selector := args["selector"]
|
||||
fullPage := args["full_page"] == "true"
|
||||
if !browserStarted || page == nil {
|
||||
return []byte(`{"error": "Browser not started. Call pw_start first."}`)
|
||||
}
|
||||
path := fmt.Sprintf("/tmp/pw_screenshot_%d.png", os.Getpid())
|
||||
var err error
|
||||
if selector != "" && selector != "body" {
|
||||
locator := page.Locator(selector)
|
||||
_, err = locator.Screenshot(playwright.LocatorScreenshotOptions{
|
||||
Path: playwright.String(path),
|
||||
})
|
||||
} else {
|
||||
_, err = page.Screenshot(playwright.PageScreenshotOptions{
|
||||
Path: playwright.String(path),
|
||||
FullPage: playwright.Bool(fullPage),
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
return []byte(fmt.Sprintf(`{"error": "failed to take screenshot: %s"}`, err.Error()))
|
||||
}
|
||||
dataURL, err := models.CreateImageURLFromPath(path)
|
||||
if err != nil {
|
||||
return []byte(fmt.Sprintf(`{"error": "failed to create image URL: %s"}`, err.Error()))
|
||||
}
|
||||
resp := models.MultimodalToolResp{
|
||||
Type: "multimodal_content",
|
||||
Parts: []map[string]string{
|
||||
{"type": "text", "text": "Screenshot saved: " + path},
|
||||
{"type": "image_url", "url": dataURL},
|
||||
},
|
||||
}
|
||||
jsonResult, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
return []byte(fmt.Sprintf(`{"error": "failed to marshal result: %s"}`, err.Error()))
|
||||
}
|
||||
return jsonResult
|
||||
}
|
||||
|
||||
func pwWaitForSelector(args map[string]string) []byte {
|
||||
selector, ok := args["selector"]
|
||||
if !ok || selector == "" {
|
||||
return []byte(`{"error": "selector not provided"}`)
|
||||
}
|
||||
if !browserStarted || page == nil {
|
||||
return []byte(`{"error": "Browser not started. Call pw_start first."}`)
|
||||
}
|
||||
timeout := 30000
|
||||
if args["timeout"] != "" {
|
||||
if t, err := strconv.Atoi(args["timeout"]); err != nil {
|
||||
logger.Warn("failed to parse timeout", "value", args["timeout"], "error", err)
|
||||
} else {
|
||||
timeout = t
|
||||
}
|
||||
}
|
||||
locator := page.Locator(selector)
|
||||
err := locator.WaitFor(playwright.LocatorWaitForOptions{
|
||||
Timeout: playwright.Float(float64(timeout)),
|
||||
})
|
||||
if err != nil {
|
||||
return []byte(fmt.Sprintf(`{"error": "element not found: %s"}`, err.Error()))
|
||||
}
|
||||
return []byte(`{"success": true, "message": "Element found"}`)
|
||||
}
|
||||
|
||||
func pwDrag(args map[string]string) []byte {
|
||||
x1, ok := args["x1"]
|
||||
if !ok {
|
||||
return []byte(`{"error": "x1 not provided"}`)
|
||||
}
|
||||
y1, ok := args["y1"]
|
||||
if !ok {
|
||||
return []byte(`{"error": "y1 not provided"}`)
|
||||
}
|
||||
x2, ok := args["x2"]
|
||||
if !ok {
|
||||
return []byte(`{"error": "x2 not provided"}`)
|
||||
}
|
||||
y2, ok := args["y2"]
|
||||
if !ok {
|
||||
return []byte(`{"error": "y2 not provided"}`)
|
||||
}
|
||||
if !browserStarted || page == nil {
|
||||
return []byte(`{"error": "Browser not started. Call pw_start first."}`)
|
||||
}
|
||||
var fx1, fy1, fx2, fy2 float64
|
||||
if parsedX1, err := strconv.ParseFloat(x1, 64); err != nil {
|
||||
logger.Warn("failed to parse x1", "value", x1, "error", err)
|
||||
} else {
|
||||
fx1 = parsedX1
|
||||
}
|
||||
if parsedY1, err := strconv.ParseFloat(y1, 64); err != nil {
|
||||
logger.Warn("failed to parse y1", "value", y1, "error", err)
|
||||
} else {
|
||||
fy1 = parsedY1
|
||||
}
|
||||
if parsedX2, err := strconv.ParseFloat(x2, 64); err != nil {
|
||||
logger.Warn("failed to parse x2", "value", x2, "error", err)
|
||||
} else {
|
||||
fx2 = parsedX2
|
||||
}
|
||||
if parsedY2, err := strconv.ParseFloat(y2, 64); err != nil {
|
||||
logger.Warn("failed to parse y2", "value", y2, "error", err)
|
||||
} else {
|
||||
fy2 = parsedY2
|
||||
}
|
||||
mouse := page.Mouse()
|
||||
err := mouse.Move(fx1, fy1)
|
||||
if err != nil {
|
||||
return []byte(fmt.Sprintf(`{"error": "failed to move mouse: %s"}`, err.Error()))
|
||||
}
|
||||
err = mouse.Down()
|
||||
if err != nil {
|
||||
return []byte(fmt.Sprintf(`{"error": "failed to mouse down: %s"}`, err.Error()))
|
||||
}
|
||||
err = mouse.Move(fx2, fy2)
|
||||
if err != nil {
|
||||
return []byte(fmt.Sprintf(`{"error": "failed to move mouse: %s"}`, err.Error()))
|
||||
}
|
||||
err = mouse.Up()
|
||||
if err != nil {
|
||||
return []byte(fmt.Sprintf(`{"error": "failed to mouse up: %s"}`, err.Error()))
|
||||
}
|
||||
return []byte(fmt.Sprintf(`{"success": true, "message": "Dragged from (%s,%s) to (%s,%s)"}`, x1, y1, x2, y2))
|
||||
}
|
||||
|
||||
func pwClickAt(args map[string]string) []byte {
|
||||
x, ok := args["x"]
|
||||
if !ok {
|
||||
return []byte(`{"error": "x not provided"}`)
|
||||
}
|
||||
y, ok := args["y"]
|
||||
if !ok {
|
||||
return []byte(`{"error": "y not provided"}`)
|
||||
}
|
||||
if !browserStarted || page == nil {
|
||||
return []byte(`{"error": "Browser not started. Call pw_start first."}`)
|
||||
}
|
||||
fx, err := strconv.ParseFloat(x, 64)
|
||||
if err != nil {
|
||||
return []byte(fmt.Sprintf(`{"error": "failed to parse x: %s"}`, err.Error()))
|
||||
}
|
||||
fy, err := strconv.ParseFloat(y, 64)
|
||||
if err != nil {
|
||||
return []byte(fmt.Sprintf(`{"error": "failed to parse y: %s"}`, err.Error()))
|
||||
}
|
||||
mouse := page.Mouse()
|
||||
err = mouse.Click(fx, fy)
|
||||
if err != nil {
|
||||
return []byte(fmt.Sprintf(`{"error": "failed to click: %s"}`, err.Error()))
|
||||
}
|
||||
return []byte(fmt.Sprintf(`{"success": true, "message": "Clicked at (%s,%s)"}`, x, y))
|
||||
}
|
||||
|
||||
func pwGetHTML(args map[string]string) []byte {
|
||||
selector := args["selector"]
|
||||
if selector == "" {
|
||||
selector = "body"
|
||||
}
|
||||
if !browserStarted || page == nil {
|
||||
return []byte(`{"error": "Browser not started. Call pw_start first."}`)
|
||||
}
|
||||
locator := page.Locator(selector)
|
||||
count, err := locator.Count()
|
||||
if err != nil {
|
||||
return []byte(fmt.Sprintf(`{"error": "failed to find elements: %s"}`, err.Error()))
|
||||
}
|
||||
if count == 0 {
|
||||
return []byte(`{"error": "No elements found"}`)
|
||||
}
|
||||
html, err := locator.First().InnerHTML()
|
||||
if err != nil {
|
||||
return []byte(fmt.Sprintf(`{"error": "failed to get HTML: %s"}`, err.Error()))
|
||||
}
|
||||
return []byte(fmt.Sprintf(`{"html": %s}`, jsonString(html)))
|
||||
}
|
||||
|
||||
type DOMElement struct {
|
||||
Tag string `json:"tag,omitempty"`
|
||||
Attributes map[string]string `json:"attributes,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Children []DOMElement `json:"children,omitempty"`
|
||||
Selector string `json:"selector,omitempty"`
|
||||
InnerHTML string `json:"innerHTML,omitempty"`
|
||||
}
|
||||
|
||||
func buildDOMTree(locator playwright.Locator) ([]DOMElement, error) {
|
||||
var results []DOMElement
|
||||
count, err := locator.Count()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := 0; i < count; i++ {
|
||||
el := locator.Nth(i)
|
||||
dom, err := elementToDOM(el)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
results = append(results, dom)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func elementToDOM(el playwright.Locator) (DOMElement, error) {
|
||||
dom := DOMElement{}
|
||||
tag, err := el.Evaluate(`el => el.nodeName`, nil)
|
||||
if err == nil {
|
||||
dom.Tag = strings.ToLower(fmt.Sprintf("%v", tag))
|
||||
}
|
||||
attributes := make(map[string]string)
|
||||
attrs, err := el.Evaluate(`el => {
|
||||
let attrs = {};
|
||||
for (let i = 0; i < el.attributes.length; i++) {
|
||||
let attr = el.attributes[i];
|
||||
attrs[attr.name] = attr.value;
|
||||
}
|
||||
return attrs;
|
||||
}`, nil)
|
||||
if err == nil {
|
||||
if amap, ok := attrs.(map[string]any); ok {
|
||||
for k, v := range amap {
|
||||
if vs, ok := v.(string); ok {
|
||||
attributes[k] = vs
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(attributes) > 0 {
|
||||
dom.Attributes = attributes
|
||||
}
|
||||
text, err := el.TextContent()
|
||||
if err == nil && text != "" {
|
||||
dom.Text = text
|
||||
}
|
||||
innerHTML, err := el.InnerHTML()
|
||||
if err == nil && innerHTML != "" {
|
||||
dom.InnerHTML = innerHTML
|
||||
}
|
||||
childCount, _ := el.Count()
|
||||
if childCount > 0 {
|
||||
childrenLocator := el.Locator("*")
|
||||
children, err := buildDOMTree(childrenLocator)
|
||||
if err == nil && len(children) > 0 {
|
||||
dom.Children = children
|
||||
}
|
||||
}
|
||||
return dom, nil
|
||||
}
|
||||
|
||||
func pwGetDOM(args map[string]string) []byte {
|
||||
selector := args["selector"]
|
||||
if selector == "" {
|
||||
selector = "body"
|
||||
}
|
||||
if !browserStarted || page == nil {
|
||||
return []byte(`{"error": "Browser not started. Call pw_start first."}`)
|
||||
}
|
||||
locator := page.Locator(selector)
|
||||
count, err := locator.Count()
|
||||
if err != nil {
|
||||
return []byte(fmt.Sprintf(`{"error": "failed to find elements: %s"}`, err.Error()))
|
||||
}
|
||||
if count == 0 {
|
||||
return []byte(`{"error": "No elements found"}`)
|
||||
}
|
||||
dom, err := elementToDOM(locator.First())
|
||||
if err != nil {
|
||||
return []byte(fmt.Sprintf(`{"error": "failed to get DOM: %s"}`, err.Error()))
|
||||
}
|
||||
data, err := json.Marshal(dom)
|
||||
if err != nil {
|
||||
return []byte(fmt.Sprintf(`{"error": "failed to marshal DOM: %s"}`, err.Error()))
|
||||
}
|
||||
return []byte(fmt.Sprintf(`{"dom": %s}`, string(data)))
|
||||
}
|
||||
|
||||
func pwSearchElements(args map[string]string) []byte {
|
||||
text := args["text"]
|
||||
selector := args["selector"]
|
||||
if text == "" && selector == "" {
|
||||
return []byte(`{"error": "text or selector not provided"}`)
|
||||
}
|
||||
if !browserStarted || page == nil {
|
||||
return []byte(`{"error": "Browser not started. Call pw_start first."}`)
|
||||
}
|
||||
var locator playwright.Locator
|
||||
if text != "" {
|
||||
locator = page.GetByText(text)
|
||||
} else {
|
||||
locator = page.Locator(selector)
|
||||
}
|
||||
count, err := locator.Count()
|
||||
if err != nil {
|
||||
return []byte(fmt.Sprintf(`{"error": "failed to search elements: %s"}`, err.Error()))
|
||||
}
|
||||
if count == 0 {
|
||||
return []byte(`{"elements": []}`)
|
||||
}
|
||||
var results []map[string]string
|
||||
for i := 0; i < count; i++ {
|
||||
el := locator.Nth(i)
|
||||
tag, _ := el.Evaluate(`el => el.nodeName`, nil)
|
||||
text, _ := el.TextContent()
|
||||
html, _ := el.InnerHTML()
|
||||
results = append(results, map[string]string{
|
||||
"index": strconv.Itoa(i),
|
||||
"tag": strings.ToLower(fmt.Sprintf("%v", tag)),
|
||||
"text": text,
|
||||
"html": html,
|
||||
})
|
||||
}
|
||||
data, err := json.Marshal(results)
|
||||
if err != nil {
|
||||
return []byte(fmt.Sprintf(`{"error": "failed to marshal results: %s"}`, err.Error()))
|
||||
}
|
||||
return []byte(fmt.Sprintf(`{"elements": %s}`, string(data)))
|
||||
}
|
||||
|
||||
func jsonString(s string) string {
|
||||
b, _ := json.Marshal(s)
|
||||
return string(b)
|
||||
}
|
||||
Reference in New Issue
Block a user