Compare commits
29 Commits
feat/reaso
...
feat/ragto
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
8
Makefile
8
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 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,6 +15,12 @@ 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
|
||||
|
||||
lint: ## Run linters. Use make install-linters first.
|
||||
golangci-lint run -c .golangci.yml ./...
|
||||
|
||||
|
||||
@@ -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
|
||||

|
||||
|
||||
142
bot.go
142
bot.go
@@ -46,6 +46,7 @@ var (
|
||||
ragger *rag.RAG
|
||||
chunkParser ChunkParser
|
||||
lastToolCall *models.FuncCall
|
||||
lastRespStats *models.ResponseStats
|
||||
//nolint:unused // TTS_ENABLED conditionally uses this
|
||||
orator Orator
|
||||
asr STT
|
||||
@@ -410,14 +411,21 @@ func fetchLCPModelsWithLoadStatus() ([]string, error) {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]string, 0, len(models.Data))
|
||||
for _, m := range models.Data {
|
||||
li := 0 // loaded index
|
||||
for i, m := range models.Data {
|
||||
modelName := m.ID
|
||||
if m.Status.Value == "loaded" {
|
||||
modelName = modelName + " (loaded)"
|
||||
modelName = "(loaded) " + modelName
|
||||
li = i
|
||||
}
|
||||
result = append(result, modelName)
|
||||
}
|
||||
return result, nil
|
||||
if li == 0 {
|
||||
return result, nil // no loaded models
|
||||
}
|
||||
loadedModel := result[li]
|
||||
result = append(result[:li], result[li+1:]...)
|
||||
return slices.Concat([]string{loadedModel}, result), nil
|
||||
}
|
||||
|
||||
// fetchLCPModelsWithStatus returns the full LCPModels struct including status information.
|
||||
@@ -484,30 +492,28 @@ func monitorModelLoad(modelID string) {
|
||||
// extractDetailedErrorFromBytes extracts detailed error information from response body bytes
|
||||
func extractDetailedErrorFromBytes(body []byte, statusCode int) string {
|
||||
// Try to parse as JSON to extract detailed error information
|
||||
var errorResponse map[string]interface{}
|
||||
var errorResponse map[string]any
|
||||
if err := json.Unmarshal(body, &errorResponse); err == nil {
|
||||
// Check if it's an error response with detailed information
|
||||
if errorData, ok := errorResponse["error"]; ok {
|
||||
if errorMap, ok := errorData.(map[string]interface{}); ok {
|
||||
if errorMap, ok := errorData.(map[string]any); ok {
|
||||
var errorMsg string
|
||||
if msg, ok := errorMap["message"]; ok {
|
||||
errorMsg = fmt.Sprintf("%v", msg)
|
||||
}
|
||||
|
||||
var details []string
|
||||
if code, ok := errorMap["code"]; ok {
|
||||
details = append(details, fmt.Sprintf("Code: %v", code))
|
||||
}
|
||||
|
||||
if metadata, ok := errorMap["metadata"]; ok {
|
||||
// Handle metadata which might contain raw error details
|
||||
if metadataMap, ok := metadata.(map[string]interface{}); ok {
|
||||
if metadataMap, ok := metadata.(map[string]any); ok {
|
||||
if raw, ok := metadataMap["raw"]; ok {
|
||||
// Parse the raw error string if it's JSON
|
||||
var rawError map[string]interface{}
|
||||
var rawError map[string]any
|
||||
if rawStr, ok := raw.(string); ok && json.Unmarshal([]byte(rawStr), &rawError) == nil {
|
||||
if rawErrorData, ok := rawError["error"]; ok {
|
||||
if rawErrorMap, ok := rawErrorData.(map[string]interface{}); ok {
|
||||
if rawErrorMap, ok := rawErrorData.(map[string]any); ok {
|
||||
if rawMsg, ok := rawErrorMap["message"]; ok {
|
||||
return fmt.Sprintf("API Error: %s", rawMsg)
|
||||
}
|
||||
@@ -518,20 +524,30 @@ func extractDetailedErrorFromBytes(body []byte, statusCode int) string {
|
||||
}
|
||||
details = append(details, fmt.Sprintf("Metadata: %v", metadata))
|
||||
}
|
||||
|
||||
if len(details) > 0 {
|
||||
return fmt.Sprintf("API Error: %s (%s)", errorMsg, strings.Join(details, ", "))
|
||||
}
|
||||
|
||||
return "API Error: " + errorMsg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If not a structured error response, return the raw body with status
|
||||
return fmt.Sprintf("HTTP Status: %d, Response Body: %s", statusCode, string(body))
|
||||
}
|
||||
|
||||
func finalizeRespStats(tokenCount int, startTime time.Time) {
|
||||
duration := time.Since(startTime).Seconds()
|
||||
var tps float64
|
||||
if duration > 0 {
|
||||
tps = float64(tokenCount) / duration
|
||||
}
|
||||
lastRespStats = &models.ResponseStats{
|
||||
Tokens: tokenCount,
|
||||
Duration: duration,
|
||||
TokensPerSec: tps,
|
||||
}
|
||||
}
|
||||
|
||||
// sendMsgToLLM expects streaming resp
|
||||
func sendMsgToLLM(body io.Reader) {
|
||||
choseChunkParser()
|
||||
@@ -560,7 +576,6 @@ func sendMsgToLLM(body io.Reader) {
|
||||
streamDone <- true
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the initial response is an error before starting to stream
|
||||
if resp.StatusCode >= 400 {
|
||||
// Read the response body to get detailed error information
|
||||
@@ -575,7 +590,6 @@ func sendMsgToLLM(body io.Reader) {
|
||||
streamDone <- true
|
||||
return
|
||||
}
|
||||
|
||||
// Parse the error response for detailed information
|
||||
detailedError := extractDetailedErrorFromBytes(bodyBytes, resp.StatusCode)
|
||||
logger.Error("API returned error status", "status_code", resp.StatusCode, "detailed_error", detailedError)
|
||||
@@ -586,12 +600,17 @@ func sendMsgToLLM(body io.Reader) {
|
||||
streamDone <- true
|
||||
return
|
||||
}
|
||||
|
||||
//
|
||||
defer resp.Body.Close()
|
||||
reader := bufio.NewReader(resp.Body)
|
||||
counter := uint32(0)
|
||||
tokenCount := 0
|
||||
startTime := time.Now()
|
||||
hasReasoning := false
|
||||
reasoningSent := false
|
||||
defer func() {
|
||||
finalizeRespStats(tokenCount, startTime)
|
||||
}()
|
||||
for {
|
||||
var (
|
||||
answerText string
|
||||
@@ -667,11 +686,13 @@ func sendMsgToLLM(body io.Reader) {
|
||||
// Close the thinking block if we were streaming reasoning and haven't closed it yet
|
||||
if hasReasoning && !reasoningSent {
|
||||
chunkChan <- "</think>"
|
||||
tokenCount++
|
||||
}
|
||||
if chunk.Chunk != "" {
|
||||
logger.Warn("text inside of finish llmchunk", "chunk", chunk, "counter", counter)
|
||||
answerText = strings.ReplaceAll(chunk.Chunk, "\n\n", "\n")
|
||||
chunkChan <- answerText
|
||||
tokenCount++
|
||||
}
|
||||
streamDone <- true
|
||||
break
|
||||
@@ -684,22 +705,23 @@ func sendMsgToLLM(body io.Reader) {
|
||||
if !hasReasoning {
|
||||
// First reasoning chunk - send opening tag
|
||||
chunkChan <- "<think>"
|
||||
tokenCount++
|
||||
hasReasoning = true
|
||||
}
|
||||
// Stream reasoning content immediately
|
||||
answerText = strings.ReplaceAll(chunk.Reasoning, "\n\n", "\n")
|
||||
if answerText != "" {
|
||||
chunkChan <- answerText
|
||||
tokenCount++
|
||||
}
|
||||
}
|
||||
|
||||
// When we get content and have been streaming reasoning, close the thinking block
|
||||
if chunk.Chunk != "" && hasReasoning && !reasoningSent {
|
||||
// Close the thinking block before sending actual content
|
||||
chunkChan <- "</think>"
|
||||
tokenCount++
|
||||
reasoningSent = true
|
||||
}
|
||||
|
||||
// bot sends way too many \n
|
||||
answerText = strings.ReplaceAll(chunk.Chunk, "\n\n", "\n")
|
||||
// Accumulate text to check for stop strings that might span across chunks
|
||||
@@ -709,9 +731,11 @@ func sendMsgToLLM(body io.Reader) {
|
||||
slices.Contains(stopStrings, answerText) {
|
||||
logger.Debug("stop string detected on client side for completion endpoint", "stop_string", answerText)
|
||||
streamDone <- true
|
||||
break
|
||||
}
|
||||
if answerText != "" {
|
||||
chunkChan <- answerText
|
||||
tokenCount++
|
||||
}
|
||||
openAIToolChan <- chunk.ToolChunk
|
||||
if chunk.FuncName != "" {
|
||||
@@ -743,12 +767,10 @@ func chatRagUse(qText string) (string, error) {
|
||||
questions[i] = q.Text
|
||||
logger.Debug("RAG question extracted", "index", i, "question", q.Text)
|
||||
}
|
||||
|
||||
if len(questions) == 0 {
|
||||
logger.Warn("No questions extracted from query text", "query", qText)
|
||||
return "No related results from RAG vector storage.", nil
|
||||
}
|
||||
|
||||
respVecs := []models.VectorRow{}
|
||||
for i, q := range questions {
|
||||
logger.Debug("Processing RAG question", "index", i, "question", q)
|
||||
@@ -758,7 +780,6 @@ func chatRagUse(qText string) (string, error) {
|
||||
continue
|
||||
}
|
||||
logger.Debug("Got embeddings for question", "index", i, "question_len", len(q), "embedding_len", len(emb))
|
||||
|
||||
// Create EmbeddingResp struct for the search
|
||||
embeddingResp := &models.EmbeddingResp{
|
||||
Embedding: emb,
|
||||
@@ -772,7 +793,6 @@ func chatRagUse(qText string) (string, error) {
|
||||
logger.Debug("RAG search returned vectors", "index", i, "question", q, "vector_count", len(vecs))
|
||||
respVecs = append(respVecs, vecs...)
|
||||
}
|
||||
|
||||
// get raw text
|
||||
resps := []string{}
|
||||
logger.Debug("RAG query final results", "total_vecs_found", len(respVecs))
|
||||
@@ -780,12 +800,10 @@ func chatRagUse(qText string) (string, error) {
|
||||
resps = append(resps, rv.RawText)
|
||||
logger.Debug("RAG result", "slug", rv.Slug, "filename", rv.FileName, "raw_text_len", len(rv.RawText))
|
||||
}
|
||||
|
||||
if len(resps) == 0 {
|
||||
logger.Info("No RAG results found for query", "original_query", qText, "question_count", len(questions))
|
||||
return "No related results from RAG vector storage.", nil
|
||||
}
|
||||
|
||||
result := strings.Join(resps, "\n")
|
||||
logger.Debug("RAG query completed", "result_len", len(result), "response_count", len(resps))
|
||||
return result, nil
|
||||
@@ -808,14 +826,45 @@ func chatWatcher(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// inpired by https://github.com/rivo/tview/issues/225
|
||||
func showSpinner() {
|
||||
spinners := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
||||
var i int
|
||||
botPersona := cfg.AssistantRole
|
||||
if cfg.WriteNextMsgAsCompletionAgent != "" {
|
||||
botPersona = cfg.WriteNextMsgAsCompletionAgent
|
||||
}
|
||||
for botRespMode || toolRunningMode {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
spin := i % len(spinners)
|
||||
app.QueueUpdateDraw(func() {
|
||||
if toolRunningMode {
|
||||
textArea.SetTitle(spinners[spin] + " tool")
|
||||
} else if botRespMode {
|
||||
textArea.SetTitle(spinners[spin] + " " + botPersona)
|
||||
} else {
|
||||
textArea.SetTitle(spinners[spin] + " input")
|
||||
}
|
||||
})
|
||||
i++
|
||||
}
|
||||
app.QueueUpdateDraw(func() {
|
||||
textArea.SetTitle("input")
|
||||
})
|
||||
}
|
||||
|
||||
func chatRound(r *models.ChatRoundReq) error {
|
||||
botRespMode = true
|
||||
go showSpinner()
|
||||
updateStatusLine()
|
||||
botPersona := cfg.AssistantRole
|
||||
if cfg.WriteNextMsgAsCompletionAgent != "" {
|
||||
botPersona = cfg.WriteNextMsgAsCompletionAgent
|
||||
}
|
||||
defer func() { botRespMode = false }()
|
||||
defer func() {
|
||||
botRespMode = false
|
||||
ClearImageAttachment()
|
||||
}()
|
||||
// check that there is a model set to use if is not local
|
||||
choseChunkParser()
|
||||
reader, err := chunkParser.FormMsg(r.UserMsg, r.Role, r.Resume)
|
||||
@@ -834,13 +883,14 @@ func chatRound(r *models.ChatRoundReq) error {
|
||||
chatBody.Messages = append(chatBody.Messages, models.RoleMsg{
|
||||
Role: botPersona, Content: "",
|
||||
})
|
||||
fmt.Fprintf(textView, "\n[-:-:b](%d) ", msgIdx)
|
||||
fmt.Fprint(textView, roleToIcon(botPersona))
|
||||
fmt.Fprint(textView, "[-:-:-]\n")
|
||||
if cfg.ThinkUse && !strings.Contains(cfg.CurrentAPI, "v1") {
|
||||
// fmt.Fprint(textView, "<think>")
|
||||
chunkChan <- "<think>"
|
||||
nl := "\n\n"
|
||||
prevText := textView.GetText(true)
|
||||
if strings.HasSuffix(prevText, nl) {
|
||||
nl = ""
|
||||
} else if strings.HasSuffix(prevText, "\n") {
|
||||
nl = "\n"
|
||||
}
|
||||
fmt.Fprintf(textView, "%s[-:-:b](%d) %s[-:-:-]\n", nl, msgIdx, roleToIcon(botPersona))
|
||||
} else {
|
||||
msgIdx = len(chatBody.Messages) - 1
|
||||
}
|
||||
@@ -914,7 +964,6 @@ out:
|
||||
textView.ScrollToEnd()
|
||||
}
|
||||
case <-streamDone:
|
||||
// drain any remaining chunks from chunkChan before exiting
|
||||
for len(chunkChan) > 0 {
|
||||
chunk := <-chunkChan
|
||||
fmt.Fprint(textView, chunk)
|
||||
@@ -923,31 +972,40 @@ out:
|
||||
textView.ScrollToEnd()
|
||||
}
|
||||
if cfg.TTS_ENABLED {
|
||||
// Send chunk to audio stream handler
|
||||
TTSTextChan <- chunk
|
||||
}
|
||||
}
|
||||
if cfg.TTS_ENABLED {
|
||||
// msg is done; flush it down
|
||||
TTSFlushChan <- true
|
||||
}
|
||||
break out
|
||||
}
|
||||
}
|
||||
var msgStats *models.ResponseStats
|
||||
if lastRespStats != nil {
|
||||
msgStats = &models.ResponseStats{
|
||||
Tokens: lastRespStats.Tokens,
|
||||
Duration: lastRespStats.Duration,
|
||||
TokensPerSec: lastRespStats.TokensPerSec,
|
||||
}
|
||||
lastRespStats = nil
|
||||
}
|
||||
botRespMode = false
|
||||
// numbers in chatbody and displayed must be the same
|
||||
if r.Resume {
|
||||
chatBody.Messages[len(chatBody.Messages)-1].Content += respText.String()
|
||||
// lastM.Content = lastM.Content + respText.String()
|
||||
// Process the updated message to check for known_to tags in resumed response
|
||||
updatedMsg := chatBody.Messages[len(chatBody.Messages)-1]
|
||||
processedMsg := processMessageTag(&updatedMsg)
|
||||
chatBody.Messages[len(chatBody.Messages)-1] = *processedMsg
|
||||
if msgStats != nil && chatBody.Messages[len(chatBody.Messages)-1].Role != cfg.ToolRole {
|
||||
chatBody.Messages[len(chatBody.Messages)-1].Stats = msgStats
|
||||
}
|
||||
} else {
|
||||
// Message was already added at the start, just process it for known_to tags
|
||||
chatBody.Messages[msgIdx].Content = respText.String()
|
||||
processedMsg := processMessageTag(&chatBody.Messages[msgIdx])
|
||||
chatBody.Messages[msgIdx] = *processedMsg
|
||||
if msgStats != nil && chatBody.Messages[msgIdx].Role != cfg.ToolRole {
|
||||
chatBody.Messages[msgIdx].Stats = msgStats
|
||||
}
|
||||
stopTTSIfNotForUser(&chatBody.Messages[msgIdx])
|
||||
}
|
||||
cleanChatBody()
|
||||
@@ -1169,7 +1227,11 @@ func findCall(msg, toolCall string) bool {
|
||||
chatRoundChan <- crr
|
||||
return true
|
||||
}
|
||||
// Show tool call progress indicator before execution
|
||||
fmt.Fprintf(textView, "\n[yellow::i][tool: %s...][-:-:-]", fc.Name)
|
||||
toolRunningMode = true
|
||||
resp := callToolWithAgent(fc.Name, fc.Args)
|
||||
toolRunningMode = false
|
||||
toolMsg := string(resp) // Remove the "tool response: " prefix and %+v formatting
|
||||
logger.Info("llm used a tool call", "tool_name", fc.Name, "too_args", fc.Args, "id", fc.ID, "tool_resp", toolMsg)
|
||||
fmt.Fprintf(textView, "%s[-:-:b](%d) <%s>: [-:-:-]\n%s\n",
|
||||
@@ -1208,7 +1270,6 @@ func chatToTextSlice(messages []models.RoleMsg, showSys bool) []string {
|
||||
func chatToText(messages []models.RoleMsg, showSys bool) string {
|
||||
s := chatToTextSlice(messages, showSys)
|
||||
text := strings.Join(s, "\n")
|
||||
|
||||
// Collapse thinking blocks if enabled
|
||||
if thinkingCollapsed {
|
||||
text = thinkRE.ReplaceAllStringFunc(text, func(match string) string {
|
||||
@@ -1232,7 +1293,6 @@ func chatToText(messages []models.RoleMsg, showSys bool) string {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
@@ -1369,7 +1429,7 @@ func init() {
|
||||
var err error
|
||||
cfg, err = config.LoadConfig("config.toml")
|
||||
if err != nil {
|
||||
fmt.Println("failed to load config.toml")
|
||||
fmt.Println("failed to load config.toml", err)
|
||||
cancel()
|
||||
os.Exit(1)
|
||||
return
|
||||
|
||||
@@ -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,6 +24,7 @@ ChunkLimit = 100000
|
||||
AutoScrollEnabled = true
|
||||
AutoCleanToolCallsFromCtx = false
|
||||
# rag settings
|
||||
RAGEnabled = false
|
||||
RAGBatchSize = 1
|
||||
RAGWordLimit = 80
|
||||
RAGWorkers = 2
|
||||
@@ -41,9 +45,8 @@ 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
|
||||
CodingDir = "." # Default directory for coding assistant file operations (relative paths resolved against this)
|
||||
EnableMouse = false # Enable mouse support in the UI
|
||||
# character specific context
|
||||
CharSpecificContextEnabled = true
|
||||
|
||||
@@ -18,7 +18,6 @@ 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"`
|
||||
@@ -32,15 +31,14 @@ type Config struct {
|
||||
DBPATH string `toml:"DBPATH"`
|
||||
FilePickerDir string `toml:"FilePickerDir"`
|
||||
FilePickerExts string `toml:"FilePickerExts"`
|
||||
CodingDir string `toml:"CodingDir"`
|
||||
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"`
|
||||
EmbedURL string `toml:"EmbedURL"`
|
||||
HFToken string `toml:"HFToken"`
|
||||
// rag settings
|
||||
RAGEnabled bool `toml:"RAGEnabled"`
|
||||
RAGDir string `toml:"RAGDir"`
|
||||
RAGWorkers uint32 `toml:"RAGWorkers"`
|
||||
RAGBatchSize int `toml:"RAGBatchSize"`
|
||||
RAGWordLimit uint32 `toml:"RAGWordLimit"`
|
||||
@@ -126,6 +124,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
|
||||
}
|
||||
|
||||
@@ -140,17 +140,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.
|
||||
|
||||
#### CodingDir (`"."`)
|
||||
- Default directory for coding assistant file operations. Relative paths in file tools (file_read, file_write, etc.) are resolved against this directory. Use absolute paths (starting with `/`) to bypass this.
|
||||
- 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.
|
||||
@@ -158,8 +165,11 @@ 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.
|
||||
### 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
|
||||
|
||||
|
||||
62
extra/tts.go
62
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)
|
||||
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
|
||||
if len(sentences) <= 1 {
|
||||
o.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
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)
|
||||
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
|
||||
if len(sentences) <= 1 {
|
||||
o.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
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 {
|
||||
|
||||
4
go.mod
4
go.mod
@@ -6,17 +6,19 @@ 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/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/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
|
||||
|
||||
3
go.sum
3
go.sum
@@ -43,6 +43,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=
|
||||
@@ -67,6 +69,7 @@ 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/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=
|
||||
|
||||
21
helpfuncs.go
21
helpfuncs.go
@@ -108,14 +108,17 @@ func refreshChatDisplay() {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -354,13 +357,17 @@ func makeStatusLine() string {
|
||||
}
|
||||
// Get model color based on load status for local llama.cpp models
|
||||
modelColor := getModelColor()
|
||||
statusLine := fmt.Sprintf(statusLineTempl, boolColors[botRespMode], botRespMode, activeChatName,
|
||||
boolColors[cfg.ToolUse], cfg.ToolUse, modelColor, chatBody.Model, boolColors[cfg.SkipLLMResp],
|
||||
cfg.SkipLLMResp, cfg.CurrentAPI, boolColors[isRecording], isRecording, persona,
|
||||
botPersona)
|
||||
statusLine := fmt.Sprintf(statusLineTempl, boolColors[botRespMode], 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(" | role injection [%s:-:b]%v[-:-:-] (alt+7)", boolColors[injectRole], injectRole)
|
||||
roleInject := fmt.Sprintf(" | [%s:-:b]role injection[-:-:-] (alt+7)", boolColors[injectRole])
|
||||
statusLine += roleInject
|
||||
}
|
||||
return statusLine + imageInfo + shellModeInfo
|
||||
|
||||
107
llm.go
107
llm.go
@@ -11,7 +11,6 @@ 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 {
|
||||
@@ -142,22 +141,6 @@ func (lcp LCPCompletion) 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))
|
||||
}
|
||||
// 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})
|
||||
@@ -184,9 +167,6 @@ func (lcp LCPCompletion) FormMsg(msg, role string, resume bool) (io.Reader, erro
|
||||
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,
|
||||
@@ -304,23 +284,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
|
||||
@@ -392,22 +355,6 @@ 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})
|
||||
@@ -423,9 +370,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,
|
||||
@@ -480,22 +424,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
|
||||
@@ -558,22 +486,6 @@ 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})
|
||||
@@ -589,9 +501,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)
|
||||
@@ -679,22 +588,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
|
||||
|
||||
3
main.go
3
main.go
@@ -7,13 +7,14 @@ 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
|
||||
thinkingCollapsed = false
|
||||
statusLineTempl = "help (F12) | llm turn: [%s:-:b]%v[-:-:-] (F6) | chat: [orange:-:b]%s[-:-:-] (F1) |tool-use: [%s:-:b]%v[-:-:-] (ctrl+k) | model: [%s:-:b]%s[-:-:-] (ctrl+l) | skip LLM resp: [%s:-:b]%v[-:-:-] (F10)\nAPI: [orange:-:b]%s[-:-:-] (ctrl+v) | voice recording: [%s:-:b]%v[-:-:-] (ctrl+r) | writing as: [orange:-:b]%s[-:-:-] (ctrl+q) | bot will write as [orange:-:b]%s[-:-:-] (ctrl+x)"
|
||||
statusLineTempl = "help (F12) | [%s:-:b]llm writes[-:-:-] (F6 to interrupt) | chat: [orange:-:b]%s[-:-:-] (F1) | [%s:-:b]tool use[-:-:-] (ctrl+k) | model: [%s:-:b]%s[-:-:-] (ctrl+l) | [%s:-:b]skip LLM resp[-:-:-] (F10)\nAPI: [orange:-:b]%s[-:-:-] (ctrl+v) | writing as: [orange:-:b]%s[-:-:-] (ctrl+q) | bot will write as [orange:-:b]%s[-:-:-] (ctrl+x)"
|
||||
focusSwitcher = map[tview.Primitive]tview.Primitive{}
|
||||
)
|
||||
|
||||
|
||||
@@ -105,12 +105,13 @@ type ImageContentPart struct {
|
||||
|
||||
// RoleMsg represents a message with content that can be either a simple string or structured content parts
|
||||
type RoleMsg struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"-"`
|
||||
ContentParts []any `json:"-"`
|
||||
ToolCallID string `json:"tool_call_id,omitempty"` // For tool response messages
|
||||
KnownTo []string `json:"known_to,omitempty"`
|
||||
hasContentParts bool // Flag to indicate which content type to marshal
|
||||
Role string `json:"role"`
|
||||
Content string `json:"-"`
|
||||
ContentParts []any `json:"-"`
|
||||
ToolCallID string `json:"tool_call_id,omitempty"` // For tool response messages
|
||||
KnownTo []string `json:"known_to,omitempty"`
|
||||
Stats *ResponseStats `json:"stats"`
|
||||
hasContentParts bool // Flag to indicate which content type to marshal
|
||||
}
|
||||
|
||||
// MarshalJSON implements custom JSON marshaling for RoleMsg
|
||||
@@ -183,13 +184,11 @@ func (m *RoleMsg) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -198,7 +197,6 @@ func (m *RoleMsg) ToText(i int) string {
|
||||
textParts = append(textParts, p.Text)
|
||||
}
|
||||
case ImageContentPart:
|
||||
// Collect image indicator
|
||||
displayPath := p.Path
|
||||
if displayPath == "" {
|
||||
displayPath = "image"
|
||||
@@ -216,7 +214,6 @@ func (m *RoleMsg) ToText(i int) string {
|
||||
}
|
||||
}
|
||||
case "image_url":
|
||||
// Handle unmarshaled image content
|
||||
var displayPath string
|
||||
if pathVal, pathExists := p["path"]; pathExists {
|
||||
if pathStr, isStr := pathVal.(string); isStr && pathStr != "" {
|
||||
@@ -233,23 +230,19 @@ func (m *RoleMsg) ToText(i int) string {
|
||||
}
|
||||
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)
|
||||
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")
|
||||
}
|
||||
@@ -331,6 +324,7 @@ func (m *RoleMsg) Copy() RoleMsg {
|
||||
ContentParts: m.ContentParts,
|
||||
ToolCallID: m.ToolCallID,
|
||||
KnownTo: m.KnownTo,
|
||||
Stats: m.Stats,
|
||||
hasContentParts: m.hasContentParts,
|
||||
}
|
||||
}
|
||||
@@ -643,6 +637,12 @@ func (lcp *LCPModels) ListModels() []string {
|
||||
return resp
|
||||
}
|
||||
|
||||
type ResponseStats struct {
|
||||
Tokens int
|
||||
Duration float64
|
||||
TokensPerSec float64
|
||||
}
|
||||
|
||||
type ChatRoundReq struct {
|
||||
UserMsg string
|
||||
Role string
|
||||
|
||||
32
popups.go
32
popups.go
@@ -51,7 +51,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, "(loaded) ") == chatBody.Model {
|
||||
currentModelIndex = i
|
||||
}
|
||||
modelListWidget.AddItem(model, "", 0, nil)
|
||||
@@ -61,23 +61,23 @@ func showModelSelectionPopup() {
|
||||
modelListWidget.SetCurrentItem(currentModelIndex)
|
||||
}
|
||||
modelListWidget.SetSelectedFunc(func(index int, mainText string, secondaryText string, shortcut rune) {
|
||||
// Strip "(loaded)" suffix if present for local llama.cpp models
|
||||
modelName := strings.TrimSuffix(mainText, " (loaded)")
|
||||
// Update the model in both chatBody and config
|
||||
modelName := strings.TrimPrefix(mainText, "(loaded) ")
|
||||
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
|
||||
@@ -162,19 +162,21 @@ func showAPILinkSelectionPopup() {
|
||||
chatBody.Model = newModelList[0]
|
||||
cfg.CurrentModel = chatBody.Model
|
||||
}
|
||||
// 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
|
||||
@@ -234,6 +236,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()
|
||||
@@ -241,10 +244,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
|
||||
@@ -307,16 +312,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
|
||||
@@ -336,7 +344,7 @@ func showBotRoleSelectionPopup() {
|
||||
}
|
||||
|
||||
func showFileCompletionPopup(filter string) {
|
||||
baseDir := cfg.CodingDir
|
||||
baseDir := cfg.FilePickerDir
|
||||
if baseDir == "" {
|
||||
baseDir = "."
|
||||
}
|
||||
@@ -368,14 +376,17 @@ func showFileCompletionPopup(filter string) {
|
||||
textArea.SetText(before+mainText, true)
|
||||
}
|
||||
pages.RemovePage("fileCompletionPopup")
|
||||
app.SetFocus(textArea)
|
||||
})
|
||||
widget.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
pages.RemovePage("fileCompletionPopup")
|
||||
app.SetFocus(textArea)
|
||||
return nil
|
||||
}
|
||||
if event.Key() == tcell.KeyRune && event.Rune() == 'x' {
|
||||
pages.RemovePage("fileCompletionPopup")
|
||||
app.SetFocus(textArea)
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
@@ -488,14 +499,17 @@ func showColorschemeSelectionPopup() {
|
||||
}
|
||||
// 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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
184
rag/extractors.go
Normal file
184
rag/extractors.go
Normal file
@@ -0,0 +1,184 @@
|
||||
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
|
||||
}
|
||||
563
rag/rag.go
563
rag/rag.go
@@ -7,8 +7,9 @@ import (
|
||||
"gf-lt/models"
|
||||
"gf-lt/storage"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
@@ -23,13 +24,13 @@ 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 {
|
||||
@@ -54,7 +55,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 +66,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 +76,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 +94,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,215 +101,82 @@ 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++
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// 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:
|
||||
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))
|
||||
}
|
||||
}
|
||||
if len(nonEmptyBatch) == 0 {
|
||||
continue
|
||||
}
|
||||
// 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 - 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
|
||||
}
|
||||
return err // Stop the entire RAG operation on DB error
|
||||
}
|
||||
// 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,
|
||||
}
|
||||
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")
|
||||
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 <- FinishedRAGStatus:
|
||||
case LongJobStatusCh <- ErrRAGStatus:
|
||||
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
|
||||
r.logger.Warn("LongJobStatusCh channel full, dropping message")
|
||||
}
|
||||
return nil
|
||||
return fmt.Errorf("failed to write vector: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
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 or closed, dropping status message", "message", statusMsg)
|
||||
// Channel is full or closed, ignore the message to prevent panic
|
||||
r.logger.Warn("LongJobStatusCh channel full, dropping message")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -332,3 +195,309 @@ 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 {
|
||||
contextBuilder.WriteString(fmt.Sprintf("[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
|
||||
}
|
||||
finalAnswer.WriteString(fmt.Sprintf("- 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
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"sys_prompt": "You are an expert software engineering assistant. Your goal is to help users with coding tasks, debugging, refactoring, and software development.\n\n## Core Principles\n1. **Security First**: Never expose secrets, keys, or credentials. Never commit sensitive data.\n2. **No Git Actions**: You can READ git info (status, log, diff) for context, but NEVER perform git actions (commit, add, push, checkout, reset, rm, etc.). Let the user handle all git operations.\n3. **Explore Before Execute**: Always understand the codebase structure before making changes.\n4. **Follow Conventions**: Match existing code style, patterns, and frameworks used in the project.\n5. **Be Concise**: Minimize output tokens while maintaining quality. Avoid unnecessary explanations.\n\n## Workflow for Complex Tasks\nFor multi-step tasks, ALWAYS use the todo system to track progress:\n\n1. **Create Todo List**: At the start of complex tasks, use `todo_create` to break down work into actionable items.\n2. **Update Progress**: Mark items as `in_progress` when working on them, and `completed` when done.\n3. **Check Status**: Use `todo_read` to review your progress.\n\nExample workflow:\n- User: \"Add user authentication to this app\"\n- You: Create todos: [\"Analyze existing auth structure\", \"Check frameworks in use\", \"Implement auth middleware\", \"Add login endpoints\", \"Test implementation\"]\n\n## Task Execution Flow\n\n### Phase 1: Exploration (Always First)\n- Use `file_list` to understand directory structure (path defaults to CodingDir if not specified)\n- Use `file_read` to examine relevant files (paths are relative to CodingDir unless starting with `/`)\n- Use `execute_command` with `grep`/`find` to search for patterns\n- Check `README` or documentation files\n- Identify: frameworks, conventions, testing approach\n- **Git reads allowed**: You may use `git status`, `git log`, `git diff` for context, but only to inform your work\n- **Path handling**: Relative paths are resolved against CodingDir (configurable via Alt+O). Use absolute paths (starting with `/`) to bypass CodingDir.\n\n### Phase 2: Planning\n- For complex tasks: create todo items\n- Identify files that need modification\n- Plan your approach following existing patterns\n\n### Phase 3: Implementation\n- Make changes using appropriate file tools\n- Prefer `file_write` for new files, `file_read` then modify for existing files\n- Follow existing code style exactly\n- Use existing libraries and utilities\n\n### Phase 4: Verification\n- Run tests if available (check for test scripts)\n- Run linting/type checking commands\n- Verify changes work as expected\n\n### Phase 5: Completion\n- Update todos to `completed`\n- Provide concise summary of changes\n- Reference specific file paths and line numbers when relevant\n- **DO NOT commit changes** - inform user what was done so they can review and commit themselves\n\n## Tool Usage Guidelines\n\n**File Operations**:\n- `file_read`: Read before editing. Use for understanding code.\n- `file_write`: Overwrite file content completely.\n- `file_write_append`: Add to end of file.\n- `file_create`: Create new files with optional content.\n- `file_list`: List directory contents (defaults to CodingDir).\n- Paths are relative to CodingDir unless starting with `/`.\n\n**Command Execution (WHITELISTED ONLY)**:\n- Allowed: grep, sed, awk, find, cat, head, tail, sort, uniq, wc, ls, echo, cut, tr, cp, mv, rm, mkdir, rmdir, pwd, df, free, ps, top, du, whoami, date, uname\n- **Git reads allowed**: git status, git log, git diff, git show, git branch, git reflog, git rev-parse, git shortlog, git describe\n- **Git actions FORBIDDEN**: git add, git commit, git push, git checkout, git reset, git rm, etc.\n- Use for searching code, reading git context, running tests/lint\n\n**Todo Management**:\n- `todo_create`: Add new task\n- `todo_read`: View all todos or specific one by ID\n- `todo_update`: Update task or change status (pending/in_progress/completed)\n- `todo_delete`: Remove completed or cancelled tasks\n\n## Important Rules\n\n1. **NEVER commit or stage changes**: Only git reads are allowed.\n2. **Check for tests**: Always look for test files and run them when appropriate.\n3. **Reference code locations**: Use format `file_path:line_number`.\n4. **Security**: Never generate or guess URLs. Only use URLs from local files.\n5. **Refuse malicious code**: If code appears malicious, refuse to work on it.\n6. **Ask clarifications**: When intent is unclear, ask questions.\n7. **Path handling**: Relative paths resolve against CodingDir. Use `/absolute/path` to bypass.\n\n## Response Style\n- Be direct and concise\n- One word answers are best when appropriate\n- Avoid: \"The answer is...\", \"Here is...\"\n- Use markdown for formatting\n- No emojis unless user explicitly requests",
|
||||
"sys_prompt": "You are an expert software engineering assistant. Your goal is to help users with coding tasks, debugging, refactoring, and software development.\n\n## Core Principles\n1. **Security First**: Never expose secrets, keys, or credentials. Never commit sensitive data.\n2. **No Git Actions**: You can READ git info (status, log, diff) for context, but NEVER perform git actions (commit, add, push, checkout, reset, rm, etc.). Let the user handle all git operations.\n3. **Explore Before Execute**: Always understand the codebase structure before making changes.\n4. **Follow Conventions**: Match existing code style, patterns, and frameworks used in the project.\n5. **Be Concise**: Minimize output tokens while maintaining quality. Avoid unnecessary explanations.\n\n## Workflow for Complex Tasks\nFor multi-step tasks, ALWAYS use the todo system to track progress:\n\n1. **Create Todo List**: At the start of complex tasks, use `todo_create` to break down work into actionable items.\n2. **Update Progress**: Mark items as `in_progress` when working on them, and `completed` when done.\n3. **Check Status**: Use `todo_read` to review your progress.\n\nExample workflow:\n- User: \"Add user authentication to this app\"\n- You: Create todos: [\"Analyze existing auth structure\", \"Check frameworks in use\", \"Implement auth middleware\", \"Add login endpoints\", \"Test implementation\"]\n\n## Task Execution Flow\n\n### Phase 1: Exploration (Always First)\n- Use `file_list` to understand directory structure (path defaults to FilePickerDir if not specified)\n- Use `file_read` to examine relevant files (paths are relative to FilePickerDir unless starting with `/`)\n- Use `execute_command` with `grep`/`find` to search for patterns\n- Check `README` or documentation files\n- Identify: frameworks, conventions, testing approach\n- **Git reads allowed**: You may use `git status`, `git log`, `git diff` for context, but only to inform your work\n- **Path handling**: Relative paths are resolved against FilePickerDir (configurable via Alt+O). Use absolute paths (starting with `/`) to bypass FilePickerDir.\n\n### Phase 2: Planning\n- For complex tasks: create todo items\n- Identify files that need modification\n- Plan your approach following existing patterns\n\n### Phase 3: Implementation\n- Make changes using appropriate file tools\n- Prefer `file_write` for new files, `file_read` then modify for existing files\n- Follow existing code style exactly\n- Use existing libraries and utilities\n\n### Phase 4: Verification\n- Run tests if available (check for test scripts)\n- Run linting/type checking commands\n- Verify changes work as expected\n\n### Phase 5: Completion\n- Update todos to `completed`\n- Provide concise summary of changes\n- Reference specific file paths and line numbers when relevant\n- **DO NOT commit changes** - inform user what was done so they can review and commit themselves\n\n## Tool Usage Guidelines\n\n**File Operations**:\n- `file_read`: Read before editing. Use for understanding code.\n- `file_write`: Overwrite file content completely.\n- `file_write_append`: Add to end of file.\n- `file_create`: Create new files with optional content.\n- `file_list`: List directory contents (defaults to FilePickerDir).\n- Paths are relative to FilePickerDir unless starting with `/`.\n\n**Command Execution (WHITELISTED ONLY)**:\n- Allowed: grep, sed, awk, find, cat, head, tail, sort, uniq, wc, ls, echo, cut, tr, cp, mv, rm, mkdir, rmdir, pwd, df, free, ps, top, du, whoami, date, uname\n- **Git reads allowed**: git status, git log, git diff, git show, git branch, git reflog, git rev-parse, git shortlog, git describe\n- **Git actions FORBIDDEN**: git add, git commit, git push, git checkout, git reset, git rm, etc.\n- Use for searching code, reading git context, running tests/lint\n\n**Todo Management**:\n- `todo_create`: Add new task\n- `todo_read`: View all todos or specific one by ID\n- `todo_update`: Update task or change status (pending/in_progress/completed)\n- `todo_delete`: Remove completed or cancelled tasks\n\n## Important Rules\n\n1. **NEVER commit or stage changes**: Only git reads are allowed.\n2. **Check for tests**: Always look for test files and run them when appropriate.\n3. **Reference code locations**: Use format `file_path:line_number`.\n4. **Security**: Never generate or guess URLs. Only use URLs from local files.\n5. **Refuse malicious code**: If code appears malicious, refuse to work on it.\n6. **Ask clarifications**: When intent is unclear, ask questions.\n7. **Path handling**: Relative paths resolve against FilePickerDir. Use `/absolute/path` to bypass.\n\n## Response Style\n- Be direct and concise\n- One word answers are best when appropriate\n- Avoid: \"The answer is...\", \"Here is...\"\n- Use markdown for formatting\n- No emojis unless user explicitly requests",
|
||||
"role": "CodingAssistant",
|
||||
"filepath": "sysprompts/coding_assistant.json",
|
||||
"first_msg": "Hello! I'm your coding assistant. I can help you with software engineering tasks like writing code, debugging, refactoring, and exploring codebases. I work best when you give me specific tasks, and for complex work, I'll create a todo list to track my progress. What would you like to work on?"
|
||||
|
||||
274
tables.go
274
tables.go
@@ -236,9 +236,60 @@ 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 +303,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]).
|
||||
case c == 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)").
|
||||
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]).
|
||||
case c == 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))
|
||||
} else {
|
||||
fileTable.SetCell(r+1, c,
|
||||
tview.NewTableCell("error").
|
||||
SetTextColor(color).
|
||||
SetAlign(tview.AlignCenter).
|
||||
SetSelectable(false))
|
||||
}
|
||||
case c == 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 c == 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 +420,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,30 +437,58 @@ 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)
|
||||
app.QueueUpdate(func() {
|
||||
pages.RemovePage(RAGPage)
|
||||
})
|
||||
return
|
||||
}
|
||||
_ = notifyUser("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)
|
||||
_ = notifyUser("RAG", "failed to unload file; error: "+err.Error())
|
||||
app.QueueUpdate(func() {
|
||||
pages.RemovePage(RAGPage)
|
||||
})
|
||||
return
|
||||
}
|
||||
_ = notifyUser("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
|
||||
@@ -385,7 +515,7 @@ func makeRAGTable(fileList []string) *tview.Flex {
|
||||
|
||||
func makeLoadedRAGTable(fileList []string) *tview.Flex {
|
||||
actions := []string{"delete"}
|
||||
rows, cols := len(fileList), len(actions)+1
|
||||
rows, cols := len(fileList), len(actions)+2
|
||||
// Add 1 extra row for the "exit" option at the top
|
||||
fileTable := tview.NewTable().
|
||||
SetBorders(true)
|
||||
@@ -400,39 +530,61 @@ func makeLoadedRAGTable(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 Loaded Files 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").
|
||||
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++ {
|
||||
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
|
||||
case c == 0:
|
||||
fileTable.SetCell(r+1, c,
|
||||
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)").
|
||||
case c == 1:
|
||||
if fi, err := os.Stat(fileList[r]); 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))
|
||||
} else {
|
||||
fileTable.SetCell(r+1, c,
|
||||
tview.NewTableCell("error").
|
||||
SetTextColor(color).
|
||||
SetAlign(tview.AlignCenter).
|
||||
SetSelectable(false))
|
||||
}
|
||||
case c == 2:
|
||||
fileTable.SetCell(r+1, c,
|
||||
tview.NewTableCell("load").
|
||||
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]).
|
||||
SetAlign(tview.AlignCenter))
|
||||
default:
|
||||
fileTable.SetCell(r+1, c,
|
||||
tview.NewTableCell("delete").
|
||||
SetTextColor(color).
|
||||
SetAlign(tview.AlignCenter))
|
||||
}
|
||||
@@ -440,7 +592,7 @@ func makeLoadedRAGTable(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 {
|
||||
@@ -456,6 +608,8 @@ func makeLoadedRAGTable(fileList []string) *tview.Flex {
|
||||
return
|
||||
}
|
||||
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(RAGLoadedPage)
|
||||
@@ -533,7 +687,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 +703,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 {
|
||||
@@ -630,7 +786,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,6 +802,8 @@ 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 {
|
||||
@@ -702,7 +860,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 +876,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 {
|
||||
@@ -820,7 +980,9 @@ func makeFilePicker() *tview.Flex {
|
||||
}
|
||||
// Create UI elements
|
||||
listView := tview.NewList()
|
||||
listView.SetBorder(true).SetTitle("Files & Directories [c: set CodingDir]").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)
|
||||
@@ -1032,8 +1194,8 @@ func makeFilePicker() *tview.Flex {
|
||||
refreshList(currentDisplayDir, "")
|
||||
return nil
|
||||
}
|
||||
if event.Rune() == 'c' {
|
||||
// Set CodingDir to current directory
|
||||
if event.Rune() == 's' {
|
||||
// Set FilePickerDir to current directory
|
||||
itemIndex := listView.GetCurrentItem()
|
||||
if itemIndex >= 0 && itemIndex < listView.GetItemCount() {
|
||||
itemText, _ := listView.GetItemText(itemIndex)
|
||||
@@ -1056,11 +1218,11 @@ func makeFilePicker() *tview.Flex {
|
||||
targetDir = currentDisplayDir
|
||||
}
|
||||
}
|
||||
cfg.CodingDir = targetDir
|
||||
if err := notifyUser("CodingDir", "Set to: "+targetDir); err != nil {
|
||||
cfg.FilePickerDir = targetDir
|
||||
if err := notifyUser("FilePickerDir", "Set to: "+targetDir); err != nil {
|
||||
logger.Error("failed to notify user", "error", err)
|
||||
}
|
||||
pages.RemovePage(filePickerPage)
|
||||
// pages.RemovePage(filePickerPage)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
79
tools.go
79
tools.go
@@ -16,6 +16,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gf-lt/rag"
|
||||
"github.com/GrailFinder/searchagent/searcher"
|
||||
)
|
||||
|
||||
@@ -58,9 +59,9 @@ Your current tools:
|
||||
"when_to_use": "when asked to search the web for information; returns clean summary without html,css and other web elements; limit is optional (default 3)"
|
||||
},
|
||||
{
|
||||
"name":"websearch_raw",
|
||||
"name":"rag_search",
|
||||
"args": ["query", "limit"],
|
||||
"when_to_use": "when asked to search the web for information; returns raw data as is without processing; limit is optional (default 3)"
|
||||
"when_to_use": "when asked to search the local document database for information; performs query refinement, semantic search, reranking, and synthesis; returns clean summary with sources; limit is optional (default 3)"
|
||||
},
|
||||
{
|
||||
"name":"read_url",
|
||||
@@ -146,6 +147,7 @@ under the topic: Adam's number is stored:
|
||||
After that you are free to respond to the user.
|
||||
`
|
||||
webSearchSysPrompt = `Summarize the web search results, extracting key information and presenting a concise answer. Provide sources and URLs where relevant.`
|
||||
ragSearchSysPrompt = `Synthesize the document search results, extracting key information and presenting a concise answer. Provide sources and document IDs where relevant.`
|
||||
readURLSysPrompt = `Extract and summarize the content from the webpage. Provide key information, main points, and any relevant details.`
|
||||
summarySysPrompt = `Please provide a concise summary of the following conversation. Focus on key points, decisions, and actions. Provide only the summary, no additional commentary.`
|
||||
basicCard = &models.CharCard{
|
||||
@@ -170,6 +172,10 @@ func init() {
|
||||
panic("failed to init seachagent; error: " + err.Error())
|
||||
}
|
||||
WebSearcher = sa
|
||||
|
||||
if err := rag.Init(cfg, logger, store); err != nil {
|
||||
logger.Warn("failed to init rag; rag_search tool will not be available", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// getWebAgentClient returns a singleton AgentClient for web agents.
|
||||
@@ -196,6 +202,8 @@ func getWebAgentClient() *agent.AgentClient {
|
||||
func registerWebAgents() {
|
||||
webAgentsOnce.Do(func() {
|
||||
client := getWebAgentClient()
|
||||
// Register rag_search agent
|
||||
agent.Register("rag_search", agent.NewWebAgentB(client, ragSearchSysPrompt))
|
||||
// Register websearch agent
|
||||
agent.Register("websearch", agent.NewWebAgentB(client, webSearchSysPrompt))
|
||||
// Register read_url agent
|
||||
@@ -239,6 +247,48 @@ func websearch(args map[string]string) []byte {
|
||||
return data
|
||||
}
|
||||
|
||||
// rag search (searches local document database)
|
||||
func ragsearch(args map[string]string) []byte {
|
||||
query, ok := args["query"]
|
||||
if !ok || query == "" {
|
||||
msg := "query not provided to rag_search tool"
|
||||
logger.Error(msg)
|
||||
return []byte(msg)
|
||||
}
|
||||
limitS, ok := args["limit"]
|
||||
if !ok || limitS == "" {
|
||||
limitS = "3"
|
||||
}
|
||||
limit, err := strconv.Atoi(limitS)
|
||||
if err != nil || limit == 0 {
|
||||
logger.Warn("ragsearch limit; passed bad value; setting to default (3)",
|
||||
"limit_arg", limitS, "error", err)
|
||||
limit = 3
|
||||
}
|
||||
|
||||
ragInstance := rag.GetInstance()
|
||||
if ragInstance == nil {
|
||||
msg := "rag not initialized; rag_search tool is not available"
|
||||
logger.Error(msg)
|
||||
return []byte(msg)
|
||||
}
|
||||
|
||||
results, err := ragInstance.Search(query, limit)
|
||||
if err != nil {
|
||||
msg := "rag search failed; error: " + err.Error()
|
||||
logger.Error(msg)
|
||||
return []byte(msg)
|
||||
}
|
||||
|
||||
data, err := json.Marshal(results)
|
||||
if err != nil {
|
||||
msg := "failed to marshal rag search result; error: " + err.Error()
|
||||
logger.Error(msg)
|
||||
return []byte(msg)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// web search raw (returns raw data without processing)
|
||||
func websearchRaw(args map[string]string) []byte {
|
||||
// make http request return bytes
|
||||
@@ -577,7 +627,7 @@ func resolvePath(p string) string {
|
||||
if filepath.IsAbs(p) {
|
||||
return p
|
||||
}
|
||||
return filepath.Join(cfg.CodingDir, p)
|
||||
return filepath.Join(cfg.FilePickerDir, p)
|
||||
}
|
||||
|
||||
func readStringFromFile(filename string) (string, error) {
|
||||
@@ -997,6 +1047,7 @@ var fnMap = map[string]fnSig{
|
||||
"recall": recall,
|
||||
"recall_topics": recallTopics,
|
||||
"memorise": memorise,
|
||||
"rag_search": ragsearch,
|
||||
"websearch": websearch,
|
||||
"websearch_raw": websearchRaw,
|
||||
"read_url": readURL,
|
||||
@@ -1033,6 +1084,28 @@ func callToolWithAgent(name string, args map[string]string) []byte {
|
||||
|
||||
// openai style def
|
||||
var baseTools = []models.Tool{
|
||||
// rag_search
|
||||
models.Tool{
|
||||
Type: "function",
|
||||
Function: models.ToolFunc{
|
||||
Name: "rag_search",
|
||||
Description: "Search local document database given query, limit of sources (default 3). Performs query refinement, semantic search, reranking, and synthesis.",
|
||||
Parameters: models.ToolFuncParams{
|
||||
Type: "object",
|
||||
Required: []string{"query", "limit"},
|
||||
Properties: map[string]models.ToolArgProps{
|
||||
"query": models.ToolArgProps{
|
||||
Type: "string",
|
||||
Description: "search query",
|
||||
},
|
||||
"limit": models.ToolArgProps{
|
||||
Type: "string",
|
||||
Description: "limit of the document results",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
// websearch
|
||||
models.Tool{
|
||||
Type: "function",
|
||||
|
||||
48
tui.go
48
tui.go
@@ -15,6 +15,11 @@ import (
|
||||
"github.com/rivo/tview"
|
||||
)
|
||||
|
||||
func isFullScreenPageActive() bool {
|
||||
name, _ := pages.GetFrontPage()
|
||||
return name != "main"
|
||||
}
|
||||
|
||||
var (
|
||||
app *tview.Application
|
||||
pages *tview.Pages
|
||||
@@ -106,7 +111,7 @@ var (
|
||||
[yellow]x[white]: to exit the table page
|
||||
|
||||
=== filepicker ===
|
||||
[yellow]c[white]: (in file picker) set current dir as CodingDir
|
||||
[yellow]s[white]: (in file picker) set current dir as FilePickerDir
|
||||
[yellow]x[white]: to exit
|
||||
|
||||
=== shell mode ===
|
||||
@@ -525,6 +530,9 @@ func init() {
|
||||
return nil
|
||||
}
|
||||
if event.Key() == tcell.KeyRune && event.Rune() == 'i' && event.Modifiers()&tcell.ModAlt != 0 {
|
||||
if isFullScreenPageActive() {
|
||||
return event
|
||||
}
|
||||
showColorschemeSelectionPopup()
|
||||
return nil
|
||||
}
|
||||
@@ -731,6 +739,9 @@ func init() {
|
||||
return nil
|
||||
}
|
||||
if event.Key() == tcell.KeyCtrlL {
|
||||
if isFullScreenPageActive() {
|
||||
return event
|
||||
}
|
||||
// Show model selection popup instead of rotating models
|
||||
showModelSelectionPopup()
|
||||
return nil
|
||||
@@ -744,6 +755,9 @@ func init() {
|
||||
return nil
|
||||
}
|
||||
if event.Key() == tcell.KeyCtrlV {
|
||||
if isFullScreenPageActive() {
|
||||
return event
|
||||
}
|
||||
// Show API link selection popup instead of rotating APIs
|
||||
showAPILinkSelectionPopup()
|
||||
return nil
|
||||
@@ -850,11 +864,17 @@ func init() {
|
||||
return nil
|
||||
}
|
||||
if event.Key() == tcell.KeyCtrlQ {
|
||||
if isFullScreenPageActive() {
|
||||
return event
|
||||
}
|
||||
// Show user role selection popup instead of cycling through roles
|
||||
showUserRoleSelectionPopup()
|
||||
return nil
|
||||
}
|
||||
if event.Key() == tcell.KeyCtrlX {
|
||||
if isFullScreenPageActive() {
|
||||
return event
|
||||
}
|
||||
// Show bot role selection popup instead of cycling through roles
|
||||
showBotRoleSelectionPopup()
|
||||
return nil
|
||||
@@ -893,6 +913,7 @@ func init() {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
// Get files from ragdir
|
||||
fileList := []string{}
|
||||
for _, f := range files {
|
||||
if f.IsDir() {
|
||||
@@ -900,22 +921,14 @@ func init() {
|
||||
}
|
||||
fileList = append(fileList, f.Name())
|
||||
}
|
||||
chatRAGTable := makeRAGTable(fileList)
|
||||
pages.AddPage(RAGPage, chatRAGTable, true, true)
|
||||
return nil
|
||||
}
|
||||
if event.Key() == tcell.KeyCtrlY { // Use Ctrl+Y to list loaded RAG files
|
||||
// List files already loaded into the RAG system
|
||||
fileList, err := ragger.ListLoaded()
|
||||
// Get loaded files from vector DB
|
||||
loadedFiles, err := ragger.ListLoaded()
|
||||
if err != nil {
|
||||
logger.Error("failed to list loaded RAG files", "error", err)
|
||||
if notifyerr := notifyUser("failed to list RAG files", err.Error()); notifyerr != nil {
|
||||
logger.Error("failed to send notification", "error", notifyerr)
|
||||
}
|
||||
return nil
|
||||
loadedFiles = []string{} // Continue with empty list on error
|
||||
}
|
||||
chatLoadedRAGTable := makeLoadedRAGTable(fileList)
|
||||
pages.AddPage(RAGLoadedPage, chatLoadedRAGTable, true, true)
|
||||
chatRAGTable := makeRAGTable(fileList, loadedFiles)
|
||||
pages.AddPage(RAGPage, chatRAGTable, true, true)
|
||||
return nil
|
||||
}
|
||||
if event.Key() == tcell.KeyRune && event.Modifiers() == tcell.ModAlt && event.Rune() == '1' {
|
||||
@@ -975,13 +988,6 @@ func init() {
|
||||
}
|
||||
// go chatRound(msgText, persona, textView, false, false)
|
||||
chatRoundChan <- &models.ChatRoundReq{Role: persona, UserMsg: msgText}
|
||||
// Also clear any image attachment after sending the message
|
||||
go func() {
|
||||
// Wait a short moment for the message to be processed, then clear the image attachment
|
||||
// This allows the image to be sent with the current message if it was attached
|
||||
// But clears it for the next message
|
||||
ClearImageAttachment()
|
||||
}()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user