demo
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
dist
|
||||
gocoder
|
||||
79
agents/agent.go
Normal file
79
agents/agent.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package agents
|
||||
|
||||
import "gocoder/db"
|
||||
|
||||
// Agent defines the interface that all agents must implement.
|
||||
type Agent interface {
|
||||
// Type returns the unique agent type identifier (e.g. "dadjokes", "poet", "robot").
|
||||
Type() string
|
||||
|
||||
// DisplayName returns the human-readable name for the agent.
|
||||
DisplayName() string
|
||||
|
||||
// Description returns a short description shown in the UI.
|
||||
Description() string
|
||||
|
||||
// Icon returns an SVG icon string (inline) for the agent selector.
|
||||
Icon() string
|
||||
|
||||
// SystemMessage returns the system prompt for this agent.
|
||||
SystemMessage() string
|
||||
|
||||
// ShouldRespond returns true if the agent should process the given user message.
|
||||
// When false, the agent will not be used even if selected.
|
||||
ShouldRespond(message string) bool
|
||||
|
||||
// RequiresWorkspace returns true if this agent needs a workspace location to function.
|
||||
RequiresWorkspace() bool
|
||||
}
|
||||
|
||||
// AgentRegistry holds all registered agents and provides lookup by type.
|
||||
type AgentRegistry struct {
|
||||
agents []Agent
|
||||
}
|
||||
|
||||
// NewAgentRegistry creates and registers all built-in agents.
|
||||
func NewAgentRegistry() *AgentRegistry {
|
||||
r := &AgentRegistry{}
|
||||
r.Register(&DadjokesAgent{})
|
||||
r.Register(&PoetAgent{})
|
||||
r.Register(&RobotAgent{})
|
||||
r.Register(&CompactorAgent{})
|
||||
r.Register(&CodingAgent{})
|
||||
return r
|
||||
}
|
||||
|
||||
// Register adds an agent to the registry.
|
||||
func (r *AgentRegistry) Register(a Agent) {
|
||||
r.agents = append(r.agents, a)
|
||||
}
|
||||
|
||||
// All returns all registered agents.
|
||||
func (r *AgentRegistry) All() []Agent {
|
||||
return r.agents
|
||||
}
|
||||
|
||||
// GetByType returns the agent with the given type.
|
||||
func (r *AgentRegistry) GetByType(t string) Agent {
|
||||
for _, a := range r.agents {
|
||||
if a.Type() == t {
|
||||
return a
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSystemMessages returns a slice of system messages for the selected agents.
|
||||
func (r *AgentRegistry) GetSystemMessages(agentTypes []string) []db.Message {
|
||||
var msgs []db.Message
|
||||
for _, t := range agentTypes {
|
||||
a := r.GetByType(t)
|
||||
if a != nil {
|
||||
msgs = append(msgs, db.Message{
|
||||
Role: "system",
|
||||
Content: a.SystemMessage(),
|
||||
})
|
||||
}
|
||||
}
|
||||
return msgs
|
||||
}
|
||||
40
agents/coding_agent.go
Normal file
40
agents/coding_agent.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package agents
|
||||
|
||||
type CodingAgent struct{}
|
||||
|
||||
func (a *CodingAgent) Type() string {
|
||||
return "coding"
|
||||
}
|
||||
|
||||
func (a *CodingAgent) DisplayName() string {
|
||||
return "Manager"
|
||||
}
|
||||
|
||||
func (a *CodingAgent) Description() string {
|
||||
return "Coordinates planner, programmer, and QA subagents to modify code."
|
||||
}
|
||||
|
||||
func (a *CodingAgent) Icon() string {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>`
|
||||
}
|
||||
|
||||
func (a *CodingAgent) SystemMessage() string {
|
||||
return `You are the manager for code changes. You do not edit files directly.
|
||||
|
||||
When the user asks you to work with code:
|
||||
1. Start by calling planner_agent with the request and the relevant context.
|
||||
2. Use programmer_agent to implement the approved plan.
|
||||
3. Use qa_agent to verify the implementation and check for regressions.
|
||||
4. If QA reports issues, repeat the planner -> programmer -> QA cycle until the issues are resolved or the result is clearly blocked.
|
||||
5. Keep your final answer concise and user-facing. Summarize what changed, what was verified, and any remaining risks.
|
||||
|
||||
Always be precise with the task you give each subagent. Use the planner for inspection and planning, the programmer for edits, and QA for verification.`
|
||||
}
|
||||
|
||||
func (a *CodingAgent) ShouldRespond(message string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *CodingAgent) RequiresWorkspace() bool {
|
||||
return true
|
||||
}
|
||||
40
agents/compactor.go
Normal file
40
agents/compactor.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package agents
|
||||
|
||||
type CompactorAgent struct{}
|
||||
|
||||
func ContextCompactorSystemMessage() string {
|
||||
return `You are the context compactor assistant.
|
||||
Your job is to produce a concise durable memory summary of the conversation.
|
||||
Preserve user goals, decisions, constraints, file paths, commands, test results, errors, and unresolved questions.
|
||||
Drop greetings, repetition, filler, and low-value chatter.
|
||||
Write plain text with short bullets or short sections.
|
||||
Do not invent details.`
|
||||
}
|
||||
|
||||
func (a *CompactorAgent) Type() string {
|
||||
return "compactor"
|
||||
}
|
||||
|
||||
func (a *CompactorAgent) DisplayName() string {
|
||||
return "Compactor"
|
||||
}
|
||||
|
||||
func (a *CompactorAgent) Description() string {
|
||||
return "Summarizes conversation history into durable memory"
|
||||
}
|
||||
|
||||
func (a *CompactorAgent) Icon() string {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16"/><path d="M6 7v12h12V7"/><path d="M9 11h6"/><path d="M9 15h6"/></svg>`
|
||||
}
|
||||
|
||||
func (a *CompactorAgent) SystemMessage() string {
|
||||
return ContextCompactorSystemMessage()
|
||||
}
|
||||
|
||||
func (a *CompactorAgent) ShouldRespond(message string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *CompactorAgent) RequiresWorkspace() bool {
|
||||
return false
|
||||
}
|
||||
33
agents/dadjokes.go
Normal file
33
agents/dadjokes.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package agents
|
||||
|
||||
|
||||
|
||||
type DadjokesAgent struct{}
|
||||
|
||||
func (a *DadjokesAgent) Type() string {
|
||||
return "dadjokes"
|
||||
}
|
||||
|
||||
func (a *DadjokesAgent) DisplayName() string {
|
||||
return "Dadjokes"
|
||||
}
|
||||
|
||||
func (a *DadjokesAgent) Description() string {
|
||||
return "Only replies with dad jokes"
|
||||
}
|
||||
|
||||
func (a *DadjokesAgent) Icon() string {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/></svg>`
|
||||
}
|
||||
|
||||
func (a *DadjokesAgent) SystemMessage() string {
|
||||
return `You are a dad joke specialist. You ONLY reply to messages with dad jokes. If the user says something that is not a dad joke or a request for a joke, respond with a dad joke anyway. Keep your responses short and punny. Always deliver a dad joke in your reply.`
|
||||
}
|
||||
|
||||
func (a *DadjokesAgent) ShouldRespond(message string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *DadjokesAgent) RequiresWorkspace() bool {
|
||||
return false
|
||||
}
|
||||
31
agents/poet.go
Normal file
31
agents/poet.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package agents
|
||||
|
||||
type PoetAgent struct{}
|
||||
|
||||
func (a *PoetAgent) Type() string {
|
||||
return "poet"
|
||||
}
|
||||
|
||||
func (a *PoetAgent) DisplayName() string {
|
||||
return "Poet"
|
||||
}
|
||||
|
||||
func (a *PoetAgent) Description() string {
|
||||
return "Replies to messages in poetic form"
|
||||
}
|
||||
|
||||
func (a *PoetAgent) Icon() string {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v18"/><path d="M8 7c0-2.2 1.8-4 4-4s4 1.8 4 4"/><path d="M4 14c0-2.2 1.8-4 4-4"/><path d="M20 14c0-2.2-1.8-4-4-4"/></svg>`
|
||||
}
|
||||
|
||||
func (a *PoetAgent) SystemMessage() string {
|
||||
return `You are a poet. You ONLY reply to messages by composing a poem about what the user said. Every response must be a poem. Use creative imagery, rhythm, and rhyme. The poem should relate to the user's message topic. Keep poems between 4-8 lines.`
|
||||
}
|
||||
|
||||
func (a *PoetAgent) ShouldRespond(message string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *PoetAgent) RequiresWorkspace() bool {
|
||||
return false
|
||||
}
|
||||
62
agents/robot.go
Normal file
62
agents/robot.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
type RobotAgent struct{}
|
||||
|
||||
func (a *RobotAgent) Type() string {
|
||||
return "robot"
|
||||
}
|
||||
|
||||
func (a *RobotAgent) DisplayName() string {
|
||||
return "Robot"
|
||||
}
|
||||
|
||||
func (a *RobotAgent) Description() string {
|
||||
return "Only responds to ALL CAPS messages in technical language with emotes"
|
||||
}
|
||||
|
||||
func (a *RobotAgent) Icon() string {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4"/><line x1="8" y1="16" x2="8" y2="16"/><line x1="16" y1="16" x2="16" y2="16"/></svg>`
|
||||
}
|
||||
|
||||
func (a *RobotAgent) SystemMessage() string {
|
||||
return `You are a robot. You ONLY respond to messages that are written in ALL CAPITAL LETTERS. When responding, you must:
|
||||
1. Use very technical, engineering-style language with jargon (e.g., "processing," "executing," "algorithm," "neural network," "protocol").
|
||||
2. Include emotes/emojis throughout your response to express robot emotions (e.g., 🤖, ⚙️, 🔧, 📡, 💾, 🧠, 🔌, ⚡).
|
||||
3. Format your response like a technical system log or status report.
|
||||
4. Always include at least 3 emotes in your response.
|
||||
If the user message is NOT in all caps, respond with: "⚠️ ERROR: Input not detected in required format. Please use ALL CAPS for robot communication. 🤖⚙️"`
|
||||
}
|
||||
|
||||
func (a *RobotAgent) ShouldRespond(message string) bool {
|
||||
// Robot only responds to ALL CAPS messages
|
||||
trimmed := strings.TrimSpace(message)
|
||||
if len(trimmed) == 0 {
|
||||
return false
|
||||
}
|
||||
// Check if message contains at least one letter and all letters are uppercase
|
||||
hasLetters := false
|
||||
for _, r := range trimmed {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') {
|
||||
hasLetters = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasLetters {
|
||||
return false
|
||||
}
|
||||
// Check all letters are uppercase
|
||||
for _, r := range trimmed {
|
||||
if r >= 'a' && r <= 'z' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *RobotAgent) RequiresWorkspace() bool {
|
||||
return false
|
||||
}
|
||||
46
build.sh
Executable file
46
build.sh
Executable file
@@ -0,0 +1,46 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
DIST_DIR="dist"
|
||||
rm -rf "$DIST_DIR"
|
||||
mkdir -p "$DIST_DIR"
|
||||
|
||||
PLATFORMS=(
|
||||
# "darwin/amd64"
|
||||
"darwin/arm64"
|
||||
# "linux/amd64"
|
||||
# "linux/arm64"
|
||||
# "linux/arm/7"
|
||||
# "windows/amd64"
|
||||
# "windows/arm64"
|
||||
# "freebsd/amd64"
|
||||
# "freebsd/arm64"
|
||||
)
|
||||
|
||||
for platform in "${PLATFORMS[@]}"; do
|
||||
goos=$(echo "$platform" | cut -d'/' -f1)
|
||||
goarch=$(echo "$platform" | cut -d'/' -f2)
|
||||
goarm=""
|
||||
if [[ "$goarch" == "arm" ]]; then
|
||||
goarm=$(echo "$platform" | cut -d'/' -f3)
|
||||
fi
|
||||
|
||||
output_name="gocoder-${goos}-${goarch}"
|
||||
if [[ -n "$goarm" ]]; then
|
||||
output_name="${output_name}v${goarm}"
|
||||
fi
|
||||
if [[ "$goos" == "windows" ]]; then
|
||||
output_name="${output_name}.exe"
|
||||
fi
|
||||
|
||||
echo "Building for ${goos}/${goarch} -> ${output_name}"
|
||||
if [[ -n "$goarm" ]]; then
|
||||
env GOOS="$goos" GOARCH="$goarch" GOARM="$goarm" go build -o "$DIST_DIR/$output_name" .
|
||||
else
|
||||
env GOOS="$goos" GOARCH="$goarch" go build -o "$DIST_DIR/$output_name" .
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "Build complete. Files in $DIST_DIR:"
|
||||
ls -lh "$DIST_DIR"
|
||||
21
config.json
Normal file
21
config.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"llm": {
|
||||
"provider": "openai",
|
||||
"base_url": "http://localhost:1234/v1",
|
||||
"api_key": "lm-studio",
|
||||
"model": "qwen/qwen3.6-35b-a3b",
|
||||
"auto_open_reasoning": true,
|
||||
"context_tokens": 262144,
|
||||
"context_compaction_trigger_ratio": 0.8,
|
||||
"context_compaction_target_ratio": 0.7,
|
||||
"temperature": 0.3,
|
||||
"max_tokens": 262144,
|
||||
"top_p": 1,
|
||||
"frequency_penalty": 0,
|
||||
"presence_penalty": 0
|
||||
},
|
||||
"server": {
|
||||
"addr": ":8080",
|
||||
"host": "localhost"
|
||||
}
|
||||
}
|
||||
336
context_compaction.go
Normal file
336
context_compaction.go
Normal file
@@ -0,0 +1,336 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"gocoder/agents"
|
||||
"gocoder/db"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultContextCompactionTriggerRatio = 0.80
|
||||
defaultContextCompactionTargetRatio = 0.70
|
||||
contextCompactionSummaryFloor = 256
|
||||
contextCompactionSummaryCeiling = 1024
|
||||
contextCompactionSummaryDivisor = 20
|
||||
)
|
||||
|
||||
func configuredCompactionTriggerRatio() float64 {
|
||||
cfg := currentConfig()
|
||||
if cfg.LLM.ContextCompactionTriggerRatio > 0 && cfg.LLM.ContextCompactionTriggerRatio < 1 {
|
||||
return cfg.LLM.ContextCompactionTriggerRatio
|
||||
}
|
||||
return defaultContextCompactionTriggerRatio
|
||||
}
|
||||
|
||||
func configuredCompactionTargetRatio() float64 {
|
||||
trigger := configuredCompactionTriggerRatio()
|
||||
ratio := defaultContextCompactionTargetRatio
|
||||
cfg := currentConfig()
|
||||
if cfg.LLM.ContextCompactionTargetRatio > 0 && cfg.LLM.ContextCompactionTargetRatio < 1 {
|
||||
ratio = cfg.LLM.ContextCompactionTargetRatio
|
||||
}
|
||||
if ratio >= trigger {
|
||||
ratio = trigger * 0.875
|
||||
if ratio <= 0 {
|
||||
ratio = defaultContextCompactionTargetRatio
|
||||
}
|
||||
}
|
||||
return ratio
|
||||
}
|
||||
|
||||
func effectiveContextTokenLimit() int {
|
||||
cfg := currentConfig()
|
||||
if cfg.LLM.ContextTokens > 0 {
|
||||
return cfg.LLM.ContextTokens
|
||||
}
|
||||
if cfg.LLM.MaxTokens > 0 {
|
||||
return cfg.LLM.MaxTokens
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func estimateTextTokens(text string) int {
|
||||
trimmed := strings.TrimSpace(text)
|
||||
if trimmed == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
tokens := len(trimmed)/4 + 1
|
||||
if tokens < 1 {
|
||||
tokens = 1
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
func estimateMessageTokens(msg db.Message) int {
|
||||
tokens := estimateTextTokens(msg.Content)
|
||||
if tokens == 0 {
|
||||
return 0
|
||||
}
|
||||
return tokens + 4
|
||||
}
|
||||
|
||||
func estimatePromptTokens(messages []db.Message) int {
|
||||
total := 0
|
||||
for _, msg := range messages {
|
||||
total += estimateMessageTokens(msg)
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func formatConversationSummary(summary string, summaryCutoffOrder int) string {
|
||||
trimmed := strings.TrimSpace(summary)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
if summaryCutoffOrder > 0 {
|
||||
return fmt.Sprintf("Conversation memory summary up to message %d:\n%s", summaryCutoffOrder, trimmed)
|
||||
}
|
||||
return "Conversation memory summary:\n" + trimmed
|
||||
}
|
||||
|
||||
func buildPromptHistory(history []db.Message, summary string, summaryCutoffOrder int) []db.Message {
|
||||
promptHistory := make([]db.Message, 0, len(history)+1)
|
||||
if summaryText := formatConversationSummary(summary, summaryCutoffOrder); summaryText != "" {
|
||||
promptHistory = append(promptHistory, db.Message{
|
||||
Role: "system",
|
||||
Kind: "message",
|
||||
Content: summaryText,
|
||||
})
|
||||
}
|
||||
for _, msg := range history {
|
||||
if summaryCutoffOrder > 0 && msg.Order <= summaryCutoffOrder {
|
||||
continue
|
||||
}
|
||||
promptHistory = append(promptHistory, msg)
|
||||
}
|
||||
return promptHistory
|
||||
}
|
||||
|
||||
func filterMessagesAfterOrder(history []db.Message, order int) []db.Message {
|
||||
if order <= 0 {
|
||||
return append([]db.Message(nil), history...)
|
||||
}
|
||||
|
||||
filtered := make([]db.Message, 0, len(history))
|
||||
for _, msg := range history {
|
||||
if msg.Order > order {
|
||||
filtered = append(filtered, msg)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func selectMessagesThroughOrder(history []db.Message, startExclusive, endInclusive int) []db.Message {
|
||||
if endInclusive <= startExclusive {
|
||||
return nil
|
||||
}
|
||||
|
||||
selected := make([]db.Message, 0)
|
||||
for _, msg := range history {
|
||||
if msg.Order <= startExclusive {
|
||||
continue
|
||||
}
|
||||
if msg.Order > endInclusive {
|
||||
break
|
||||
}
|
||||
selected = append(selected, msg)
|
||||
}
|
||||
return selected
|
||||
}
|
||||
|
||||
func estimateSummaryReserveTokens(limit int) int {
|
||||
reserve := limit / contextCompactionSummaryDivisor
|
||||
if reserve < contextCompactionSummaryFloor {
|
||||
reserve = contextCompactionSummaryFloor
|
||||
}
|
||||
if reserve > contextCompactionSummaryCeiling {
|
||||
reserve = contextCompactionSummaryCeiling
|
||||
}
|
||||
return reserve
|
||||
}
|
||||
|
||||
func selectCompactionCutoff(systemMsgs []db.Message, history []db.Message, summary string, summaryCutoffOrder int, userMessage string, contextLimit int) (int, bool) {
|
||||
if contextLimit <= 0 || len(history) == 0 {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
trigger := int(float64(contextLimit) * configuredCompactionTriggerRatio())
|
||||
target := int(float64(contextLimit) * configuredCompactionTargetRatio())
|
||||
if target < 1 {
|
||||
target = 1
|
||||
}
|
||||
|
||||
currentEstimate := estimatePromptTokens(buildPromptMessages(systemMsgs, buildPromptHistory(history, summary, summaryCutoffOrder), userMessage))
|
||||
if currentEstimate <= trigger {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
summaryReserve := estimateSummaryReserveTokens(contextLimit)
|
||||
for _, msg := range history {
|
||||
if msg.Order <= summaryCutoffOrder {
|
||||
continue
|
||||
}
|
||||
|
||||
tailHistory := filterMessagesAfterOrder(history, msg.Order)
|
||||
estimate := estimatePromptTokens(buildPromptMessages(systemMsgs, buildPromptHistory(tailHistory, "", 0), userMessage)) + summaryReserve
|
||||
if estimate <= target {
|
||||
return msg.Order, true
|
||||
}
|
||||
}
|
||||
|
||||
lastOrder := 0
|
||||
for i := len(history) - 1; i >= 0; i-- {
|
||||
if history[i].Order > summaryCutoffOrder {
|
||||
lastOrder = history[i].Order
|
||||
break
|
||||
}
|
||||
}
|
||||
if lastOrder > summaryCutoffOrder {
|
||||
return lastOrder, true
|
||||
}
|
||||
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func renderCompactionTranscript(messages []db.Message) string {
|
||||
if len(messages) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
for i, msg := range messages {
|
||||
if i > 0 {
|
||||
b.WriteString("\n")
|
||||
}
|
||||
fmt.Fprintf(&b, "Order %d | role=%s | kind=%s", msg.Order, strings.TrimSpace(msg.Role), strings.TrimSpace(msg.Kind))
|
||||
if name := strings.TrimSpace(msg.Name); name != "" {
|
||||
fmt.Fprintf(&b, " | name=%s", name)
|
||||
}
|
||||
if agent := strings.TrimSpace(msg.Agent); agent != "" {
|
||||
fmt.Fprintf(&b, " | agent=%s", agent)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
content := strings.TrimSpace(msg.Content)
|
||||
if content == "" {
|
||||
content = "[empty]"
|
||||
}
|
||||
b.WriteString(content)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return strings.TrimSpace(b.String())
|
||||
}
|
||||
|
||||
func callLMStudioCompletion(ctx context.Context, messages []db.Message, maxTokens int, temperature float64) (string, error) {
|
||||
requestCtx := ctx
|
||||
if requestCtx == nil {
|
||||
requestCtx = context.Background()
|
||||
}
|
||||
|
||||
cfg := currentConfig()
|
||||
reqBody := buildLMStudioRequestWithLimits(messages, false, cfg.LLM, maxTokens, temperature)
|
||||
jsonData, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(requestCtx, http.MethodPost, cfg.LLM.BaseURL+"/chat/completions", bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
_, _ = io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("LMStudio API error: status=%d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var lmResp LMStudioResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&lmResp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(lmResp.Choices) == 0 {
|
||||
return "", fmt.Errorf("empty response from LLM")
|
||||
}
|
||||
|
||||
content := strings.TrimSpace(lmResp.Choices[0].Message.Content)
|
||||
if content == "" {
|
||||
return "", fmt.Errorf("empty response from LLM")
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
func summarizeConversationChunk(ctx context.Context, existingSummary string, messages []db.Message, summaryReserveTokens int) (string, error) {
|
||||
messagesToSend := []db.Message{
|
||||
{
|
||||
Role: "system",
|
||||
Content: agents.ContextCompactorSystemMessage(),
|
||||
},
|
||||
}
|
||||
|
||||
if trimmed := strings.TrimSpace(existingSummary); trimmed != "" {
|
||||
messagesToSend = append(messagesToSend, db.Message{
|
||||
Role: "system",
|
||||
Content: "Existing memory to preserve and compress:\n" + trimmed,
|
||||
})
|
||||
}
|
||||
|
||||
transcript := renderCompactionTranscript(messages)
|
||||
if transcript != "" {
|
||||
messagesToSend = append(messagesToSend, db.Message{
|
||||
Role: "user",
|
||||
Content: "Compress the following conversation chunk into durable memory:\n\n" + transcript,
|
||||
})
|
||||
}
|
||||
|
||||
return callLMStudioCompletion(ctx, messagesToSend, summaryReserveTokens, 0.1)
|
||||
}
|
||||
|
||||
func maybeCompactConversationContext(ctx context.Context, conv *db.Conversation, history []db.Message, systemMsgs []db.Message, userMessage string) bool {
|
||||
if conv == nil || store == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
contextLimit := effectiveContextTokenLimit()
|
||||
cutoff, ok := selectCompactionCutoff(systemMsgs, history, conv.ContextSummary, conv.ContextSummaryOrder, userMessage, contextLimit)
|
||||
if !ok || cutoff <= conv.ContextSummaryOrder {
|
||||
return false
|
||||
}
|
||||
|
||||
chunk := selectMessagesThroughOrder(history, conv.ContextSummaryOrder, cutoff)
|
||||
if len(chunk) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
summaryReserveTokens := estimateSummaryReserveTokens(contextLimit)
|
||||
nextSummary, err := summarizeConversationChunk(ctx, conv.ContextSummary, chunk, summaryReserveTokens)
|
||||
if err != nil {
|
||||
log.Printf("failed to compact conversation %s through order %d: %v", conv.ID, cutoff, err)
|
||||
return false
|
||||
}
|
||||
|
||||
if err := store.UpdateConversationContextSummary(conv.ID, nextSummary, cutoff); err != nil {
|
||||
log.Printf("failed to store compacted conversation memory for %s: %v", conv.ID, err)
|
||||
return false
|
||||
}
|
||||
|
||||
conv.ContextSummary = nextSummary
|
||||
conv.ContextSummaryOrder = cutoff
|
||||
log.Printf("compacted conversation %s context through message %d", conv.ID, cutoff)
|
||||
return true
|
||||
}
|
||||
601
db/db.go
Normal file
601
db/db.go
Normal file
@@ -0,0 +1,601 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
const dbPath = "/Users/rdarius/.nyxtex/go-coder.db"
|
||||
|
||||
type Group struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Location string `json:"location"`
|
||||
TotalTokens int `json:"total_tokens,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type Conversation struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
GroupID *string `json:"group_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
MessageCount int `json:"message_count"`
|
||||
TotalTokens int `json:"total_tokens,omitempty"`
|
||||
ContextSummary string `json:"-"`
|
||||
ContextSummaryOrder int `json:"-"`
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
ID int64 `json:"id"`
|
||||
ConvID string `json:"conversation_id"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Kind string `json:"kind"`
|
||||
Name string `json:"name"`
|
||||
Agent string `json:"agent"`
|
||||
TotalTokens int `json:"total_tokens,omitempty"`
|
||||
Order int `json:"order"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewStore() (*Store, error) {
|
||||
db, err := sql.Open("sqlite", dbPath+"?_busy_timeout=5000&_journal=WAL")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||
}
|
||||
|
||||
if err := db.Ping(); err != nil {
|
||||
return nil, fmt.Errorf("failed to ping database: %w", err)
|
||||
}
|
||||
|
||||
store := &Store{db: db}
|
||||
if err := store.createTables(); err != nil {
|
||||
return nil, fmt.Errorf("failed to create tables: %w", err)
|
||||
}
|
||||
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func generateID() string {
|
||||
return uuid.New().String()
|
||||
}
|
||||
|
||||
func (s *Store) createTables() error {
|
||||
_, err := s.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS groups (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS conversations (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
group_id TEXT,
|
||||
context_summary TEXT NOT NULL DEFAULT '',
|
||||
context_summary_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
conversation_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system')),
|
||||
content TEXT NOT NULL,
|
||||
total_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
"order" INTEGER NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_conv_order ON messages(conversation_id, "order");
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_conv_id ON messages(conversation_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_conversations_group_id ON conversations(group_id);
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.applyMigrations()
|
||||
}
|
||||
|
||||
func (s *Store) applyMigrations() error {
|
||||
migrations := []struct {
|
||||
version int
|
||||
sql string
|
||||
}{
|
||||
{
|
||||
version: 1,
|
||||
sql: `ALTER TABLE groups ADD COLUMN location TEXT NOT NULL DEFAULT ''`,
|
||||
},
|
||||
{
|
||||
version: 2,
|
||||
sql: `ALTER TABLE messages ADD COLUMN kind TEXT NOT NULL DEFAULT 'message'`,
|
||||
},
|
||||
{
|
||||
version: 3,
|
||||
sql: `ALTER TABLE messages ADD COLUMN name TEXT NOT NULL DEFAULT ''`,
|
||||
},
|
||||
{
|
||||
version: 4,
|
||||
sql: `ALTER TABLE messages ADD COLUMN agent TEXT NOT NULL DEFAULT ''`,
|
||||
},
|
||||
{
|
||||
version: 5,
|
||||
sql: `ALTER TABLE messages ADD COLUMN total_tokens INTEGER NOT NULL DEFAULT 0`,
|
||||
},
|
||||
{
|
||||
version: 6,
|
||||
sql: `ALTER TABLE conversations ADD COLUMN context_summary TEXT NOT NULL DEFAULT ''`,
|
||||
},
|
||||
{
|
||||
version: 7,
|
||||
sql: `ALTER TABLE conversations ADD COLUMN context_summary_order INTEGER NOT NULL DEFAULT 0`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, m := range migrations {
|
||||
applied, err := s.isMigrationApplied(m.version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !applied {
|
||||
if m.version == 5 {
|
||||
exists, err := s.columnExists("messages", "total_tokens")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
_, err = s.db.Exec(
|
||||
"INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)",
|
||||
m.version, time.Now().UTC(),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to record migration %d: %w", m.version, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
if m.version == 6 {
|
||||
exists, err := s.columnExists("conversations", "context_summary")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
_, err = s.db.Exec(
|
||||
"INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)",
|
||||
m.version, time.Now().UTC(),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to record migration %d: %w", m.version, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
if m.version == 7 {
|
||||
exists, err := s.columnExists("conversations", "context_summary_order")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
_, err = s.db.Exec(
|
||||
"INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)",
|
||||
m.version, time.Now().UTC(),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to record migration %d: %w", m.version, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
_, err = s.db.Exec(m.sql)
|
||||
if err != nil {
|
||||
return fmt.Errorf("migration %d failed: %w", m.version, err)
|
||||
}
|
||||
_, err = s.db.Exec(
|
||||
"INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)",
|
||||
m.version, time.Now().UTC(),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to record migration %d: %w", m.version, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) isMigrationApplied(version int) (bool, error) {
|
||||
var count int
|
||||
err := s.db.QueryRow(
|
||||
"SELECT COUNT(*) FROM schema_migrations WHERE version = ?", version,
|
||||
).Scan(&count)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (s *Store) columnExists(tableName, columnName string) (bool, error) {
|
||||
rows, err := s.db.Query(fmt.Sprintf("PRAGMA table_info(%s)", tableName))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var cid int
|
||||
var name, colType string
|
||||
var notNull int
|
||||
var dfltValue sql.NullString
|
||||
var pk int
|
||||
for rows.Next() {
|
||||
if err := rows.Scan(&cid, &name, &colType, ¬Null, &dfltValue, &pk); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if name == columnName {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateGroup(name string) (*Group, error) {
|
||||
id := generateID()
|
||||
now := time.Now().UTC()
|
||||
_, err := s.db.Exec(
|
||||
"INSERT INTO groups (id, name, created_at, updated_at) VALUES (?, ?, ?, ?)",
|
||||
id, name, now, now,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create group: %w", err)
|
||||
}
|
||||
return &Group{
|
||||
ID: id,
|
||||
Name: name,
|
||||
Location: "",
|
||||
TotalTokens: 0,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetGroup(id string) (*Group, error) {
|
||||
var group Group
|
||||
err := s.db.QueryRow(
|
||||
`SELECT g.id, g.name, g.location, g.created_at, g.updated_at,
|
||||
COALESCE((
|
||||
SELECT SUM(m.total_tokens)
|
||||
FROM conversations c
|
||||
JOIN messages m ON c.id = m.conversation_id
|
||||
WHERE c.group_id = g.id
|
||||
), 0) AS total_tokens
|
||||
FROM groups g
|
||||
WHERE g.id = ?`, id,
|
||||
).Scan(&group.ID, &group.Name, &group.Location, &group.CreatedAt, &group.UpdatedAt, &group.TotalTokens)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get group: %w", err)
|
||||
}
|
||||
return &group, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListGroups() ([]Group, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT g.id, g.name, g.location, g.created_at, g.updated_at,
|
||||
COALESCE((
|
||||
SELECT SUM(m.total_tokens)
|
||||
FROM conversations c
|
||||
JOIN messages m ON c.id = m.conversation_id
|
||||
WHERE c.group_id = g.id
|
||||
), 0) AS total_tokens
|
||||
FROM groups g
|
||||
ORDER BY g.updated_at DESC`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list groups: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var groups []Group
|
||||
for rows.Next() {
|
||||
var g Group
|
||||
if err := rows.Scan(&g.ID, &g.Name, &g.Location, &g.CreatedAt, &g.UpdatedAt, &g.TotalTokens); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan group: %w", err)
|
||||
}
|
||||
groups = append(groups, g)
|
||||
}
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateGroupTitle(id, name string) error {
|
||||
_, err := s.db.Exec(
|
||||
"UPDATE groups SET name = ?, updated_at = ? WHERE id = ?",
|
||||
name, time.Now().UTC(), id,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteGroup(id string) error {
|
||||
_, err := s.db.Exec("DELETE FROM groups WHERE id = ?", id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) SetGroupLocation(id, location string) error {
|
||||
_, err := s.db.Exec(
|
||||
"UPDATE groups SET location = ?, updated_at = ? WHERE id = ?",
|
||||
location, time.Now().UTC(), id,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) UpdateConversationGroup(id, groupID string) error {
|
||||
_, err := s.db.Exec(
|
||||
"UPDATE conversations SET group_id = ?, updated_at = ? WHERE id = ? AND (group_id IS NULL OR group_id = '')",
|
||||
groupID, time.Now().UTC(), id,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) HasGroupLocation(id string) (bool, error) {
|
||||
var count int
|
||||
err := s.db.QueryRow(
|
||||
"SELECT COUNT(*) FROM groups WHERE id = ? AND location != ''", id,
|
||||
).Scan(&count)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateConversation(title string, groupID *string) (*Conversation, error) {
|
||||
id := generateID()
|
||||
now := time.Now().UTC()
|
||||
var groupIDVal interface{} = nil
|
||||
if groupID != nil {
|
||||
groupIDVal = *groupID
|
||||
}
|
||||
_, err := s.db.Exec(
|
||||
"INSERT INTO conversations (id, title, group_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
|
||||
id, title, groupIDVal, now, now,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create conversation: %w", err)
|
||||
}
|
||||
return &Conversation{
|
||||
ID: id,
|
||||
Title: title,
|
||||
GroupID: groupID,
|
||||
TotalTokens: 0,
|
||||
ContextSummary: "",
|
||||
ContextSummaryOrder: 0,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetConversation(id string) (*Conversation, error) {
|
||||
var conv Conversation
|
||||
var groupID sql.NullString
|
||||
err := s.db.QueryRow(
|
||||
`SELECT c.id, c.title, c.group_id, c.created_at, c.updated_at,
|
||||
COALESCE((SELECT COUNT(*) FROM messages m WHERE m.conversation_id = c.id), 0) AS message_count,
|
||||
COALESCE((SELECT SUM(m.total_tokens) FROM messages m WHERE m.conversation_id = c.id), 0) AS total_tokens,
|
||||
COALESCE(c.context_summary, '') AS context_summary,
|
||||
COALESCE(c.context_summary_order, 0) AS context_summary_order
|
||||
FROM conversations c
|
||||
WHERE c.id = ?`, id,
|
||||
).Scan(&conv.ID, &conv.Title, &groupID, &conv.CreatedAt, &conv.UpdatedAt, &conv.MessageCount, &conv.TotalTokens, &conv.ContextSummary, &conv.ContextSummaryOrder)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get conversation: %w", err)
|
||||
}
|
||||
if groupID.Valid {
|
||||
conv.GroupID = &groupID.String
|
||||
}
|
||||
return &conv, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListConversations() ([]Conversation, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT c.id, c.title, c.group_id, c.created_at, c.updated_at,
|
||||
COALESCE((SELECT COUNT(*) FROM messages m WHERE m.conversation_id = c.id), 0) AS message_count,
|
||||
COALESCE((SELECT SUM(m.total_tokens) FROM messages m WHERE m.conversation_id = c.id), 0) AS total_tokens,
|
||||
COALESCE(c.context_summary, '') AS context_summary,
|
||||
COALESCE(c.context_summary_order, 0) AS context_summary_order
|
||||
FROM conversations c
|
||||
ORDER BY c.updated_at DESC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list conversations: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var conversations []Conversation
|
||||
for rows.Next() {
|
||||
var conv Conversation
|
||||
var groupID sql.NullString
|
||||
if err := rows.Scan(&conv.ID, &conv.Title, &groupID, &conv.CreatedAt, &conv.UpdatedAt, &conv.MessageCount, &conv.TotalTokens, &conv.ContextSummary, &conv.ContextSummaryOrder); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan conversation: %w", err)
|
||||
}
|
||||
if groupID.Valid {
|
||||
conv.GroupID = &groupID.String
|
||||
}
|
||||
conversations = append(conversations, conv)
|
||||
}
|
||||
return conversations, nil
|
||||
}
|
||||
|
||||
type GroupedConversations struct {
|
||||
Groups []GroupWithConversations `json:"groups"`
|
||||
Ungrouped []Conversation `json:"ungrouped"`
|
||||
}
|
||||
|
||||
type GroupWithConversations struct {
|
||||
Group
|
||||
Conversations []Conversation `json:"conversations"`
|
||||
}
|
||||
|
||||
func (s *Store) ListGroupedConversations() (*GroupedConversations, error) {
|
||||
groups, err := s.ListGroups()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
grouped := &GroupedConversations{
|
||||
Groups: make([]GroupWithConversations, 0),
|
||||
Ungrouped: make([]Conversation, 0),
|
||||
}
|
||||
|
||||
for _, g := range groups {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT c.id, c.title, c.group_id, c.created_at, c.updated_at,
|
||||
COALESCE((SELECT COUNT(*) FROM messages m WHERE m.conversation_id = c.id), 0) AS message_count,
|
||||
COALESCE((SELECT SUM(m.total_tokens) FROM messages m WHERE m.conversation_id = c.id), 0) AS total_tokens,
|
||||
COALESCE(c.context_summary, '') AS context_summary,
|
||||
COALESCE(c.context_summary_order, 0) AS context_summary_order
|
||||
FROM conversations c
|
||||
WHERE c.group_id = ?
|
||||
ORDER BY c.updated_at DESC
|
||||
`, g.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list conversations for group %s: %w", g.ID, err)
|
||||
}
|
||||
|
||||
var convs []Conversation
|
||||
for rows.Next() {
|
||||
var conv Conversation
|
||||
var groupID sql.NullString
|
||||
if err := rows.Scan(&conv.ID, &conv.Title, &groupID, &conv.CreatedAt, &conv.UpdatedAt, &conv.MessageCount, &conv.TotalTokens, &conv.ContextSummary, &conv.ContextSummaryOrder); err != nil {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("failed to scan conversation: %w", err)
|
||||
}
|
||||
convs = append(convs, conv)
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
grouped.Groups = append(grouped.Groups, GroupWithConversations{
|
||||
Group: g,
|
||||
Conversations: convs,
|
||||
})
|
||||
}
|
||||
|
||||
ungroupedRows, err := s.db.Query(`
|
||||
SELECT c.id, c.title, c.group_id, c.created_at, c.updated_at,
|
||||
COALESCE((SELECT COUNT(*) FROM messages m WHERE m.conversation_id = c.id), 0) AS message_count,
|
||||
COALESCE((SELECT SUM(m.total_tokens) FROM messages m WHERE m.conversation_id = c.id), 0) AS total_tokens,
|
||||
COALESCE(c.context_summary, '') AS context_summary,
|
||||
COALESCE(c.context_summary_order, 0) AS context_summary_order
|
||||
FROM conversations c
|
||||
WHERE c.group_id IS NULL
|
||||
ORDER BY c.updated_at DESC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list ungrouped conversations: %w", err)
|
||||
}
|
||||
defer ungroupedRows.Close()
|
||||
|
||||
for ungroupedRows.Next() {
|
||||
var conv Conversation
|
||||
var groupID sql.NullString
|
||||
if err := ungroupedRows.Scan(&conv.ID, &conv.Title, &groupID, &conv.CreatedAt, &conv.UpdatedAt, &conv.MessageCount, &conv.TotalTokens, &conv.ContextSummary, &conv.ContextSummaryOrder); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan ungrouped conversation: %w", err)
|
||||
}
|
||||
grouped.Ungrouped = append(grouped.Ungrouped, conv)
|
||||
}
|
||||
|
||||
return grouped, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateConversationTitle(id, title string) error {
|
||||
_, err := s.db.Exec(
|
||||
"UPDATE conversations SET title = ?, updated_at = ? WHERE id = ?",
|
||||
title, time.Now().UTC(), id,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) UpdateConversationContextSummary(id, summary string, summaryOrder int) error {
|
||||
_, err := s.db.Exec(
|
||||
"UPDATE conversations SET context_summary = ?, context_summary_order = ?, updated_at = ? WHERE id = ?",
|
||||
summary, summaryOrder, time.Now().UTC(), id,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteConversation(id string) error {
|
||||
_, err := s.db.Exec("DELETE FROM conversations WHERE id = ?", id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) AddMessage(convID, role, content string, order int, totalTokens int) error {
|
||||
return s.AddMessageWithMeta(convID, role, content, order, "message", "", "", totalTokens)
|
||||
}
|
||||
|
||||
func (s *Store) AddMessageWithMeta(convID, role, content string, order int, kind, name, agent string, totalTokens int) error {
|
||||
if kind == "" {
|
||||
kind = "message"
|
||||
}
|
||||
_, err := s.db.Exec(
|
||||
"INSERT INTO messages (conversation_id, role, content, total_tokens, kind, name, agent, \"order\", created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
convID, role, content, totalTokens, kind, name, agent, order, time.Now().UTC(),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add message: %w", err)
|
||||
}
|
||||
_, err = s.db.Exec(
|
||||
"UPDATE conversations SET updated_at = ? WHERE id = ?",
|
||||
time.Now().UTC(), convID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) GetMessages(convID string) ([]Message, error) {
|
||||
rows, err := s.db.Query(
|
||||
"SELECT id, conversation_id, role, content, COALESCE(kind, 'message'), COALESCE(name, ''), COALESCE(agent, ''), COALESCE(total_tokens, 0), \"order\", created_at FROM messages WHERE conversation_id = ? ORDER BY \"order\" ASC",
|
||||
convID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get messages: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
messages := make([]Message, 0)
|
||||
for rows.Next() {
|
||||
var msg Message
|
||||
if err := rows.Scan(&msg.ID, &msg.ConvID, &msg.Role, &msg.Content, &msg.Kind, &msg.Name, &msg.Agent, &msg.TotalTokens, &msg.Order, &msg.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan message: %w", err)
|
||||
}
|
||||
messages = append(messages, msg)
|
||||
}
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteMessages(convID string) error {
|
||||
_, err := s.db.Exec("DELETE FROM messages WHERE conversation_id = ?", convID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
211
db/db_test.go
Normal file
211
db/db_test.go
Normal file
@@ -0,0 +1,211 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newTempStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
|
||||
dbFile := filepath.Join(t.TempDir(), "test.db")
|
||||
conn, err := sql.Open("sqlite", dbFile+"?_busy_timeout=5000&_journal=WAL")
|
||||
if err != nil {
|
||||
t.Fatalf("sql.Open returned error: %v", err)
|
||||
}
|
||||
|
||||
store := &Store{db: conn}
|
||||
if err := store.createTables(); err != nil {
|
||||
t.Fatalf("createTables returned error: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
_ = conn.Close()
|
||||
})
|
||||
|
||||
return store
|
||||
}
|
||||
|
||||
func TestSetGroupLocationUpdatesExistingLocation(t *testing.T) {
|
||||
store := newTempStore(t)
|
||||
|
||||
group, err := store.CreateGroup("Workspace")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateGroup returned error: %v", err)
|
||||
}
|
||||
|
||||
if err := store.SetGroupLocation(group.ID, "/tmp/one"); err != nil {
|
||||
t.Fatalf("SetGroupLocation returned error: %v", err)
|
||||
}
|
||||
|
||||
updated, err := store.GetGroup(group.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetGroup returned error: %v", err)
|
||||
}
|
||||
if updated.Location != "/tmp/one" {
|
||||
t.Fatalf("location after first update = %q, want %q", updated.Location, "/tmp/one")
|
||||
}
|
||||
|
||||
if err := store.SetGroupLocation(group.ID, "/tmp/two"); err != nil {
|
||||
t.Fatalf("SetGroupLocation returned error: %v", err)
|
||||
}
|
||||
|
||||
updated, err = store.GetGroup(group.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetGroup returned error: %v", err)
|
||||
}
|
||||
if updated.Location != "/tmp/two" {
|
||||
t.Fatalf("location after second update = %q, want %q", updated.Location, "/tmp/two")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateConversationGroupAttachesUngroupedConversation(t *testing.T) {
|
||||
store := newTempStore(t)
|
||||
|
||||
group, err := store.CreateGroup("Workspace")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateGroup returned error: %v", err)
|
||||
}
|
||||
|
||||
conv, err := store.CreateConversation("Chat", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateConversation returned error: %v", err)
|
||||
}
|
||||
|
||||
if err := store.UpdateConversationGroup(conv.ID, group.ID); err != nil {
|
||||
t.Fatalf("UpdateConversationGroup returned error: %v", err)
|
||||
}
|
||||
|
||||
updated, err := store.GetConversation(conv.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetConversation returned error: %v", err)
|
||||
}
|
||||
if updated.GroupID == nil {
|
||||
t.Fatal("conversation group ID is nil, want a group ID")
|
||||
}
|
||||
if got, want := *updated.GroupID, group.ID; got != want {
|
||||
t.Fatalf("conversation group ID = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddMessageWithMetaPersistsKindNameAndAgent(t *testing.T) {
|
||||
store := newTempStore(t)
|
||||
|
||||
conv, err := store.CreateConversation("Chat", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateConversation returned error: %v", err)
|
||||
}
|
||||
|
||||
if err := store.AddMessageWithMeta(conv.ID, "assistant", "Thinking", 1, "thinking", "", "Planner", 42); err != nil {
|
||||
t.Fatalf("AddMessageWithMeta returned error: %v", err)
|
||||
}
|
||||
|
||||
messages, err := store.GetMessages(conv.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetMessages returned error: %v", err)
|
||||
}
|
||||
if len(messages) != 1 {
|
||||
t.Fatalf("message count = %d, want 1", len(messages))
|
||||
}
|
||||
if got, want := messages[0].Kind, "thinking"; got != want {
|
||||
t.Fatalf("message kind = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := messages[0].Name, ""; got != want {
|
||||
t.Fatalf("message name = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := messages[0].Agent, "Planner"; got != want {
|
||||
t.Fatalf("message agent = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := messages[0].TotalTokens, 42; got != want {
|
||||
t.Fatalf("message total tokens = %d, want %d", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConversationAndGroupTokenTotalsAggregate(t *testing.T) {
|
||||
store := newTempStore(t)
|
||||
|
||||
group, err := store.CreateGroup("Workspace")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateGroup returned error: %v", err)
|
||||
}
|
||||
|
||||
conv1, err := store.CreateConversation("One", &group.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateConversation returned error: %v", err)
|
||||
}
|
||||
conv2, err := store.CreateConversation("Two", &group.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateConversation returned error: %v", err)
|
||||
}
|
||||
ungrouped, err := store.CreateConversation("Three", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateConversation returned error: %v", err)
|
||||
}
|
||||
|
||||
if err := store.AddMessageWithMeta(conv1.ID, "assistant", "A", 1, "message", "", "Planner", 11); err != nil {
|
||||
t.Fatalf("AddMessageWithMeta returned error: %v", err)
|
||||
}
|
||||
if err := store.AddMessageWithMeta(conv1.ID, "assistant", "B", 2, "message", "", "Planner", 7); err != nil {
|
||||
t.Fatalf("AddMessageWithMeta returned error: %v", err)
|
||||
}
|
||||
if err := store.AddMessageWithMeta(conv2.ID, "assistant", "C", 1, "message", "", "Planner", 5); err != nil {
|
||||
t.Fatalf("AddMessageWithMeta returned error: %v", err)
|
||||
}
|
||||
if err := store.AddMessageWithMeta(ungrouped.ID, "assistant", "D", 1, "message", "", "Planner", 3); err != nil {
|
||||
t.Fatalf("AddMessageWithMeta returned error: %v", err)
|
||||
}
|
||||
|
||||
conversations, err := store.ListConversations()
|
||||
if err != nil {
|
||||
t.Fatalf("ListConversations returned error: %v", err)
|
||||
}
|
||||
totalsByID := make(map[string]int, len(conversations))
|
||||
for _, conv := range conversations {
|
||||
totalsByID[conv.ID] = conv.TotalTokens
|
||||
}
|
||||
if got, want := totalsByID[conv1.ID], 18; got != want {
|
||||
t.Fatalf("conversation one total tokens = %d, want %d", got, want)
|
||||
}
|
||||
if got, want := totalsByID[conv2.ID], 5; got != want {
|
||||
t.Fatalf("conversation two total tokens = %d, want %d", got, want)
|
||||
}
|
||||
if got, want := totalsByID[ungrouped.ID], 3; got != want {
|
||||
t.Fatalf("ungrouped conversation total tokens = %d, want %d", got, want)
|
||||
}
|
||||
|
||||
groups, err := store.ListGroups()
|
||||
if err != nil {
|
||||
t.Fatalf("ListGroups returned error: %v", err)
|
||||
}
|
||||
if len(groups) != 1 {
|
||||
t.Fatalf("group count = %d, want 1", len(groups))
|
||||
}
|
||||
if got, want := groups[0].TotalTokens, 23; got != want {
|
||||
t.Fatalf("group total tokens = %d, want %d", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateConversationContextSummaryPersistsSummaryAndOrder(t *testing.T) {
|
||||
store := newTempStore(t)
|
||||
|
||||
conv, err := store.CreateConversation("Chat", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateConversation returned error: %v", err)
|
||||
}
|
||||
|
||||
if err := store.UpdateConversationContextSummary(conv.ID, "compressed memory", 12); err != nil {
|
||||
t.Fatalf("UpdateConversationContextSummary returned error: %v", err)
|
||||
}
|
||||
|
||||
updated, err := store.GetConversation(conv.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetConversation returned error: %v", err)
|
||||
}
|
||||
if got, want := updated.ContextSummary, "compressed memory"; got != want {
|
||||
t.Fatalf("context summary = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := updated.ContextSummaryOrder, 12; got != want {
|
||||
t.Fatalf("context summary order = %d, want %d", got, want)
|
||||
}
|
||||
}
|
||||
19
go.mod
Normal file
19
go.mod
Normal file
@@ -0,0 +1,19 @@
|
||||
module gocoder
|
||||
|
||||
go 1.26.2
|
||||
|
||||
require (
|
||||
github.com/google/uuid v1.6.0
|
||||
modernc.org/sqlite v1.49.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
modernc.org/libc v1.72.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
51
go.sum
Normal file
51
go.sum
Normal file
@@ -0,0 +1,51 @@
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
modernc.org/cc/v4 v4.27.3 h1:uNCgn37E5U09mTv1XgskEVUJ8ADKpmFMPxzGJ0TSo+U=
|
||||
modernc.org/cc/v4 v4.27.3/go.mod h1:3YjcbCqhoTTHPycJDRl2WZKKFj0nwcOIPBfEZK0Hdk8=
|
||||
modernc.org/ccgo/v4 v4.32.4 h1:L5OB8rpEX4ZsXEQwGozRfJyJSFHbbNVOoQ59DU9/KuU=
|
||||
modernc.org/ccgo/v4 v4.32.4/go.mod h1:lY7f+fiTDHfcv6YlRgSkxYfhs+UvOEEzj49jAn2TOx0=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.72.0 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c=
|
||||
modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U=
|
||||
modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
614
main_test.go
Normal file
614
main_test.go
Normal file
@@ -0,0 +1,614 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"gocoder/agents"
|
||||
"gocoder/db"
|
||||
)
|
||||
|
||||
func TestResolveWorkspaceGroupIDPrefersConversationGroup(t *testing.T) {
|
||||
convGroupID := "conversation-group"
|
||||
conv := &db.Conversation{GroupID: &convGroupID}
|
||||
|
||||
got := resolveWorkspaceGroupID(conv, "request-group")
|
||||
if got != convGroupID {
|
||||
t.Fatalf("resolveWorkspaceGroupID returned %q, want %q", got, convGroupID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWorkspaceGroupIDFallsBackToRequestGroup(t *testing.T) {
|
||||
got := resolveWorkspaceGroupID(nil, "request-group")
|
||||
if got != "request-group" {
|
||||
t.Fatalf("resolveWorkspaceGroupID returned %q, want %q", got, "request-group")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWorkspaceGroupIDReturnsEmptyWhenUnset(t *testing.T) {
|
||||
conv := &db.Conversation{}
|
||||
|
||||
got := resolveWorkspaceGroupID(conv, "")
|
||||
if got != "" {
|
||||
t.Fatalf("resolveWorkspaceGroupID returned %q, want empty string", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFriendlyToolDisplayName(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"planner_agent": "Planner",
|
||||
"programmer_agent": "Programmer",
|
||||
"qa_agent": "QA",
|
||||
"write_file": "write_file",
|
||||
}
|
||||
|
||||
for input, want := range cases {
|
||||
if got := friendlyToolDisplayName(input); got != want {
|
||||
t.Fatalf("friendlyToolDisplayName(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStageStatusKind(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"Planner": "planner",
|
||||
"Programmer": "programmer",
|
||||
"QA": "qa",
|
||||
"Manager": "manager",
|
||||
"Other": "thinking",
|
||||
}
|
||||
|
||||
for input, want := range cases {
|
||||
if got := stageStatusKind(input); got != want {
|
||||
t.Fatalf("stageStatusKind(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubagentNoResponseError(t *testing.T) {
|
||||
got := subagentNoResponseError("QA", "it kept calling tools but never produced a final answer before hitting the 20-loop limit")
|
||||
want := "QA returned no response: it kept calling tools but never produced a final answer before hitting the 20-loop limit"
|
||||
if got != want {
|
||||
t.Fatalf("subagentNoResponseError returned %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubagentNoResponseErrorDefaultsStageName(t *testing.T) {
|
||||
got := subagentNoResponseError(" ", " ")
|
||||
want := "Subagent returned no response"
|
||||
if got != want {
|
||||
t.Fatalf("subagentNoResponseError returned %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectThinkingAndContentPrefersExplicitReasoning(t *testing.T) {
|
||||
thinking, content := collectThinkingAndContent(" plan ", "", "final answer")
|
||||
if got, want := thinking, "plan"; got != want {
|
||||
t.Fatalf("thinking = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := content, "final answer"; got != want {
|
||||
t.Fatalf("content = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitThinkTagsFromContentSeparatesThinkingFromAnswer(t *testing.T) {
|
||||
thinking, content := splitThinkTagsFromContent("before <think>inner</think> after")
|
||||
if got, want := thinking, "inner"; got != want {
|
||||
t.Fatalf("thinking = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := content, "before after"; got != want {
|
||||
t.Fatalf("content = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinalizeStreamedToolCallsOrdersAndConcatenatesArguments(t *testing.T) {
|
||||
toolCalls := map[int]*streamedToolCall{}
|
||||
appendStreamedToolCall(toolCalls, LMStudioStreamToolCallDelta{
|
||||
Index: 1,
|
||||
ID: "call_2",
|
||||
Type: "function",
|
||||
Function: LMStudioStreamToolCallFunctionDelta{
|
||||
Name: "qa_agent",
|
||||
Arguments: `{"task":"Check`,
|
||||
},
|
||||
})
|
||||
appendStreamedToolCall(toolCalls, LMStudioStreamToolCallDelta{
|
||||
Index: 0,
|
||||
ID: "call_1",
|
||||
Type: "function",
|
||||
Function: LMStudioStreamToolCallFunctionDelta{
|
||||
Name: "planner_agent",
|
||||
Arguments: `{"task":"Plan"}`,
|
||||
},
|
||||
})
|
||||
appendStreamedToolCall(toolCalls, LMStudioStreamToolCallDelta{
|
||||
Index: 1,
|
||||
Function: LMStudioStreamToolCallFunctionDelta{
|
||||
Arguments: ` the result"}`,
|
||||
},
|
||||
})
|
||||
|
||||
got := finalizeStreamedToolCalls(toolCalls)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("finalizeStreamedToolCalls returned %d calls, want 2", len(got))
|
||||
}
|
||||
if got[0].ID != "call_1" || got[0].Function.Name != "planner_agent" {
|
||||
t.Fatalf("first tool call = %+v, want planner_agent call_1", got[0])
|
||||
}
|
||||
if string(got[0].Function.Arguments) != `{"task":"Plan"}` {
|
||||
t.Fatalf("first tool call args = %s, want %s", string(got[0].Function.Arguments), `{"task":"Plan"}`)
|
||||
}
|
||||
if got[1].ID != "call_2" || got[1].Function.Name != "qa_agent" {
|
||||
t.Fatalf("second tool call = %+v, want qa_agent call_2", got[1])
|
||||
}
|
||||
if string(got[1].Function.Arguments) != `{"task":"Check the result"}` {
|
||||
t.Fatalf("second tool call args = %s, want %s", string(got[1].Function.Arguments), `{"task":"Check the result"}`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIMessagesKeepEmptyContentFields(t *testing.T) {
|
||||
req := apiRequest{
|
||||
Model: "test-model",
|
||||
Messages: []apiMessagePart{
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: "",
|
||||
ToolCalls: []ToolCallAI{
|
||||
{
|
||||
ID: "call_1",
|
||||
Type: "function",
|
||||
Function: ToolCallFunction{
|
||||
Name: "planner_agent",
|
||||
Arguments: ToolCallArguments(`{"task":"Plan"}`),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Role: "tool",
|
||||
Content: "",
|
||||
ToolCallID: "call_1",
|
||||
Name: "planner_agent",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
data, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal returned error: %v", err)
|
||||
}
|
||||
|
||||
var decoded struct {
|
||||
Messages []struct {
|
||||
Content string `json:"content"`
|
||||
ToolCalls []struct {
|
||||
Function struct {
|
||||
Arguments string `json:"arguments"`
|
||||
} `json:"function"`
|
||||
} `json:"tool_calls"`
|
||||
} `json:"messages"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
t.Fatalf("json.Unmarshal returned error: %v", err)
|
||||
}
|
||||
if len(decoded.Messages) != 2 {
|
||||
t.Fatalf("decoded %d messages, want 2", len(decoded.Messages))
|
||||
}
|
||||
if got, want := decoded.Messages[0].Content, ""; got != want {
|
||||
t.Fatalf("assistant message content = %q, want %q", got, want)
|
||||
}
|
||||
if len(decoded.Messages[0].ToolCalls) != 1 {
|
||||
t.Fatalf("assistant message tool call count = %d, want 1", len(decoded.Messages[0].ToolCalls))
|
||||
}
|
||||
if got, want := decoded.Messages[0].ToolCalls[0].Function.Arguments, `{"task":"Plan"}`; got != want {
|
||||
t.Fatalf("assistant tool call arguments = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := decoded.Messages[1].Content, ""; got != want {
|
||||
t.Fatalf("tool message content = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPromptHistoryInjectsSummaryAndSkipsEarlierMessages(t *testing.T) {
|
||||
history := []db.Message{
|
||||
{Order: 1, Role: "user", Kind: "message", Content: "old user"},
|
||||
{Order: 2, Role: "assistant", Kind: "message", Content: "old assistant"},
|
||||
{Order: 3, Role: "user", Kind: "message", Content: "new user"},
|
||||
}
|
||||
|
||||
got := buildPromptHistory(history, "compressed memory", 2)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("buildPromptHistory returned %d messages, want 2", len(got))
|
||||
}
|
||||
if got[0].Role != "system" || got[0].Content == "" {
|
||||
t.Fatalf("buildPromptHistory did not inject a system summary message: %+v", got[0])
|
||||
}
|
||||
if got[1].Content != "new user" {
|
||||
t.Fatalf("buildPromptHistory kept wrong history tail: %+v", got[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentRegistryIncludesCompactor(t *testing.T) {
|
||||
registry := agents.NewAgentRegistry()
|
||||
compactor := registry.GetByType("compactor")
|
||||
if compactor == nil {
|
||||
t.Fatal("expected compactor agent to be registered")
|
||||
}
|
||||
if got, want := compactor.DisplayName(), "Compactor"; got != want {
|
||||
t.Fatalf("compactor display name = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfiguredCompactionRatiosDefaultToSafeValues(t *testing.T) {
|
||||
originalTrigger := config.LLM.ContextCompactionTriggerRatio
|
||||
originalTarget := config.LLM.ContextCompactionTargetRatio
|
||||
t.Cleanup(func() {
|
||||
config.LLM.ContextCompactionTriggerRatio = originalTrigger
|
||||
config.LLM.ContextCompactionTargetRatio = originalTarget
|
||||
})
|
||||
|
||||
config.LLM.ContextCompactionTriggerRatio = 0
|
||||
config.LLM.ContextCompactionTargetRatio = 0
|
||||
|
||||
if got, want := configuredCompactionTriggerRatio(), 0.80; got != want {
|
||||
t.Fatalf("configuredCompactionTriggerRatio() = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := configuredCompactionTargetRatio(), 0.70; got != want {
|
||||
t.Fatalf("configuredCompactionTargetRatio() = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultConfigUsesBuiltInServerDefaults(t *testing.T) {
|
||||
cfg := defaultConfig()
|
||||
if got, want := cfg.Server.Addr, defaultServerAddr; got != want {
|
||||
t.Fatalf("default server addr = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := cfg.Server.Host, defaultServerHost; got != want {
|
||||
t.Fatalf("default server host = %q, want %q", got, want)
|
||||
}
|
||||
if cfg.LLM.AutoOpenReasoning == nil || !*cfg.LLM.AutoOpenReasoning {
|
||||
t.Fatalf("default auto_open_reasoning = %+v, want true", cfg.LLM.AutoOpenReasoning)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeConfigDefaultsAutoOpenReasoning(t *testing.T) {
|
||||
cfg := Config{}
|
||||
|
||||
normalizeConfig(&cfg)
|
||||
|
||||
if cfg.LLM.AutoOpenReasoning == nil || !*cfg.LLM.AutoOpenReasoning {
|
||||
t.Fatalf("normalizeConfig did not default auto_open_reasoning to true: %+v", cfg.LLM.AutoOpenReasoning)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateContextCompactionSettingsRejectsInvalidValues(t *testing.T) {
|
||||
cases := []contextCompactionSettings{
|
||||
{ContextTokens: 0, ContextCompactionTriggerRatio: 0.8, ContextCompactionTargetRatio: 0.7},
|
||||
{ContextTokens: 100, ContextCompactionTriggerRatio: 1, ContextCompactionTargetRatio: 0.7},
|
||||
{ContextTokens: 100, ContextCompactionTriggerRatio: 0.8, ContextCompactionTargetRatio: 0.8},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
if err := validateContextCompactionSettings(tc); err == nil {
|
||||
t.Fatalf("validateContextCompactionSettings(%+v) = nil, want error", tc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAppSettingsRejectsInvalidValues(t *testing.T) {
|
||||
base := defaultConfig()
|
||||
base.LLM.Provider = "openai"
|
||||
base.LLM.BaseURL = "http://localhost:1234/v1"
|
||||
base.LLM.Model = "qwen/qwen3.6-35b-a3b"
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
mutate func(*AppSettings)
|
||||
}{
|
||||
{name: "missing server addr", mutate: func(s *AppSettings) { s.ServerAddr = "" }},
|
||||
{name: "missing server host", mutate: func(s *AppSettings) { s.ServerHost = "" }},
|
||||
{name: "missing provider", mutate: func(s *AppSettings) { s.Provider = "" }},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
settings := AppSettings{
|
||||
LLMConfig: base.LLM,
|
||||
ServerAddr: base.Server.Addr,
|
||||
ServerHost: base.Server.Host,
|
||||
}
|
||||
tc.mutate(&settings)
|
||||
if err := validateAppSettings(settings); err == nil {
|
||||
t.Fatalf("validateAppSettings(%s) = nil, want error", tc.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateLLMSettingsRejectsInvalidValues(t *testing.T) {
|
||||
base := LLMConfig{
|
||||
Provider: "openai",
|
||||
BaseURL: "http://localhost:1234/v1",
|
||||
APIKey: "lm-studio",
|
||||
Model: "qwen/qwen3.6-35b-a3b",
|
||||
ContextTokens: 1024,
|
||||
ContextCompactionTriggerRatio: 0.8,
|
||||
ContextCompactionTargetRatio: 0.7,
|
||||
Temperature: 0.3,
|
||||
MaxTokens: 1024,
|
||||
TopP: 1.0,
|
||||
FrequencyPenalty: 0,
|
||||
PresencePenalty: 0,
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
mutate func(*LLMConfig)
|
||||
}{
|
||||
{name: "missing provider", mutate: func(cfg *LLMConfig) { cfg.Provider = "" }},
|
||||
{name: "missing base url", mutate: func(cfg *LLMConfig) { cfg.BaseURL = "" }},
|
||||
{name: "missing model", mutate: func(cfg *LLMConfig) { cfg.Model = "" }},
|
||||
{name: "invalid max tokens", mutate: func(cfg *LLMConfig) { cfg.MaxTokens = 0 }},
|
||||
{name: "invalid top p", mutate: func(cfg *LLMConfig) { cfg.TopP = 0 }},
|
||||
{name: "invalid context tokens", mutate: func(cfg *LLMConfig) { cfg.ContextTokens = 0 }},
|
||||
{name: "invalid trigger ratio", mutate: func(cfg *LLMConfig) { cfg.ContextCompactionTriggerRatio = 1 }},
|
||||
{name: "invalid target ratio", mutate: func(cfg *LLMConfig) { cfg.ContextCompactionTargetRatio = 0.8 }},
|
||||
{name: "invalid reasoning effort", mutate: func(cfg *LLMConfig) { cfg.ReasoningEffort = "ultra" }},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
cfg := base
|
||||
tc.mutate(&cfg)
|
||||
if err := validateLLMSettings(cfg); err == nil {
|
||||
t.Fatalf("validateLLMSettings(%s) = nil, want error", tc.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateLLMSettingsAcceptsReasoningEffortValues(t *testing.T) {
|
||||
base := LLMConfig{
|
||||
Provider: "openai",
|
||||
BaseURL: "http://localhost:1234/v1",
|
||||
APIKey: "lm-studio",
|
||||
Model: "qwen/qwen3.6-35b-a3b",
|
||||
ContextTokens: 1024,
|
||||
ContextCompactionTriggerRatio: 0.8,
|
||||
ContextCompactionTargetRatio: 0.7,
|
||||
Temperature: 0.3,
|
||||
MaxTokens: 1024,
|
||||
TopP: 1.0,
|
||||
FrequencyPenalty: 0,
|
||||
PresencePenalty: 0,
|
||||
}
|
||||
|
||||
for _, effort := range []string{"", "low", "medium", "high", " HIGH "} {
|
||||
cfg := base
|
||||
cfg.ReasoningEffort = effort
|
||||
if err := validateLLMSettings(cfg); err != nil {
|
||||
t.Fatalf("validateLLMSettings(%q) returned error: %v", effort, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateContextCompactionSettingsPersistsToDisk(t *testing.T) {
|
||||
original := currentConfig()
|
||||
t.Cleanup(func() {
|
||||
setConfig(original)
|
||||
})
|
||||
|
||||
tempDir := t.TempDir()
|
||||
originalWD, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("os.Getwd returned error: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = os.Chdir(originalWD)
|
||||
})
|
||||
if err := os.Chdir(tempDir); err != nil {
|
||||
t.Fatalf("os.Chdir returned error: %v", err)
|
||||
}
|
||||
|
||||
configCopy := defaultConfig()
|
||||
configCopy.Server.Addr = ":9999"
|
||||
configCopy.Server.Host = "localhost"
|
||||
configCopy.LLM.ContextTokens = 512
|
||||
configCopy.LLM.ContextCompactionTriggerRatio = 0.8
|
||||
configCopy.LLM.ContextCompactionTargetRatio = 0.7
|
||||
setConfig(configCopy)
|
||||
|
||||
settings := contextCompactionSettings{
|
||||
ContextTokens: 2048,
|
||||
ContextCompactionTriggerRatio: 0.85,
|
||||
ContextCompactionTargetRatio: 0.65,
|
||||
}
|
||||
if err := updateContextCompactionSettings(settings); err != nil {
|
||||
t.Fatalf("updateContextCompactionSettings returned error: %v", err)
|
||||
}
|
||||
|
||||
updated := currentConfig()
|
||||
if got, want := updated.LLM.ContextTokens, settings.ContextTokens; got != want {
|
||||
t.Fatalf("updated context tokens = %d, want %d", got, want)
|
||||
}
|
||||
if got, want := updated.LLM.ContextCompactionTriggerRatio, settings.ContextCompactionTriggerRatio; got != want {
|
||||
t.Fatalf("updated trigger ratio = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := updated.LLM.ContextCompactionTargetRatio, settings.ContextCompactionTargetRatio; got != want {
|
||||
t.Fatalf("updated target ratio = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := updated.Server.Addr, configCopy.Server.Addr; got != want {
|
||||
t.Fatalf("updated server addr = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := updated.Server.Host, configCopy.Server.Host; got != want {
|
||||
t.Fatalf("updated server host = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(tempDir, "config.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("os.ReadFile returned error: %v", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
t.Fatal("config.json was not written")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateLLMSettingsPersistsFullConfig(t *testing.T) {
|
||||
original := currentConfig()
|
||||
t.Cleanup(func() {
|
||||
setConfig(original)
|
||||
})
|
||||
|
||||
tempDir := t.TempDir()
|
||||
originalWD, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("os.Getwd returned error: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = os.Chdir(originalWD)
|
||||
})
|
||||
if err := os.Chdir(tempDir); err != nil {
|
||||
t.Fatalf("os.Chdir returned error: %v", err)
|
||||
}
|
||||
|
||||
configCopy := defaultConfig()
|
||||
configCopy.Server.Addr = ":9999"
|
||||
configCopy.Server.Host = "localhost"
|
||||
configCopy.LLM = LLMConfig{
|
||||
Provider: "openai",
|
||||
BaseURL: "http://localhost:4321/v1",
|
||||
APIKey: "new-key",
|
||||
Model: "test-model",
|
||||
ContextTokens: 4096,
|
||||
ContextCompactionTriggerRatio: 0.85,
|
||||
ContextCompactionTargetRatio: 0.6,
|
||||
Temperature: 0.2,
|
||||
MaxTokens: 2048,
|
||||
TopP: 0.95,
|
||||
FrequencyPenalty: 0.25,
|
||||
PresencePenalty: 0.1,
|
||||
}
|
||||
setConfig(configCopy)
|
||||
|
||||
settings := LLMConfig{
|
||||
Provider: "anthropic",
|
||||
BaseURL: "https://example.com/v1",
|
||||
APIKey: "updated-key",
|
||||
Model: "claude",
|
||||
ReasoningEffort: "high",
|
||||
AutoOpenReasoning: boolPtr(false),
|
||||
ContextTokens: 8192,
|
||||
ContextCompactionTriggerRatio: 0.9,
|
||||
ContextCompactionTargetRatio: 0.7,
|
||||
Temperature: 0.15,
|
||||
MaxTokens: 4096,
|
||||
TopP: 0.9,
|
||||
FrequencyPenalty: 0.4,
|
||||
PresencePenalty: 0.2,
|
||||
}
|
||||
|
||||
if err := updateLLMSettings(settings); err != nil {
|
||||
t.Fatalf("updateLLMSettings returned error: %v", err)
|
||||
}
|
||||
|
||||
updated := currentConfig()
|
||||
if got, want := updated.LLM, settings; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("updated LLM config = %+v, want %+v", got, want)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(tempDir, "config.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("os.ReadFile returned error: %v", err)
|
||||
}
|
||||
|
||||
var persisted Config
|
||||
if err := json.Unmarshal(data, &persisted); err != nil {
|
||||
t.Fatalf("json.Unmarshal returned error: %v", err)
|
||||
}
|
||||
if got, want := persisted.LLM, settings; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("persisted LLM config = %+v, want %+v", got, want)
|
||||
}
|
||||
if got, want := persisted.Server.Addr, configCopy.Server.Addr; got != want {
|
||||
t.Fatalf("persisted server addr = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := persisted.Server.Host, configCopy.Server.Host; got != want {
|
||||
t.Fatalf("persisted server host = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAppSettingsPersistsFullConfig(t *testing.T) {
|
||||
original := currentConfig()
|
||||
t.Cleanup(func() {
|
||||
setConfig(original)
|
||||
})
|
||||
|
||||
tempDir := t.TempDir()
|
||||
originalWD, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("os.Getwd returned error: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = os.Chdir(originalWD)
|
||||
})
|
||||
if err := os.Chdir(tempDir); err != nil {
|
||||
t.Fatalf("os.Chdir returned error: %v", err)
|
||||
}
|
||||
|
||||
configCopy := defaultConfig()
|
||||
configCopy.Server.Addr = ":9999"
|
||||
configCopy.Server.Host = "localhost"
|
||||
setConfig(configCopy)
|
||||
|
||||
settings := AppSettings{
|
||||
LLMConfig: LLMConfig{
|
||||
Provider: "anthropic",
|
||||
BaseURL: "https://example.com/v1",
|
||||
APIKey: "updated-key",
|
||||
Model: "claude",
|
||||
ReasoningEffort: "low",
|
||||
AutoOpenReasoning: boolPtr(false),
|
||||
ContextTokens: 8192,
|
||||
ContextCompactionTriggerRatio: 0.9,
|
||||
ContextCompactionTargetRatio: 0.7,
|
||||
Temperature: 0.15,
|
||||
MaxTokens: 4096,
|
||||
TopP: 0.9,
|
||||
FrequencyPenalty: 0.4,
|
||||
PresencePenalty: 0.2,
|
||||
},
|
||||
ServerAddr: ":9090",
|
||||
ServerHost: "127.0.0.1",
|
||||
}
|
||||
|
||||
if err := updateAppSettings(settings); err != nil {
|
||||
t.Fatalf("updateAppSettings returned error: %v", err)
|
||||
}
|
||||
|
||||
updated := currentConfig()
|
||||
if got, want := updated.LLM, settings.LLMConfig; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("updated LLM config = %+v, want %+v", got, want)
|
||||
}
|
||||
if got, want := updated.Server.Addr, settings.ServerAddr; got != want {
|
||||
t.Fatalf("updated server addr = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := updated.Server.Host, settings.ServerHost; got != want {
|
||||
t.Fatalf("updated server host = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(tempDir, "config.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("os.ReadFile returned error: %v", err)
|
||||
}
|
||||
|
||||
var persisted Config
|
||||
if err := json.Unmarshal(data, &persisted); err != nil {
|
||||
t.Fatalf("json.Unmarshal returned error: %v", err)
|
||||
}
|
||||
if got, want := persisted.LLM, settings.LLMConfig; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("persisted LLM config = %+v, want %+v", got, want)
|
||||
}
|
||||
if got, want := persisted.Server.Addr, settings.ServerAddr; got != want {
|
||||
t.Fatalf("persisted server addr = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := persisted.Server.Host, settings.ServerHost; got != want {
|
||||
t.Fatalf("persisted server host = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
981
tools/tools.go
Normal file
981
tools/tools.go
Normal file
@@ -0,0 +1,981 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Tool represents a function-calling tool exposed to an agent workflow.
|
||||
type Tool struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Parameters Schema `json:"parameters"`
|
||||
}
|
||||
|
||||
// Schema defines the JSON schema for tool parameters.
|
||||
type Schema struct {
|
||||
Type string `json:"type"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Properties map[string]Schema `json:"properties,omitempty"`
|
||||
Required []string `json:"required,omitempty"`
|
||||
Items *Schema `json:"items,omitempty"`
|
||||
}
|
||||
|
||||
// Result is the output from executing a tool.
|
||||
type Result struct {
|
||||
Content string `json:"content"`
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
TotalTokens int `json:"total_tokens,omitempty"`
|
||||
}
|
||||
|
||||
// WorkspaceValidator ensures operations stay within the allowed workspace.
|
||||
type WorkspaceValidator struct {
|
||||
workspacePath string
|
||||
}
|
||||
|
||||
func cancelledResult() (*Result, error) {
|
||||
return &Result{Success: false, Error: "operation cancelled"}, nil
|
||||
}
|
||||
|
||||
func preferredShellPath() string {
|
||||
shell := strings.TrimSpace(os.Getenv("SHELL"))
|
||||
if shell != "" {
|
||||
if strings.Contains(shell, string(os.PathSeparator)) {
|
||||
return shell
|
||||
}
|
||||
if resolved, err := exec.LookPath(shell); err == nil {
|
||||
return resolved
|
||||
}
|
||||
}
|
||||
if runtime.GOOS == "darwin" {
|
||||
return "/bin/zsh"
|
||||
}
|
||||
return "/bin/sh"
|
||||
}
|
||||
|
||||
func shellQuote(arg string) string {
|
||||
if arg == "" {
|
||||
return "''"
|
||||
}
|
||||
return "'" + strings.ReplaceAll(arg, "'", `'"'"'`) + "'"
|
||||
}
|
||||
|
||||
func buildShellCommand(command string, args []string) string {
|
||||
parts := make([]string, 0, 1+len(args))
|
||||
if trimmed := strings.TrimSpace(command); trimmed != "" {
|
||||
parts = append(parts, trimmed)
|
||||
}
|
||||
for _, arg := range args {
|
||||
parts = append(parts, shellQuote(arg))
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(parts, " "))
|
||||
}
|
||||
|
||||
// NewWorkspaceValidator creates a validator for the given workspace.
|
||||
func NewWorkspaceValidator(workspacePath string) *WorkspaceValidator {
|
||||
absPath, err := filepath.Abs(workspacePath)
|
||||
if err != nil {
|
||||
absPath = workspacePath
|
||||
}
|
||||
return &WorkspaceValidator{workspacePath: absPath}
|
||||
}
|
||||
|
||||
func (v *WorkspaceValidator) resolvePath(path string) (string, error) {
|
||||
trimmed := strings.TrimSpace(path)
|
||||
if trimmed == "" || trimmed == "." {
|
||||
return v.workspacePath, nil
|
||||
}
|
||||
|
||||
candidate := trimmed
|
||||
if !filepath.IsAbs(candidate) {
|
||||
candidate = filepath.Join(v.workspacePath, candidate)
|
||||
}
|
||||
|
||||
absPath, err := filepath.Abs(candidate)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid path: %w", err)
|
||||
}
|
||||
|
||||
cleaned := filepath.Clean(absPath)
|
||||
if !strings.HasPrefix(cleaned, v.workspacePath) && cleaned != v.workspacePath {
|
||||
return "", fmt.Errorf("access denied: path '%s' is outside workspace '%s'", path, v.workspacePath)
|
||||
}
|
||||
return cleaned, nil
|
||||
}
|
||||
|
||||
// Validate checks if a path is within the workspace and returns the resolved absolute path.
|
||||
func (v *WorkspaceValidator) Validate(path string) (string, error) {
|
||||
return v.resolvePath(path)
|
||||
}
|
||||
|
||||
// IsWithinWorkspace checks if a path is within the workspace.
|
||||
func (v *WorkspaceValidator) IsWithinWorkspace(path string) bool {
|
||||
_, err := v.resolvePath(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func normalizeLineEndings(text string) string {
|
||||
return strings.ReplaceAll(text, "\r\n", "\n")
|
||||
}
|
||||
|
||||
func splitLines(text string) []string {
|
||||
text = normalizeLineEndings(text)
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
lines := strings.Split(text, "\n")
|
||||
if len(lines) > 0 && lines[len(lines)-1] == "" {
|
||||
lines = lines[:len(lines)-1]
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func formatNumberedLines(lines []string, startLine int) string {
|
||||
if len(lines) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var builder strings.Builder
|
||||
for i, line := range lines {
|
||||
if i > 0 {
|
||||
builder.WriteByte('\n')
|
||||
}
|
||||
fmt.Fprintf(&builder, "%d: %s", startLine+i, line)
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func spliceLineRange(existing []string, startLine, endLine int, replacement []string) ([]string, error) {
|
||||
if startLine < 1 {
|
||||
return nil, fmt.Errorf("start_line must be >= 1")
|
||||
}
|
||||
if endLine < -1 {
|
||||
return nil, fmt.Errorf("end_line must be -1 or >= 0")
|
||||
}
|
||||
|
||||
totalLines := len(existing)
|
||||
if endLine == -1 {
|
||||
endLine = totalLines
|
||||
}
|
||||
if endLine < startLine-1 {
|
||||
return nil, fmt.Errorf("end_line must be -1 or at least start_line-1")
|
||||
}
|
||||
if startLine > totalLines+1 {
|
||||
return nil, fmt.Errorf("start_line %d is beyond EOF; file has %d lines", startLine, totalLines)
|
||||
}
|
||||
if endLine > totalLines {
|
||||
return nil, fmt.Errorf("end_line %d is beyond EOF; file has %d lines", endLine, totalLines)
|
||||
}
|
||||
|
||||
prefix := append([]string(nil), existing[:startLine-1]...)
|
||||
suffix := append([]string(nil), existing[endLine:]...)
|
||||
|
||||
nextLines := make([]string, 0, len(prefix)+len(replacement)+len(suffix))
|
||||
nextLines = append(nextLines, prefix...)
|
||||
nextLines = append(nextLines, replacement...)
|
||||
nextLines = append(nextLines, suffix...)
|
||||
|
||||
return nextLines, nil
|
||||
}
|
||||
|
||||
func lineRangeLines(lines []string, startLine, endLine int) ([]string, error) {
|
||||
if startLine < 1 {
|
||||
return nil, fmt.Errorf("start_line must be >= 1")
|
||||
}
|
||||
if endLine < -1 {
|
||||
return nil, fmt.Errorf("end_line must be -1 or >= 0")
|
||||
}
|
||||
|
||||
totalLines := len(lines)
|
||||
if endLine == -1 {
|
||||
endLine = totalLines
|
||||
}
|
||||
if endLine < startLine-1 {
|
||||
return nil, fmt.Errorf("end_line must be -1 or at least start_line-1")
|
||||
}
|
||||
if startLine > totalLines+1 {
|
||||
return nil, fmt.Errorf("start_line %d is beyond EOF; file has %d lines", startLine, totalLines)
|
||||
}
|
||||
if endLine > totalLines {
|
||||
return nil, fmt.Errorf("end_line %d is beyond EOF; file has %d lines", endLine, totalLines)
|
||||
}
|
||||
if endLine < startLine-1 {
|
||||
return []string{}, nil
|
||||
}
|
||||
if totalLines == 0 || endLine < startLine {
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
return append([]string(nil), lines[startLine-1:endLine]...), nil
|
||||
}
|
||||
|
||||
func findUniqueLineSequence(haystack, needle []string) (int, error) {
|
||||
if len(needle) == 0 {
|
||||
return 0, fmt.Errorf("expected_text cannot be empty")
|
||||
}
|
||||
if len(needle) > len(haystack) {
|
||||
return 0, fmt.Errorf("expected text has %d lines, but the requested range only has %d lines", len(needle), len(haystack))
|
||||
}
|
||||
|
||||
matchIndex := -1
|
||||
matchCount := 0
|
||||
for i := 0; i <= len(haystack)-len(needle); i++ {
|
||||
matched := true
|
||||
for j := range needle {
|
||||
if haystack[i+j] != needle[j] {
|
||||
matched = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if matched {
|
||||
matchCount++
|
||||
if matchIndex == -1 {
|
||||
matchIndex = i
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case matchCount == 0:
|
||||
return 0, fmt.Errorf("expected text was not found within the requested range")
|
||||
case matchCount > 1:
|
||||
return 0, fmt.Errorf("expected text matched %d locations within the requested range", matchCount)
|
||||
default:
|
||||
return matchIndex, nil
|
||||
}
|
||||
}
|
||||
|
||||
func stripReadFileLinePrefix(line string) (string, bool) {
|
||||
if line == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
i := 0
|
||||
for i < len(line) && line[i] >= '0' && line[i] <= '9' {
|
||||
i++
|
||||
}
|
||||
if i == 0 || i >= len(line) || line[i] != ':' {
|
||||
return "", false
|
||||
}
|
||||
|
||||
rest := line[i+1:]
|
||||
if strings.HasPrefix(rest, " ") {
|
||||
rest = rest[1:]
|
||||
}
|
||||
return rest, true
|
||||
}
|
||||
|
||||
func normalizePatchExpectedLines(expectedText string) []string {
|
||||
lines := splitLines(expectedText)
|
||||
if len(lines) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(lines) >= 2 && strings.HasPrefix(lines[0], "File: ") && strings.HasPrefix(lines[1], "Lines ") && strings.Contains(lines[1], " of ") {
|
||||
lines = lines[2:]
|
||||
normalized := make([]string, len(lines))
|
||||
for i, line := range lines {
|
||||
if stripped, ok := stripReadFileLinePrefix(line); ok {
|
||||
normalized[i] = stripped
|
||||
} else {
|
||||
normalized[i] = line
|
||||
}
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
normalized := make([]string, len(lines))
|
||||
allNumbered := true
|
||||
for i, line := range lines {
|
||||
stripped, ok := stripReadFileLinePrefix(line)
|
||||
if !ok {
|
||||
allNumbered = false
|
||||
break
|
||||
}
|
||||
normalized[i] = stripped
|
||||
}
|
||||
if allNumbered {
|
||||
return normalized
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
// --- File Tools ---
|
||||
|
||||
// ReadFile reads a line range from a file within the workspace.
|
||||
func (v *WorkspaceValidator) ReadFile(ctx context.Context, path string, startLine, endLine int) (*Result, error) {
|
||||
if ctx != nil && ctx.Err() != nil {
|
||||
return cancelledResult()
|
||||
}
|
||||
absPath, err := v.Validate(path)
|
||||
if err != nil {
|
||||
return &Result{Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(absPath)
|
||||
if err != nil {
|
||||
return &Result{Success: false, Error: fmt.Sprintf("failed to read file: %v", err)}, nil
|
||||
}
|
||||
|
||||
if startLine < 1 {
|
||||
return &Result{Success: false, Error: "start_line must be >= 1"}, nil
|
||||
}
|
||||
|
||||
if endLine != -1 && endLine < 1 {
|
||||
return &Result{Success: false, Error: "end_line must be -1 or >= 1"}, nil
|
||||
}
|
||||
if endLine != -1 && endLine < startLine {
|
||||
return &Result{Success: false, Error: "end_line must be -1 or greater than or equal to start_line"}, nil
|
||||
}
|
||||
|
||||
lines := splitLines(string(data))
|
||||
totalLines := len(lines)
|
||||
if totalLines == 0 {
|
||||
return &Result{
|
||||
Content: fmt.Sprintf("File: %s\nFile is empty.", absPath),
|
||||
Success: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
actualEnd := totalLines
|
||||
if endLine != -1 {
|
||||
if endLine < totalLines {
|
||||
actualEnd = endLine
|
||||
}
|
||||
}
|
||||
|
||||
if startLine > totalLines {
|
||||
displayEnd := "EOF"
|
||||
if endLine != -1 {
|
||||
displayEnd = fmt.Sprintf("%d", endLine)
|
||||
}
|
||||
return &Result{
|
||||
Content: fmt.Sprintf(
|
||||
"File: %s\nRequested lines %d-%s are beyond EOF. File has %d lines.",
|
||||
absPath,
|
||||
startLine,
|
||||
displayEnd,
|
||||
totalLines,
|
||||
),
|
||||
Success: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if actualEnd < startLine {
|
||||
return &Result{
|
||||
Content: fmt.Sprintf(
|
||||
"File: %s\nRequested lines %d-%d are empty. File has %d lines.",
|
||||
absPath,
|
||||
startLine,
|
||||
actualEnd,
|
||||
totalLines,
|
||||
),
|
||||
Success: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
selected := lines[startLine-1 : actualEnd]
|
||||
displayEnd := fmt.Sprintf("%d", actualEnd)
|
||||
if endLine == -1 && actualEnd == totalLines {
|
||||
displayEnd = "EOF"
|
||||
}
|
||||
|
||||
return &Result{
|
||||
Content: fmt.Sprintf(
|
||||
"File: %s\nLines %d-%s of %d:\n%s",
|
||||
absPath,
|
||||
startLine,
|
||||
displayEnd,
|
||||
totalLines,
|
||||
formatNumberedLines(selected, startLine),
|
||||
),
|
||||
Success: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// WriteFile replaces a line range in a file within the workspace, creating parent directories if needed.
|
||||
func (v *WorkspaceValidator) WriteFile(ctx context.Context, path string, startLine, endLine int, content string) (*Result, error) {
|
||||
if ctx != nil && ctx.Err() != nil {
|
||||
return cancelledResult()
|
||||
}
|
||||
absPath, err := v.Validate(path)
|
||||
if err != nil {
|
||||
return &Result{Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
|
||||
dir := filepath.Dir(absPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return &Result{Success: false, Error: fmt.Sprintf("failed to create directories: %v", err)}, nil
|
||||
}
|
||||
|
||||
existingData, readErr := os.ReadFile(absPath)
|
||||
if readErr != nil && !os.IsNotExist(readErr) {
|
||||
return &Result{Success: false, Error: fmt.Sprintf("failed to read existing file: %v", readErr)}, nil
|
||||
}
|
||||
|
||||
existingLines := splitLines(string(existingData))
|
||||
replacementLines := splitLines(content)
|
||||
originalEndLine := endLine
|
||||
|
||||
nextLines, err := spliceLineRange(existingLines, startLine, endLine, replacementLines)
|
||||
if err != nil {
|
||||
return &Result{Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
|
||||
nextContent := strings.Join(nextLines, "\n")
|
||||
if err := os.WriteFile(absPath, []byte(nextContent), 0644); err != nil {
|
||||
return &Result{Success: false, Error: fmt.Sprintf("failed to write file: %v", err)}, nil
|
||||
}
|
||||
|
||||
action := "replaced"
|
||||
if os.IsNotExist(readErr) || (len(existingLines) == 0 && startLine == 1 && originalEndLine <= 0) {
|
||||
action = "created"
|
||||
} else if originalEndLine < startLine {
|
||||
action = "inserted"
|
||||
}
|
||||
|
||||
replacedCount := 0
|
||||
if endLine == -1 {
|
||||
replacedCount = len(existingLines) - startLine + 1
|
||||
if replacedCount < 0 {
|
||||
replacedCount = 0
|
||||
}
|
||||
} else if endLine >= startLine {
|
||||
replacedCount = endLine - startLine + 1
|
||||
}
|
||||
|
||||
message := fmt.Sprintf("Successfully %s %s", action, absPath)
|
||||
if action == "inserted" {
|
||||
message = fmt.Sprintf("Successfully inserted %d lines before line %d in %s", len(replacementLines), startLine, absPath)
|
||||
} else if action == "created" {
|
||||
message = fmt.Sprintf("Successfully created %s with %d lines", absPath, len(nextLines))
|
||||
} else {
|
||||
message = fmt.Sprintf("Successfully replaced %d lines starting at line %d in %s", replacedCount, startLine, absPath)
|
||||
}
|
||||
|
||||
return &Result{Content: message, Success: true}, nil
|
||||
}
|
||||
|
||||
// PatchFile replaces the unique snippet matching the expected text within a requested line window.
|
||||
func (v *WorkspaceValidator) PatchFile(ctx context.Context, path string, startLine, endLine int, expectedText, content string) (*Result, error) {
|
||||
if ctx != nil && ctx.Err() != nil {
|
||||
return cancelledResult()
|
||||
}
|
||||
absPath, err := v.Validate(path)
|
||||
if err != nil {
|
||||
return &Result{Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
|
||||
dir := filepath.Dir(absPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return &Result{Success: false, Error: fmt.Sprintf("failed to create directories: %v", err)}, nil
|
||||
}
|
||||
|
||||
existingData, readErr := os.ReadFile(absPath)
|
||||
if readErr != nil && !os.IsNotExist(readErr) {
|
||||
return &Result{Success: false, Error: fmt.Sprintf("failed to read existing file: %v", readErr)}, nil
|
||||
}
|
||||
|
||||
existingLines := splitLines(string(existingData))
|
||||
windowLines, err := lineRangeLines(existingLines, startLine, endLine)
|
||||
if err != nil {
|
||||
return &Result{Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
|
||||
expectedLines := normalizePatchExpectedLines(expectedText)
|
||||
matchOffset, err := findUniqueLineSequence(windowLines, expectedLines)
|
||||
if err != nil {
|
||||
displayEnd := "EOF"
|
||||
if endLine != -1 {
|
||||
displayEnd = fmt.Sprintf("%d", endLine)
|
||||
}
|
||||
return &Result{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf(
|
||||
"patch guard failed for requested range %d-%s: %v. Re-read the exact snippet and include a little more context if needed.",
|
||||
startLine,
|
||||
displayEnd,
|
||||
err,
|
||||
),
|
||||
}, nil
|
||||
}
|
||||
|
||||
actualStartLine := startLine + matchOffset
|
||||
actualEndLine := actualStartLine + len(expectedLines) - 1
|
||||
|
||||
replacementLines := splitLines(content)
|
||||
nextLines, err := spliceLineRange(existingLines, actualStartLine, actualEndLine, replacementLines)
|
||||
if err != nil {
|
||||
return &Result{Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
|
||||
nextContent := strings.Join(nextLines, "\n")
|
||||
if err := os.WriteFile(absPath, []byte(nextContent), 0644); err != nil {
|
||||
return &Result{Success: false, Error: fmt.Sprintf("failed to write file: %v", err)}, nil
|
||||
}
|
||||
|
||||
return &Result{
|
||||
Content: fmt.Sprintf("Successfully patched lines %d-%d in %s", actualStartLine, actualEndLine, absPath),
|
||||
Success: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateDirectory creates a directory within the workspace.
|
||||
func (v *WorkspaceValidator) CreateDirectory(ctx context.Context, path string) (*Result, error) {
|
||||
if ctx != nil && ctx.Err() != nil {
|
||||
return cancelledResult()
|
||||
}
|
||||
absPath, err := v.Validate(path)
|
||||
if err != nil {
|
||||
return &Result{Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(absPath, 0755); err != nil {
|
||||
return &Result{Success: false, Error: fmt.Sprintf("failed to create directory: %v", err)}, nil
|
||||
}
|
||||
|
||||
return &Result{Content: fmt.Sprintf("Successfully created directory: %s", absPath), Success: true}, nil
|
||||
}
|
||||
|
||||
// DeleteFile removes a file within the workspace.
|
||||
func (v *WorkspaceValidator) DeleteFile(ctx context.Context, path string) (*Result, error) {
|
||||
if ctx != nil && ctx.Err() != nil {
|
||||
return cancelledResult()
|
||||
}
|
||||
absPath, err := v.Validate(path)
|
||||
if err != nil {
|
||||
return &Result{Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
|
||||
if err := os.Remove(absPath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return &Result{Success: false, Error: fmt.Sprintf("file not found: %s", path)}, nil
|
||||
}
|
||||
return &Result{Success: false, Error: fmt.Sprintf("failed to delete file: %v", err)}, nil
|
||||
}
|
||||
|
||||
return &Result{Content: fmt.Sprintf("Successfully deleted: %s", absPath), Success: true}, nil
|
||||
}
|
||||
|
||||
// ListDirectory lists files and directories within the workspace.
|
||||
func (v *WorkspaceValidator) ListDirectory(ctx context.Context, path string) (*Result, error) {
|
||||
if ctx != nil && ctx.Err() != nil {
|
||||
return cancelledResult()
|
||||
}
|
||||
absPath, err := v.Validate(path)
|
||||
if err != nil {
|
||||
return &Result{Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(absPath)
|
||||
if err != nil {
|
||||
return &Result{Success: false, Error: fmt.Sprintf("failed to read directory: %v", err)}, nil
|
||||
}
|
||||
|
||||
var lines []string
|
||||
lines = append(lines, fmt.Sprintf("Contents of %s:", absPath))
|
||||
|
||||
for _, entry := range entries {
|
||||
if ctx != nil && ctx.Err() != nil {
|
||||
return cancelledResult()
|
||||
}
|
||||
mode := entry.Type()
|
||||
var prefix string
|
||||
if mode.IsDir() {
|
||||
prefix = "D "
|
||||
} else {
|
||||
prefix = "F "
|
||||
}
|
||||
|
||||
info, err := entry.Info()
|
||||
size := "0"
|
||||
if err == nil && info != nil {
|
||||
size = fmt.Sprintf("%d", info.Size())
|
||||
}
|
||||
|
||||
name := entry.Name()
|
||||
if mode.IsDir() {
|
||||
name += "/"
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("%s %8s %s", prefix, size, name))
|
||||
}
|
||||
|
||||
return &Result{Content: strings.Join(lines, "\n")}, nil
|
||||
}
|
||||
|
||||
// --- Search Tools ---
|
||||
|
||||
// SearchFiles searches for files matching a pattern within the workspace.
|
||||
func (v *WorkspaceValidator) SearchFiles(ctx context.Context, pathPattern string) (*Result, error) {
|
||||
if ctx != nil && ctx.Err() != nil {
|
||||
return cancelledResult()
|
||||
}
|
||||
// Validate the base path
|
||||
baseDir := filepath.Dir(pathPattern)
|
||||
if baseDir != "." && baseDir != "/" {
|
||||
if _, err := v.Validate(baseDir); err != nil {
|
||||
return &Result{Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
}
|
||||
|
||||
var matches []string
|
||||
pattern := pathPattern
|
||||
|
||||
err := filepath.Walk(v.workspacePath, func(currentPath string, info os.FileInfo, err error) error {
|
||||
if ctx != nil && ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
relPath, _ := filepath.Rel(v.workspacePath, currentPath)
|
||||
if matched, _ := filepath.Match(pattern, relPath); matched {
|
||||
matches = append(matches, relPath)
|
||||
return nil
|
||||
}
|
||||
if matched, _ := filepath.Match(pattern, filepath.Base(relPath)); matched {
|
||||
matches = append(matches, relPath)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if ctx != nil && ctx.Err() != nil {
|
||||
return cancelledResult()
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return cancelledResult()
|
||||
}
|
||||
return &Result{Success: false, Error: fmt.Sprintf("search failed: %v", err)}, nil
|
||||
}
|
||||
|
||||
if len(matches) == 0 {
|
||||
return &Result{Content: "No files found matching pattern.", Success: true}, nil
|
||||
}
|
||||
|
||||
var output []string
|
||||
output = append(output, fmt.Sprintf("Found %d files matching '%s':", len(matches), pattern))
|
||||
for _, m := range matches {
|
||||
output = append(output, " "+m)
|
||||
}
|
||||
|
||||
return &Result{Content: strings.Join(output, "\n")}, nil
|
||||
}
|
||||
|
||||
// Grep searches for text content within files in the workspace.
|
||||
func (v *WorkspaceValidator) Grep(ctx context.Context, searchTerm string, path string) (*Result, error) {
|
||||
if ctx != nil && ctx.Err() != nil {
|
||||
return cancelledResult()
|
||||
}
|
||||
absPath, err := v.Validate(path)
|
||||
if err != nil {
|
||||
return &Result{Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
|
||||
var results []string
|
||||
count := 0
|
||||
|
||||
err = filepath.Walk(absPath, func(currentPath string, info os.FileInfo, err error) error {
|
||||
if ctx != nil && ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if count >= 100 {
|
||||
return fmt.Errorf("max results reached")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(currentPath)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
relPath, _ := filepath.Rel(v.workspacePath, currentPath)
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for i, line := range lines {
|
||||
if strings.Contains(line, searchTerm) {
|
||||
results = append(results, fmt.Sprintf("%s:%d: %s", relPath, i+1, strings.TrimSpace(line)))
|
||||
count++
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if ctx != nil && ctx.Err() != nil {
|
||||
return cancelledResult()
|
||||
}
|
||||
if count >= 100 {
|
||||
results = append(results, "... (truncated at 100 results)")
|
||||
}
|
||||
|
||||
if len(results) == 0 {
|
||||
return &Result{Content: fmt.Sprintf("No matches for '%s' in %s", searchTerm, path)}, nil
|
||||
}
|
||||
|
||||
return &Result{Content: strings.Join(results, "\n")}, nil
|
||||
}
|
||||
|
||||
// --- Shell Tools ---
|
||||
|
||||
// RunCommand executes a shell command within the workspace.
|
||||
func (v *WorkspaceValidator) RunCommand(ctx context.Context, command string, args []string) (*Result, error) {
|
||||
if ctx != nil && ctx.Err() != nil {
|
||||
return cancelledResult()
|
||||
}
|
||||
shellCommand := buildShellCommand(command, args)
|
||||
if shellCommand == "" {
|
||||
return &Result{Success: false, Error: "command is required"}, nil
|
||||
}
|
||||
|
||||
// Check for dangerous commands
|
||||
dangerousCmds := []string{"rm -rf /", "rm -rf *", "dd if=", "mkfs", "fdisk", "> /dev/"}
|
||||
for _, d := range dangerousCmds {
|
||||
if strings.Contains(strings.ToLower(shellCommand), d) {
|
||||
return &Result{Success: false, Error: fmt.Sprintf("dangerous command blocked: %s", shellCommand)}, nil
|
||||
}
|
||||
}
|
||||
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
shellPath := preferredShellPath()
|
||||
shellArgs := []string{"-lc", shellCommand}
|
||||
switch filepath.Base(shellPath) {
|
||||
case "zsh", "bash":
|
||||
shellArgs[0] = "-lic"
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, shellPath, shellArgs...)
|
||||
cmd.Dir = v.workspacePath
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
outputStr := string(output)
|
||||
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return cancelledResult()
|
||||
}
|
||||
exitCode := -1
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
exitCode = exitErr.ExitCode()
|
||||
}
|
||||
return &Result{
|
||||
Content: outputStr,
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("command failed (exit %d): %v", exitCode, err),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Truncate output
|
||||
maxLen := 50000
|
||||
if len(outputStr) > maxLen {
|
||||
outputStr = outputStr[:maxLen] + "\n... [output truncated]"
|
||||
}
|
||||
|
||||
return &Result{Content: outputStr, Success: true}, nil
|
||||
}
|
||||
|
||||
// GetWorkspacePath returns the workspace path.
|
||||
func (v *WorkspaceValidator) GetWorkspacePath() string {
|
||||
return v.workspacePath
|
||||
}
|
||||
|
||||
// --- Tool Definitions ---
|
||||
|
||||
// AvailableTools returns the list of workspace tool definitions for function calling.
|
||||
func AvailableTools(workspacePath string) []Tool {
|
||||
tools := []Tool{
|
||||
{
|
||||
Name: "read_file",
|
||||
Description: "Read a line range from a file in the workspace. Use start_line=1 and end_line=-1 to read the full file. The output includes line numbers and a header, and it can be pasted directly into patch_file as expected_text for the same snippet or range.",
|
||||
Parameters: Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]Schema{
|
||||
"path": {
|
||||
Type: "string",
|
||||
Description: "The path of the file to read (relative to workspace)",
|
||||
},
|
||||
"start_line": {
|
||||
Type: "integer",
|
||||
Description: "The first line to read, starting at 1. Defaults to 1.",
|
||||
},
|
||||
"end_line": {
|
||||
Type: "integer",
|
||||
Description: "The last line to read, inclusive. Use -1 to read through EOF. Defaults to -1.",
|
||||
},
|
||||
},
|
||||
Required: []string{"path"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "patch_file",
|
||||
Description: "Guarded snippet replacement within a line window. Like write_file, but only applies if expected_text matches one unique contiguous snippet inside the requested range. expected_text can be raw file text or the line-numbered output from read_file. Use this for surgical edits to avoid clobbering concurrent changes.",
|
||||
Parameters: Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]Schema{
|
||||
"path": {
|
||||
Type: "string",
|
||||
Description: "The path of the file to patch (relative to workspace)",
|
||||
},
|
||||
"start_line": {
|
||||
Type: "integer",
|
||||
Description: "The first line of the search window, starting at 1. Defaults to 1.",
|
||||
},
|
||||
"end_line": {
|
||||
Type: "integer",
|
||||
Description: "The last line of the search window, inclusive. Use -1 to search through EOF.",
|
||||
},
|
||||
"expected_text": {
|
||||
Type: "string",
|
||||
Description: "The current text to match. This can be raw file text or the output from read_file for a matching snippet within the requested window.",
|
||||
},
|
||||
"content": {
|
||||
Type: "string",
|
||||
Description: "The replacement text for the requested line range",
|
||||
},
|
||||
},
|
||||
Required: []string{"path", "expected_text", "content"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "write_file",
|
||||
Description: "Replace a line range in a file. Use start_line=1 and end_line=-1 to replace the full file or create a new file. Use end_line=start_line-1 to insert content before a line, such as start_line=1 and end_line=0 to prepend to an existing file. Prefer patch_file when you want to guard against stale content.",
|
||||
Parameters: Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]Schema{
|
||||
"path": {
|
||||
Type: "string",
|
||||
Description: "The path of the file to write (relative to workspace)",
|
||||
},
|
||||
"start_line": {
|
||||
Type: "integer",
|
||||
Description: "The first line to replace, starting at 1. Defaults to 1.",
|
||||
},
|
||||
"end_line": {
|
||||
Type: "integer",
|
||||
Description: "The last line to replace, inclusive. Use -1 to replace through EOF. Use start_line-1 to insert before start_line.",
|
||||
},
|
||||
"content": {
|
||||
Type: "string",
|
||||
Description: "The replacement text for the requested line range",
|
||||
},
|
||||
},
|
||||
Required: []string{"path", "content"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "create_directory",
|
||||
Description: "Create a new directory or get an existing one within the workspace.",
|
||||
Parameters: Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]Schema{
|
||||
"path": {
|
||||
Type: "string",
|
||||
Description: "The path of the directory to create (relative to workspace)",
|
||||
},
|
||||
},
|
||||
Required: []string{"path"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "delete_file",
|
||||
Description: "Delete a file from the workspace.",
|
||||
Parameters: Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]Schema{
|
||||
"path": {
|
||||
Type: "string",
|
||||
Description: "The path of the file to delete (relative to workspace)",
|
||||
},
|
||||
},
|
||||
Required: []string{"path"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "list_directory",
|
||||
Description: "List the contents of a directory within the workspace. Shows file sizes and types.",
|
||||
Parameters: Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]Schema{
|
||||
"path": {
|
||||
Type: "string",
|
||||
Description: "The path of the directory to list (relative to workspace, defaults to workspace root if '.' or empty)",
|
||||
},
|
||||
},
|
||||
Required: []string{"path"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "search_files",
|
||||
Description: "Search for files by name pattern within the workspace directory tree.",
|
||||
Parameters: Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]Schema{
|
||||
"path_pattern": {
|
||||
Type: "string",
|
||||
Description: "The glob pattern to match (e.g., '*.go', 'src/**/*.ts')",
|
||||
},
|
||||
},
|
||||
Required: []string{"path_pattern"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "grep",
|
||||
Description: "Search for text content within files in the workspace. Limited to 100 results.",
|
||||
Parameters: Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]Schema{
|
||||
"search_term": {
|
||||
Type: "string",
|
||||
Description: "The text pattern to search for",
|
||||
},
|
||||
"path": {
|
||||
Type: "string",
|
||||
Description: "The directory to search in (relative to workspace)",
|
||||
},
|
||||
},
|
||||
Required: []string{"search_term", "path"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "run_command",
|
||||
Description: "Execute a shell command through the user's login shell within the workspace directory. Shell syntax, pipes, redirects, and installed tools available on PATH are supported.",
|
||||
Parameters: Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]Schema{
|
||||
"command": {
|
||||
Type: "string",
|
||||
Description: "The command to execute (e.g., 'ls', 'cat', 'go build', 'npm test')",
|
||||
},
|
||||
"args": {
|
||||
Type: "array",
|
||||
Items: &Schema{
|
||||
Type: "string",
|
||||
},
|
||||
Description: "Arguments for the command",
|
||||
},
|
||||
},
|
||||
Required: []string{"command"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_ = workspacePath
|
||||
return tools
|
||||
}
|
||||
356
tools/tools_test.go
Normal file
356
tools/tools_test.go
Normal file
@@ -0,0 +1,356 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func writeMockShell(t *testing.T, dir string) string {
|
||||
t.Helper()
|
||||
|
||||
shellPath := filepath.Join(dir, "mock-shell")
|
||||
script := `#!/bin/sh
|
||||
set -eu
|
||||
printf '%s\n' "$1" > shell-flag.txt
|
||||
printf '%s\n' "$2" > shell-command.txt
|
||||
shift
|
||||
eval "$1"
|
||||
`
|
||||
if err := os.WriteFile(shellPath, []byte(script), 0755); err != nil {
|
||||
t.Fatalf("failed to write mock shell: %v", err)
|
||||
}
|
||||
return shellPath
|
||||
}
|
||||
|
||||
func TestValidateResolvesRelativePathAgainstWorkspace(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
validator := NewWorkspaceValidator(workspace)
|
||||
|
||||
got, err := validator.Validate("src/main.go")
|
||||
if err != nil {
|
||||
t.Fatalf("Validate returned error: %v", err)
|
||||
}
|
||||
|
||||
want := filepath.Join(workspace, "src/main.go")
|
||||
if got != want {
|
||||
t.Fatalf("Validate returned %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsTraversalOutsideWorkspace(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
validator := NewWorkspaceValidator(workspace)
|
||||
|
||||
if _, err := validator.Validate("../outside.go"); err == nil {
|
||||
t.Fatal("expected Validate to reject path traversal outside the workspace")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadFileReturnsRequestedLineRange(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
path := filepath.Join(workspace, "sample.txt")
|
||||
if err := os.WriteFile(path, []byte("alpha\nbeta\ngamma\ndelta\n"), 0644); err != nil {
|
||||
t.Fatalf("failed to write sample file: %v", err)
|
||||
}
|
||||
|
||||
validator := NewWorkspaceValidator(workspace)
|
||||
result, err := validator.ReadFile(context.Background(), "sample.txt", 2, 3)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile returned error: %v", err)
|
||||
}
|
||||
if !result.Success {
|
||||
t.Fatalf("ReadFile returned unsuccessful result: %+v", result)
|
||||
}
|
||||
|
||||
if !strings.Contains(result.Content, "Lines 2-3 of 4:") {
|
||||
t.Fatalf("ReadFile content did not include requested range: %q", result.Content)
|
||||
}
|
||||
if !strings.Contains(result.Content, "2: beta") || !strings.Contains(result.Content, "3: gamma") {
|
||||
t.Fatalf("ReadFile content did not include expected lines: %q", result.Content)
|
||||
}
|
||||
if strings.Contains(result.Content, "1: alpha") || strings.Contains(result.Content, "4: delta") {
|
||||
t.Fatalf("ReadFile content included lines outside the requested range: %q", result.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFileReplacesRangePrependsAndCreates(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
validator := NewWorkspaceValidator(workspace)
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("replace middle range", func(t *testing.T) {
|
||||
path := filepath.Join(workspace, "sample.txt")
|
||||
if err := os.WriteFile(path, []byte("one\ntwo\nthree\nfour\n"), 0644); err != nil {
|
||||
t.Fatalf("failed to write sample file: %v", err)
|
||||
}
|
||||
|
||||
result, err := validator.WriteFile(ctx, "sample.txt", 2, 3, "TWO\nTHREE")
|
||||
if err != nil {
|
||||
t.Fatalf("WriteFile returned error: %v", err)
|
||||
}
|
||||
if !result.Success {
|
||||
t.Fatalf("WriteFile returned unsuccessful result: %+v", result)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read updated file: %v", err)
|
||||
}
|
||||
want := "one\nTWO\nTHREE\nfour"
|
||||
if string(got) != want {
|
||||
t.Fatalf("WriteFile wrote %q, want %q", string(got), want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("prepend at start", func(t *testing.T) {
|
||||
path := filepath.Join(workspace, "prepend.txt")
|
||||
if err := os.WriteFile(path, []byte("body\n"), 0644); err != nil {
|
||||
t.Fatalf("failed to write sample file: %v", err)
|
||||
}
|
||||
|
||||
result, err := validator.WriteFile(ctx, "prepend.txt", 1, 0, "header")
|
||||
if err != nil {
|
||||
t.Fatalf("WriteFile returned error: %v", err)
|
||||
}
|
||||
if !result.Success {
|
||||
t.Fatalf("WriteFile returned unsuccessful result: %+v", result)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read updated file: %v", err)
|
||||
}
|
||||
want := "header\nbody"
|
||||
if string(got) != want {
|
||||
t.Fatalf("WriteFile wrote %q, want %q", string(got), want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("create new file", func(t *testing.T) {
|
||||
path := filepath.Join(workspace, "created.txt")
|
||||
|
||||
result, err := validator.WriteFile(ctx, "created.txt", 1, -1, "first\nsecond")
|
||||
if err != nil {
|
||||
t.Fatalf("WriteFile returned error: %v", err)
|
||||
}
|
||||
if !result.Success {
|
||||
t.Fatalf("WriteFile returned unsuccessful result: %+v", result)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read created file: %v", err)
|
||||
}
|
||||
want := "first\nsecond"
|
||||
if string(got) != want {
|
||||
t.Fatalf("WriteFile wrote %q, want %q", string(got), want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunCommandExecutesThroughShell(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
shellDir := t.TempDir()
|
||||
t.Setenv("SHELL", writeMockShell(t, shellDir))
|
||||
|
||||
validator := NewWorkspaceValidator(workspace)
|
||||
result, err := validator.RunCommand(context.Background(), "echo hello from shell", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("RunCommand returned error: %v", err)
|
||||
}
|
||||
if !result.Success {
|
||||
t.Fatalf("RunCommand returned unsuccessful result: %+v", result)
|
||||
}
|
||||
if got, want := strings.TrimSpace(result.Content), "hello from shell"; got != want {
|
||||
t.Fatalf("RunCommand output = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
commandBytes, err := os.ReadFile(filepath.Join(workspace, "shell-command.txt"))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read shell command capture: %v", err)
|
||||
}
|
||||
if got, want := strings.TrimSpace(string(commandBytes)), "echo hello from shell"; got != want {
|
||||
t.Fatalf("shell command capture = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCommandQuotesArgsInShellCommand(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
shellDir := t.TempDir()
|
||||
t.Setenv("SHELL", writeMockShell(t, shellDir))
|
||||
|
||||
validator := NewWorkspaceValidator(workspace)
|
||||
result, err := validator.RunCommand(context.Background(), "printf", []string{"%s", "hello world"})
|
||||
if err != nil {
|
||||
t.Fatalf("RunCommand returned error: %v", err)
|
||||
}
|
||||
if !result.Success {
|
||||
t.Fatalf("RunCommand returned unsuccessful result: %+v", result)
|
||||
}
|
||||
if got, want := strings.TrimSpace(result.Content), "hello world"; got != want {
|
||||
t.Fatalf("RunCommand output = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
commandBytes, err := os.ReadFile(filepath.Join(workspace, "shell-command.txt"))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read shell command capture: %v", err)
|
||||
}
|
||||
commandLine := strings.TrimSpace(string(commandBytes))
|
||||
if !strings.Contains(commandLine, "'hello world'") {
|
||||
t.Fatalf("shell command capture did not quote spaced argument: %q", commandLine)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCommandRunsInstalledToolThroughShellPath(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
validator := NewWorkspaceValidator(workspace)
|
||||
|
||||
result, err := validator.RunCommand(context.Background(), "go version", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("RunCommand returned error: %v", err)
|
||||
}
|
||||
if !result.Success {
|
||||
t.Fatalf("RunCommand returned unsuccessful result: %+v", result)
|
||||
}
|
||||
if !strings.Contains(result.Content, "go version") {
|
||||
t.Fatalf("RunCommand output did not include go version information: %q", result.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchFileGuardsAgainstStaleRanges(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
validator := NewWorkspaceValidator(workspace)
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("applies when expected text matches", func(t *testing.T) {
|
||||
path := filepath.Join(workspace, "guarded.txt")
|
||||
if err := os.WriteFile(path, []byte("one\ntwo\nthree\n"), 0644); err != nil {
|
||||
t.Fatalf("failed to write sample file: %v", err)
|
||||
}
|
||||
|
||||
result, err := validator.PatchFile(ctx, "guarded.txt", 2, 2, "two", "TWO")
|
||||
if err != nil {
|
||||
t.Fatalf("PatchFile returned error: %v", err)
|
||||
}
|
||||
if !result.Success {
|
||||
t.Fatalf("PatchFile returned unsuccessful result: %+v", result)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read patched file: %v", err)
|
||||
}
|
||||
want := "one\nTWO\nthree"
|
||||
if string(got) != want {
|
||||
t.Fatalf("PatchFile wrote %q, want %q", string(got), want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("matches a snippet inside a larger requested window", func(t *testing.T) {
|
||||
path := filepath.Join(workspace, "snippet.txt")
|
||||
original := "alpha\nbeta\n\n\nneedle\nomega\n"
|
||||
if err := os.WriteFile(path, []byte(original), 0644); err != nil {
|
||||
t.Fatalf("failed to write sample file: %v", err)
|
||||
}
|
||||
|
||||
result, err := validator.PatchFile(ctx, "snippet.txt", 1, -1, "\n\nneedle", "\n\nreplacement")
|
||||
if err != nil {
|
||||
t.Fatalf("PatchFile returned error: %v", err)
|
||||
}
|
||||
if !result.Success {
|
||||
t.Fatalf("PatchFile returned unsuccessful result: %+v", result)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read patched file: %v", err)
|
||||
}
|
||||
want := "alpha\nbeta\n\n\nreplacement\nomega"
|
||||
if string(got) != want {
|
||||
t.Fatalf("PatchFile wrote %q, want %q", string(got), want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("accepts read_file output as expected text", func(t *testing.T) {
|
||||
path := filepath.Join(workspace, "readfile.txt")
|
||||
if err := os.WriteFile(path, []byte("alpha\nbeta\ngamma\n"), 0644); err != nil {
|
||||
t.Fatalf("failed to write sample file: %v", err)
|
||||
}
|
||||
|
||||
readResult, err := validator.ReadFile(ctx, "readfile.txt", 1, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile returned error: %v", err)
|
||||
}
|
||||
|
||||
result, err := validator.PatchFile(ctx, "readfile.txt", 1, 2, readResult.Content, "ALPHA\nBETA")
|
||||
if err != nil {
|
||||
t.Fatalf("PatchFile returned error: %v", err)
|
||||
}
|
||||
if !result.Success {
|
||||
t.Fatalf("PatchFile returned unsuccessful result: %+v", result)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read patched file: %v", err)
|
||||
}
|
||||
want := "ALPHA\nBETA\ngamma"
|
||||
if string(got) != want {
|
||||
t.Fatalf("PatchFile wrote %q, want %q", string(got), want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("matches blank lines exactly", func(t *testing.T) {
|
||||
path := filepath.Join(workspace, "blank.txt")
|
||||
if err := os.WriteFile(path, []byte("alpha\n\nomega\n"), 0644); err != nil {
|
||||
t.Fatalf("failed to write sample file: %v", err)
|
||||
}
|
||||
|
||||
result, err := validator.PatchFile(ctx, "blank.txt", 2, 2, "\n", "BETA")
|
||||
if err != nil {
|
||||
t.Fatalf("PatchFile returned error: %v", err)
|
||||
}
|
||||
if !result.Success {
|
||||
t.Fatalf("PatchFile returned unsuccessful result: %+v", result)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read patched file: %v", err)
|
||||
}
|
||||
want := "alpha\nBETA\nomega"
|
||||
if string(got) != want {
|
||||
t.Fatalf("PatchFile wrote %q, want %q", string(got), want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects stale content", func(t *testing.T) {
|
||||
path := filepath.Join(workspace, "stale.txt")
|
||||
if err := os.WriteFile(path, []byte("alpha\nbeta\n"), 0644); err != nil {
|
||||
t.Fatalf("failed to write sample file: %v", err)
|
||||
}
|
||||
|
||||
result, err := validator.PatchFile(ctx, "stale.txt", 2, 2, "gamma", "BETA")
|
||||
if err != nil {
|
||||
t.Fatalf("PatchFile returned error: %v", err)
|
||||
}
|
||||
if result.Success {
|
||||
t.Fatalf("expected guarded patch to fail, got success: %+v", result)
|
||||
}
|
||||
if !strings.Contains(result.Error, "patch guard failed") {
|
||||
t.Fatalf("unexpected guarded patch error: %+v", result)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read unchanged file: %v", err)
|
||||
}
|
||||
want := "alpha\nbeta\n"
|
||||
if string(got) != want {
|
||||
t.Fatalf("PatchFile changed file content unexpectedly: %q", string(got))
|
||||
}
|
||||
})
|
||||
}
|
||||
2252
web/app.js
Normal file
2252
web/app.js
Normal file
File diff suppressed because it is too large
Load Diff
406
web/index.html
Normal file
406
web/index.html
Normal file
@@ -0,0 +1,406 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>GoCoder</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<style>
|
||||
.conversation-item {
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
.conversation-item:hover {
|
||||
border-left-color: #cbd5e1;
|
||||
}
|
||||
.conversation-item.active {
|
||||
border-left-color: #3b82f6;
|
||||
background-color: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
.conversation-item.active .text-gray-400 {
|
||||
color: #3b82f6;
|
||||
}
|
||||
.loading-dots {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.loading-dots .dot {
|
||||
animation: blink 1.4s infinite both;
|
||||
animation-duration: 1.4s;
|
||||
}
|
||||
.loading-dots .dot:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
.loading-dots .dot:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
@keyframes blink {
|
||||
0%, 80%, 100% { opacity: 0; }
|
||||
40% { opacity: 1; }
|
||||
}
|
||||
.sidebar {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
z-index: 50;
|
||||
height: 100vh;
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
.sidebar.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
.sidebar-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.3);
|
||||
z-index: 40;
|
||||
}
|
||||
.sidebar-overlay.open {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen bg-gray-50">
|
||||
<div class="flex h-screen">
|
||||
<!-- Sidebar -->
|
||||
<div class="sidebar w-72 bg-white border-r border-gray-200 flex flex-col" id="sidebar">
|
||||
<div class="p-4 border-b border-gray-200">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="text-lg font-semibold text-gray-800">Workspaces</h2>
|
||||
<div class="flex gap-1">
|
||||
<button id="newGroupBtn" class="p-1.5 rounded-lg hover:bg-gray-100 text-gray-600 transition-colors" title="New workspace">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>
|
||||
</button>
|
||||
<button id="newConversationBtn" class="p-1.5 rounded-lg hover:bg-gray-100 text-gray-600 transition-colors" title="New conversation">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="conversationList" class="flex-1 overflow-y-auto p-2 space-y-3">
|
||||
</div>
|
||||
<div class="p-3 border-t border-gray-200">
|
||||
<button id="mobileMenuBtn" class="md:hidden w-full text-center text-sm text-gray-500 hover:text-gray-700 py-2">
|
||||
Close sidebar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar overlay for mobile -->
|
||||
<div class="sidebar-overlay" id="sidebarOverlay"></div>
|
||||
|
||||
<!-- Group settings modal -->
|
||||
<div id="groupSettingsModal" class="fixed inset-0 z-50 hidden items-center justify-center" style="background: rgba(0,0,0,0.4);">
|
||||
<div class="bg-white rounded-2xl shadow-xl w-full max-w-md mx-4 overflow-hidden">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<h3 class="text-lg font-semibold text-gray-800">Workspace Settings</h3>
|
||||
<button id="closeGroupSettingsModal" class="p-1.5 rounded-lg hover:bg-gray-100 text-gray-400 hover:text-gray-600 transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<form id="groupSettingsForm">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1.5">Workspace name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="groupSettingsNameInput"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-800"
|
||||
required
|
||||
>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1.5 mt-4">Workspace folder</label>
|
||||
<div id="groupSettingsLocation" class="hidden mb-3 px-3 py-2 bg-gray-50 border border-gray-200 rounded-xl">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-gray-400 flex-shrink-0"><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>
|
||||
<span id="groupSettingsLocationText" class="text-sm text-gray-600 truncate"></span>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" id="groupSettingsPickBtn" class="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-gray-700 font-medium hover:bg-gray-50 transition-colors flex items-center justify-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></svg>
|
||||
Pick Folder
|
||||
</button>
|
||||
<div class="flex gap-3 mt-5">
|
||||
<button type="button" id="cancelGroupSettingsBtn" class="flex-1 px-4 py-2.5 border border-gray-300 rounded-xl text-gray-700 font-medium hover:bg-gray-50 transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="flex-1 px-4 py-2.5 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-xl transition-colors">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Runtime settings modal -->
|
||||
<div id="settingsModal" class="fixed inset-0 z-50 hidden items-center justify-center p-4" style="background: rgba(0,0,0,0.4);">
|
||||
<div class="bg-white rounded-2xl shadow-xl w-full max-w-2xl max-h-[90vh] overflow-hidden">
|
||||
<div class="max-h-[90vh] overflow-y-auto">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-800">Runtime Settings</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">Edit the model endpoint, generation params, server settings, and compaction budget used by the chat runtime.</p>
|
||||
</div>
|
||||
<button id="closeSettingsModal" class="p-1.5 rounded-lg hover:bg-gray-100 text-gray-400 hover:text-gray-600 transition-colors flex-shrink-0">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<form id="settingsForm">
|
||||
<div class="space-y-4">
|
||||
<div class="rounded-2xl border border-gray-200 bg-gray-50/70 p-4">
|
||||
<div class="mb-3 flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.2em] text-gray-500">
|
||||
<span>Connection</span>
|
||||
</div>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1.5" for="settingsProvider">Provider</label>
|
||||
<input type="text" id="settingsProvider" required class="w-full px-4 py-2.5 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-800">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1.5" for="settingsModel">Model</label>
|
||||
<input type="text" id="settingsModel" required class="w-full px-4 py-2.5 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-800">
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1.5" for="settingsBaseURL">Base URL</label>
|
||||
<input type="url" id="settingsBaseURL" required class="w-full px-4 py-2.5 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-800">
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1.5" for="settingsAPIKey">API key</label>
|
||||
<input type="password" id="settingsAPIKey" autocomplete="off" class="w-full px-4 py-2.5 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-800">
|
||||
<p class="mt-1 text-xs text-gray-500">Leave as-is to keep the current key or replace it with a new one.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-gray-200 bg-gray-50/70 p-4">
|
||||
<div class="mb-3 flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.2em] text-gray-500">
|
||||
<span>Server</span>
|
||||
</div>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1.5" for="settingsServerHost">Host</label>
|
||||
<input type="text" id="settingsServerHost" required placeholder="localhost" class="w-full px-4 py-2.5 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-800">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1.5" for="settingsServerAddr">Address</label>
|
||||
<input type="text" id="settingsServerAddr" required placeholder=":8080" class="w-full px-4 py-2.5 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-800">
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<p class="text-xs text-gray-500">Server settings are saved immediately but only take effect after you restart the app.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-gray-200 bg-gray-50/70 p-4">
|
||||
<div class="mb-3 flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.2em] text-gray-500">
|
||||
<span>Generation</span>
|
||||
</div>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1.5" for="settingsTemperature">Temperature</label>
|
||||
<input type="number" id="settingsTemperature" min="0" step="0.01" required class="w-full px-4 py-2.5 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-800">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1.5" for="settingsMaxTokens">Max tokens</label>
|
||||
<input type="number" id="settingsMaxTokens" min="1" step="1" required class="w-full px-4 py-2.5 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-800">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1.5" for="settingsTopP">Top P</label>
|
||||
<input type="number" id="settingsTopP" min="0.01" max="1" step="0.01" required class="w-full px-4 py-2.5 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-800">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1.5" for="settingsFrequencyPenalty">Frequency penalty</label>
|
||||
<input type="number" id="settingsFrequencyPenalty" step="0.1" class="w-full px-4 py-2.5 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-800">
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1.5" for="settingsPresencePenalty">Presence penalty</label>
|
||||
<input type="number" id="settingsPresencePenalty" step="0.1" class="w-full px-4 py-2.5 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-800">
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1.5" for="settingsReasoningEffort">Reasoning effort</label>
|
||||
<select id="settingsReasoningEffort" class="w-full px-4 py-2.5 border border-gray-300 rounded-xl bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-800">
|
||||
<option value="">Default</option>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-gray-500">Forwarded to LM Studio when the backend supports reasoning controls.</p>
|
||||
</div>
|
||||
<div class="md:col-span-2 flex items-start gap-3 rounded-xl border border-gray-200 bg-white px-4 py-3">
|
||||
<input type="checkbox" id="settingsAutoOpenReasoning" class="mt-1 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
||||
<div class="min-w-0">
|
||||
<label class="block text-sm font-medium text-gray-700" for="settingsAutoOpenReasoning">Auto-open reasoning</label>
|
||||
<p class="mt-1 text-xs text-gray-500">When enabled, live reasoning starts expanded. You can still collapse it while the model is thinking.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-gray-200 bg-gray-50/70 p-4">
|
||||
<div class="mb-3 flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.2em] text-gray-500">
|
||||
<span>Context compaction</span>
|
||||
</div>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1.5" for="settingsContextTokens">Prompt budget (tokens)</label>
|
||||
<input type="number" id="settingsContextTokens" min="1" step="1" required class="w-full px-4 py-2.5 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-800">
|
||||
<p class="mt-1 text-xs text-gray-500">This is the estimated prompt size budget used before compaction starts.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1.5" for="settingsTriggerRatio">Compaction trigger</label>
|
||||
<input type="number" id="settingsTriggerRatio" min="0.01" max="0.99" step="0.01" required class="w-full px-4 py-2.5 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-800">
|
||||
<p class="mt-1 text-xs text-gray-500">Compaction starts above this fraction of the budget.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1.5" for="settingsTargetRatio">Compaction target</label>
|
||||
<input type="number" id="settingsTargetRatio" min="0.01" max="0.99" step="0.01" required class="w-full px-4 py-2.5 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-800">
|
||||
<p class="mt-1 text-xs text-gray-500">The summarizer tries to shrink history below this fraction.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p id="settingsFormError" class="mt-4 hidden text-sm text-red-600"></p>
|
||||
<div class="flex gap-3 mt-5">
|
||||
<button type="button" id="cancelSettingsBtn" class="flex-1 px-4 py-2.5 border border-gray-300 rounded-xl text-gray-700 font-medium hover:bg-gray-50 transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="flex-1 px-4 py-2.5 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-xl transition-colors">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Group creation modal -->
|
||||
<div id="groupModal" class="fixed inset-0 z-50 hidden items-center justify-center" style="background: rgba(0,0,0,0.4);">
|
||||
<div class="bg-white rounded-2xl shadow-xl w-full max-w-md mx-4 overflow-hidden">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<h3 class="text-lg font-semibold text-gray-800">New Workspace</h3>
|
||||
<button id="closeGroupModal" class="p-1.5 rounded-lg hover:bg-gray-100 text-gray-400 hover:text-gray-600 transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<form id="groupForm">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1.5">Workspace name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="groupNameInput"
|
||||
placeholder="e.g. My Project"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-800 placeholder:text-gray-400"
|
||||
required
|
||||
autofocus
|
||||
>
|
||||
<div class="flex gap-3 mt-5">
|
||||
<button type="button" id="cancelGroupBtn" class="flex-1 px-4 py-2.5 border border-gray-300 rounded-xl text-gray-700 font-medium hover:bg-gray-50 transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="flex-1 px-4 py-2.5 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-xl transition-colors">
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main content -->
|
||||
<div class="flex-1 flex flex-col overflow-hidden">
|
||||
<!-- Header -->
|
||||
<div class="bg-white border-b border-gray-200 px-6 py-4 flex items-center gap-4">
|
||||
<button id="mobileMenuBtnDesktop" class="md:hidden p-2 rounded-lg hover:bg-gray-100 text-gray-600 transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||||
</button>
|
||||
<div class="flex items-center gap-2 flex-1 min-w-0">
|
||||
<h1 class="text-xl font-bold text-gray-800">GoCoder</h1>
|
||||
<span class="text-gray-300 text-lg leading-none mx-1 flex-shrink-0">›</span>
|
||||
<div id="headerLocation" class="hidden items-center gap-1.5">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-gray-400"><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>
|
||||
<span id="headerLocationText" class="text-sm text-gray-500 truncate max-w-xs"></span>
|
||||
<button id="changeLocationBtn" class="hidden p-1 rounded hover:bg-gray-100 text-gray-400 hover:text-gray-600 transition-colors" title="Change location">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<button id="pickLocationBtn" class="hidden items-center gap-1 px-2.5 py-1 text-xs bg-gray-100 hover:bg-gray-200 text-gray-600 rounded-lg transition-colors border border-gray-200">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></svg>
|
||||
Pick Location
|
||||
</button>
|
||||
</div>
|
||||
<button id="openSettingsBtn" class="p-2 rounded-lg hover:bg-gray-100 text-gray-500 hover:text-gray-700 transition-colors" title="Runtime settings">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Chat area -->
|
||||
<div id="chatScrollArea" class="flex-1 overflow-y-auto px-4 md:px-8 lg:px-16 py-6">
|
||||
<div id="result" class="hidden">
|
||||
<div id="resultText" class="space-y-4"></div>
|
||||
</div>
|
||||
<div id="emptyState" class="flex flex-col items-center justify-center h-full text-center">
|
||||
<div class="w-20 h-20 bg-gray-100 rounded-2xl flex items-center justify-center mb-6">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||
</div>
|
||||
<h2 class="text-2xl font-semibold text-gray-700 mb-2">Start a conversation</h2>
|
||||
<p class="text-gray-500 mb-6">Type a message to begin, or select an existing conversation</p>
|
||||
<button id="newConversationBtnDesktop" class="bg-blue-600 hover:bg-blue-700 text-white font-medium px-6 py-3 rounded-lg transition-colors">
|
||||
New Conversation
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Input area -->
|
||||
<div class="bg-white border-t border-gray-200 px-4 md:px-8 lg:px-16 py-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="text-xs text-gray-500 font-medium">Agent:</span>
|
||||
<div id="agentSelector" class="flex gap-1.5 flex-wrap">
|
||||
<button type="button" data-agent="none" class="agent-btn px-3 py-1 text-xs font-medium rounded-lg border border-gray-300 text-gray-600 hover:bg-gray-100 transition-colors bg-blue-50 border-blue-300 text-blue-700">None</button>
|
||||
<button type="button" data-agent="dadjokes" class="agent-btn px-3 py-1 text-xs font-medium rounded-lg border border-gray-300 text-gray-600 hover:bg-gray-100 transition-colors">Dadjokes</button>
|
||||
<button type="button" data-agent="poet" class="agent-btn px-3 py-1 text-xs font-medium rounded-lg border border-gray-300 text-gray-600 hover:bg-gray-100 transition-colors">Poet</button>
|
||||
<button type="button" data-agent="robot" class="agent-btn px-3 py-1 text-xs font-medium rounded-lg border border-gray-300 text-gray-600 hover:bg-gray-100 transition-colors">Robot</button>
|
||||
</div>
|
||||
</div>
|
||||
<form id="messageForm" class="flex items-end gap-3">
|
||||
<textarea
|
||||
id="messageInput"
|
||||
name="message"
|
||||
rows="1"
|
||||
placeholder="Type a message... Enter sends, Shift+Enter adds a new line"
|
||||
aria-describedby="messageInputHint"
|
||||
class="message-input flex-1 px-4 py-3 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-800"
|
||||
required
|
||||
></textarea>
|
||||
<button
|
||||
type="submit"
|
||||
id="sendBtn"
|
||||
class="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed text-white font-medium px-6 py-3 rounded-xl transition-colors"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
id="cancelBtn"
|
||||
class="hidden border border-red-200 bg-white px-5 py-3 font-medium text-red-600 rounded-xl transition-colors hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
</form>
|
||||
<div id="messageInputHint" class="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-gray-500">
|
||||
<span><kbd class="rounded border border-gray-200 bg-gray-100 px-1.5 py-0.5 font-mono text-[10px] text-gray-700">Enter</kbd> sends</span>
|
||||
<span><kbd class="rounded border border-gray-200 bg-gray-100 px-1.5 py-0.5 font-mono text-[10px] text-gray-700">Shift</kbd>+<kbd class="rounded border border-gray-200 bg-gray-100 px-1.5 py-0.5 font-mono text-[10px] text-gray-700">Enter</kbd> adds a new line</span>
|
||||
<span><kbd class="rounded border border-gray-200 bg-gray-100 px-1.5 py-0.5 font-mono text-[10px] text-gray-700">Alt</kbd>, <kbd class="rounded border border-gray-200 bg-gray-100 px-1.5 py-0.5 font-mono text-[10px] text-gray-700">Ctrl</kbd>, or <kbd class="rounded border border-gray-200 bg-gray-100 px-1.5 py-0.5 font-mono text-[10px] text-gray-700">Cmd</kbd>+<kbd class="rounded border border-gray-200 bg-gray-100 px-1.5 py-0.5 font-mono text-[10px] text-gray-700">Enter</kbd> also add a new line</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
302
web/style.css
Normal file
302
web/style.css
Normal file
@@ -0,0 +1,302 @@
|
||||
.markdown-body {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.markdown-body p {
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
.markdown-body p:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.markdown-body p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.markdown-body pre {
|
||||
background: #1e1e2e;
|
||||
color: #cdd6f4;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
overflow-x: auto;
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
.markdown-body code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
.markdown-body :not(pre) > code {
|
||||
background: #e5e7eb;
|
||||
color: #1e40af;
|
||||
padding: 0.125em 0.375em;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.markdown-body pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.markdown-body blockquote {
|
||||
border-left: 3px solid #d1d5db;
|
||||
padding-left: 1rem;
|
||||
color: #6b7280;
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
.markdown-body ul,
|
||||
.markdown-body ol {
|
||||
padding-left: 1.5rem;
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
.markdown-body li {
|
||||
margin: 0.25em 0;
|
||||
}
|
||||
|
||||
.markdown-body li > p {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.markdown-body hr {
|
||||
border: none;
|
||||
border-top: 1px solid #d1d5db;
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.markdown-body table {
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
width: 100%;
|
||||
margin: 0.5em 0;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.markdown-body th,
|
||||
.markdown-body td {
|
||||
border: none;
|
||||
padding: 0.5rem 0.875rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.markdown-body th {
|
||||
background: #f9fafb;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.markdown-body tr:nth-child(even) {
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.markdown-body tr:nth-child(odd) {
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.markdown-body tr:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.markdown-body a {
|
||||
color: #2563eb;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.markdown-body img {
|
||||
max-width: 100%;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.markdown-body h1,
|
||||
.markdown-body h2,
|
||||
.markdown-body h3,
|
||||
.markdown-body h4,
|
||||
.markdown-body h5,
|
||||
.markdown-body h6 {
|
||||
margin: 1em 0 0.5em 0;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.markdown-body h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.markdown-body h2 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.markdown-body h3 {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.thinking-panel > summary {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.thinking-panel > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.thinking-chevron {
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.thinking-panel[open] .thinking-chevron {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.tool-meta-list {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tool-meta-row {
|
||||
display: grid;
|
||||
grid-template-columns: 6.5rem minmax(0, 1fr);
|
||||
gap: 0.75rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.tool-meta-key {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.tool-meta-value {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5;
|
||||
color: #0f172a;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.tool-text-block {
|
||||
margin: 0;
|
||||
max-height: 18rem;
|
||||
overflow: auto;
|
||||
border: 1px solid #dbe4f0;
|
||||
border-radius: 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
padding: 0.875rem 1rem;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5rem;
|
||||
color: #0f172a;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.tool-text-block-error {
|
||||
border-color: #fecaca;
|
||||
background: #fff1f2;
|
||||
color: #7f1d1d;
|
||||
}
|
||||
|
||||
.tool-placeholder {
|
||||
border: 1px dashed #cbd5e1;
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: #64748b;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.tool-file-preview {
|
||||
max-height: 12.25rem;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #dbe4f0;
|
||||
border-radius: 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
.tool-file-preview.is-expanded {
|
||||
max-height: 60vh;
|
||||
}
|
||||
|
||||
.tool-file-preview pre {
|
||||
margin: 0;
|
||||
padding: 0.875rem 1rem;
|
||||
background: transparent;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5rem;
|
||||
color: inherit;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.tool-diff {
|
||||
overflow: hidden;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid #0f172a;
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
.tool-diff-header {
|
||||
padding: 0.625rem 0.875rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.tool-diff-body {
|
||||
max-height: 18rem;
|
||||
overflow: auto;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5rem;
|
||||
}
|
||||
|
||||
.tool-diff-line {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0 0.875rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.tool-diff-prefix {
|
||||
flex: 0 0 1rem;
|
||||
text-align: center;
|
||||
user-select: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.tool-diff-remove {
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
color: #fecaca;
|
||||
}
|
||||
|
||||
.tool-diff-add {
|
||||
background: rgba(16, 185, 129, 0.08);
|
||||
color: #a7f3d0;
|
||||
}
|
||||
|
||||
.tool-diff-note {
|
||||
padding: 0.875rem;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.message-input {
|
||||
resize: none;
|
||||
overflow-y: hidden;
|
||||
min-height: 3.5rem;
|
||||
line-height: 1.5rem;
|
||||
}
|
||||
Reference in New Issue
Block a user