mirror of
https://github.com/YspCoder/clawgo.git
synced 2026-05-02 11:37:34 +08:00
Initial commit for ClawGo
This commit is contained in:
599
pkg/agent/loop.go
Normal file
599
pkg/agent/loop.go
Normal file
@@ -0,0 +1,599 @@
|
||||
// ClawGo - Ultra-lightweight personal AI agent
|
||||
// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 ClawGo contributors
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"gitea.kkkk.dev/DBT/clawgo/pkg/bus"
|
||||
"gitea.kkkk.dev/DBT/clawgo/pkg/config"
|
||||
"gitea.kkkk.dev/DBT/clawgo/pkg/cron"
|
||||
"gitea.kkkk.dev/DBT/clawgo/pkg/logger"
|
||||
"gitea.kkkk.dev/DBT/clawgo/pkg/providers"
|
||||
"gitea.kkkk.dev/DBT/clawgo/pkg/session"
|
||||
"gitea.kkkk.dev/DBT/clawgo/pkg/tools"
|
||||
)
|
||||
|
||||
type AgentLoop struct {
|
||||
bus *bus.MessageBus
|
||||
provider providers.LLMProvider
|
||||
workspace string
|
||||
model string
|
||||
maxIterations int
|
||||
sessions *session.SessionManager
|
||||
contextBuilder *ContextBuilder
|
||||
tools *tools.ToolRegistry
|
||||
running bool
|
||||
}
|
||||
|
||||
func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers.LLMProvider, cs *cron.CronService) *AgentLoop {
|
||||
workspace := cfg.WorkspacePath()
|
||||
os.MkdirAll(workspace, 0755)
|
||||
|
||||
toolsRegistry := tools.NewToolRegistry()
|
||||
toolsRegistry.Register(&tools.ReadFileTool{})
|
||||
toolsRegistry.Register(&tools.WriteFileTool{})
|
||||
toolsRegistry.Register(&tools.ListDirTool{})
|
||||
toolsRegistry.Register(tools.NewExecTool(workspace))
|
||||
|
||||
if cs != nil {
|
||||
toolsRegistry.Register(tools.NewRemindTool(cs))
|
||||
}
|
||||
|
||||
braveAPIKey := cfg.Tools.Web.Search.APIKey
|
||||
toolsRegistry.Register(tools.NewWebSearchTool(braveAPIKey, cfg.Tools.Web.Search.MaxResults))
|
||||
toolsRegistry.Register(tools.NewWebFetchTool(50000))
|
||||
|
||||
// Register message tool
|
||||
messageTool := tools.NewMessageTool()
|
||||
messageTool.SetSendCallback(func(channel, chatID, content string) error {
|
||||
msgBus.PublishOutbound(bus.OutboundMessage{
|
||||
Channel: channel,
|
||||
ChatID: chatID,
|
||||
Content: content,
|
||||
})
|
||||
return nil
|
||||
})
|
||||
toolsRegistry.Register(messageTool)
|
||||
|
||||
// Register spawn tool
|
||||
subagentManager := tools.NewSubagentManager(provider, workspace, msgBus)
|
||||
spawnTool := tools.NewSpawnTool(subagentManager)
|
||||
toolsRegistry.Register(spawnTool)
|
||||
|
||||
// Register edit file tool
|
||||
editFileTool := tools.NewEditFileTool(workspace)
|
||||
toolsRegistry.Register(editFileTool)
|
||||
|
||||
// Register memory search tool
|
||||
memorySearchTool := tools.NewMemorySearchTool(workspace)
|
||||
toolsRegistry.Register(memorySearchTool)
|
||||
|
||||
// Register camera tool
|
||||
toolsRegistry.Register(tools.NewCameraTool(workspace))
|
||||
// Register system info tool
|
||||
toolsRegistry.Register(tools.NewSystemInfoTool())
|
||||
|
||||
sessionsManager := session.NewSessionManager(filepath.Join(filepath.Dir(cfg.WorkspacePath()), "sessions"))
|
||||
|
||||
return &AgentLoop{
|
||||
bus: msgBus,
|
||||
provider: provider,
|
||||
workspace: workspace,
|
||||
model: cfg.Agents.Defaults.Model,
|
||||
maxIterations: cfg.Agents.Defaults.MaxToolIterations,
|
||||
sessions: sessionsManager,
|
||||
contextBuilder: NewContextBuilder(workspace, func() []string { return toolsRegistry.GetSummaries() }),
|
||||
tools: toolsRegistry,
|
||||
running: false,
|
||||
}
|
||||
}
|
||||
|
||||
func (al *AgentLoop) Run(ctx context.Context) error {
|
||||
al.running = true
|
||||
|
||||
for al.running {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
default:
|
||||
msg, ok := al.bus.ConsumeInbound(ctx)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
response, err := al.processMessage(ctx, msg)
|
||||
if err != nil {
|
||||
response = fmt.Sprintf("Error processing message: %v", err)
|
||||
}
|
||||
|
||||
if response != "" {
|
||||
al.bus.PublishOutbound(bus.OutboundMessage{
|
||||
Channel: msg.Channel,
|
||||
ChatID: msg.ChatID,
|
||||
Content: response,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (al *AgentLoop) Stop() {
|
||||
al.running = false
|
||||
}
|
||||
|
||||
func (al *AgentLoop) ProcessDirect(ctx context.Context, content, sessionKey string) (string, error) {
|
||||
msg := bus.InboundMessage{
|
||||
Channel: "cli",
|
||||
SenderID: "user",
|
||||
ChatID: "direct",
|
||||
Content: content,
|
||||
SessionKey: sessionKey,
|
||||
}
|
||||
|
||||
return al.processMessage(ctx, msg)
|
||||
}
|
||||
|
||||
func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) (string, error) {
|
||||
// Add message preview to log
|
||||
preview := truncate(msg.Content, 80)
|
||||
logger.InfoCF("agent", fmt.Sprintf("Processing message from %s:%s: %s", msg.Channel, msg.SenderID, preview),
|
||||
map[string]interface{}{
|
||||
"channel": msg.Channel,
|
||||
"chat_id": msg.ChatID,
|
||||
"sender_id": msg.SenderID,
|
||||
"session_key": msg.SessionKey,
|
||||
})
|
||||
|
||||
// Route system messages to processSystemMessage
|
||||
if msg.Channel == "system" {
|
||||
return al.processSystemMessage(ctx, msg)
|
||||
}
|
||||
|
||||
// Update tool contexts
|
||||
if tool, ok := al.tools.Get("message"); ok {
|
||||
if mt, ok := tool.(*tools.MessageTool); ok {
|
||||
mt.SetContext(msg.Channel, msg.ChatID)
|
||||
}
|
||||
}
|
||||
if tool, ok := al.tools.Get("spawn"); ok {
|
||||
if st, ok := tool.(*tools.SpawnTool); ok {
|
||||
st.SetContext(msg.Channel, msg.ChatID)
|
||||
}
|
||||
}
|
||||
|
||||
history := al.sessions.GetHistory(msg.SessionKey)
|
||||
summary := al.sessions.GetSummary(msg.SessionKey)
|
||||
|
||||
messages := al.contextBuilder.BuildMessages(
|
||||
history,
|
||||
summary,
|
||||
msg.Content,
|
||||
nil,
|
||||
msg.Channel,
|
||||
msg.ChatID,
|
||||
)
|
||||
|
||||
iteration := 0
|
||||
var finalContent string
|
||||
|
||||
for iteration < al.maxIterations {
|
||||
iteration++
|
||||
|
||||
logger.DebugCF("agent", "LLM iteration",
|
||||
map[string]interface{}{
|
||||
"iteration": iteration,
|
||||
"max": al.maxIterations,
|
||||
})
|
||||
|
||||
toolDefs := al.tools.GetDefinitions()
|
||||
providerToolDefs := make([]providers.ToolDefinition, 0, len(toolDefs))
|
||||
for _, td := range toolDefs {
|
||||
providerToolDefs = append(providerToolDefs, providers.ToolDefinition{
|
||||
Type: td["type"].(string),
|
||||
Function: providers.ToolFunctionDefinition{
|
||||
Name: td["function"].(map[string]interface{})["name"].(string),
|
||||
Description: td["function"].(map[string]interface{})["description"].(string),
|
||||
Parameters: td["function"].(map[string]interface{})["parameters"].(map[string]interface{}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Log LLM request details
|
||||
logger.DebugCF("agent", "LLM request",
|
||||
map[string]interface{}{
|
||||
"iteration": iteration,
|
||||
"model": al.model,
|
||||
"messages_count": len(messages),
|
||||
"tools_count": len(providerToolDefs),
|
||||
"max_tokens": 8192,
|
||||
"temperature": 0.7,
|
||||
"system_prompt_len": len(messages[0].Content),
|
||||
})
|
||||
|
||||
// Log full messages (detailed)
|
||||
logger.DebugCF("agent", "Full LLM request",
|
||||
map[string]interface{}{
|
||||
"iteration": iteration,
|
||||
"messages_json": formatMessagesForLog(messages),
|
||||
"tools_json": formatToolsForLog(providerToolDefs),
|
||||
})
|
||||
|
||||
response, err := al.provider.Chat(ctx, messages, providerToolDefs, al.model, map[string]interface{}{
|
||||
"max_tokens": 8192,
|
||||
"temperature": 0.7,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
logger.ErrorCF("agent", "LLM call failed",
|
||||
map[string]interface{}{
|
||||
"iteration": iteration,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return "", fmt.Errorf("LLM call failed: %w", err)
|
||||
}
|
||||
|
||||
if len(response.ToolCalls) == 0 {
|
||||
finalContent = response.Content
|
||||
logger.InfoCF("agent", "LLM response without tool calls (direct answer)",
|
||||
map[string]interface{}{
|
||||
"iteration": iteration,
|
||||
"content_chars": len(finalContent),
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
toolNames := make([]string, 0, len(response.ToolCalls))
|
||||
for _, tc := range response.ToolCalls {
|
||||
toolNames = append(toolNames, tc.Name)
|
||||
}
|
||||
logger.InfoCF("agent", "LLM requested tool calls",
|
||||
map[string]interface{}{
|
||||
"tools": toolNames,
|
||||
"count": len(toolNames),
|
||||
"iteration": iteration,
|
||||
})
|
||||
|
||||
assistantMsg := providers.Message{
|
||||
Role: "assistant",
|
||||
Content: response.Content,
|
||||
}
|
||||
|
||||
for _, tc := range response.ToolCalls {
|
||||
argumentsJSON, _ := json.Marshal(tc.Arguments)
|
||||
assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, providers.ToolCall{
|
||||
ID: tc.ID,
|
||||
Type: "function",
|
||||
Function: &providers.FunctionCall{
|
||||
Name: tc.Name,
|
||||
Arguments: string(argumentsJSON),
|
||||
},
|
||||
})
|
||||
}
|
||||
messages = append(messages, assistantMsg)
|
||||
|
||||
for _, tc := range response.ToolCalls {
|
||||
// Log tool call with arguments preview
|
||||
argsJSON, _ := json.Marshal(tc.Arguments)
|
||||
argsPreview := truncate(string(argsJSON), 200)
|
||||
logger.InfoCF("agent", fmt.Sprintf("Tool call: %s(%s)", tc.Name, argsPreview),
|
||||
map[string]interface{}{
|
||||
"tool": tc.Name,
|
||||
"iteration": iteration,
|
||||
})
|
||||
|
||||
result, err := al.tools.Execute(ctx, tc.Name, tc.Arguments)
|
||||
if err != nil {
|
||||
result = fmt.Sprintf("Error: %v", err)
|
||||
}
|
||||
|
||||
toolResultMsg := providers.Message{
|
||||
Role: "tool",
|
||||
Content: result,
|
||||
ToolCallID: tc.ID,
|
||||
}
|
||||
messages = append(messages, toolResultMsg)
|
||||
}
|
||||
}
|
||||
|
||||
if finalContent == "" {
|
||||
finalContent = "I've completed processing but have no response to give."
|
||||
}
|
||||
|
||||
// Filter out <think>...</think> content from user-facing response
|
||||
// Keep full content in debug logs if needed, but remove from final output
|
||||
re := regexp.MustCompile(`(?s)<think>.*?</think>`)
|
||||
userContent := re.ReplaceAllString(finalContent, "")
|
||||
userContent = strings.TrimSpace(userContent)
|
||||
if userContent == "" && finalContent != "" {
|
||||
// If only thoughts were present, maybe provide a generic "Done" or keep something?
|
||||
// For now, let's assume thoughts are auxiliary and empty response is okay if tools did work.
|
||||
// If no tools ran and only thoughts, user might be confused.
|
||||
if iteration == 1 {
|
||||
userContent = "Thinking process completed."
|
||||
}
|
||||
}
|
||||
|
||||
al.sessions.AddMessage(msg.SessionKey, "user", msg.Content)
|
||||
// We store the filtered content in history so the model sees what the user saw
|
||||
// (or we could store full content if we want the model to remember its thoughts)
|
||||
// The prompt says "filter out ... from the user-facing response".
|
||||
// I'll store the filtered version to be safe.
|
||||
al.sessions.AddMessage(msg.SessionKey, "assistant", userContent)
|
||||
al.sessions.Save(al.sessions.GetOrCreate(msg.SessionKey))
|
||||
|
||||
// Log response preview (original content)
|
||||
responsePreview := truncate(finalContent, 120)
|
||||
logger.InfoCF("agent", fmt.Sprintf("Response to %s:%s: %s", msg.Channel, msg.SenderID, responsePreview),
|
||||
map[string]interface{}{
|
||||
"iterations": iteration,
|
||||
"final_length": len(finalContent),
|
||||
"user_length": len(userContent),
|
||||
})
|
||||
|
||||
return userContent, nil
|
||||
}
|
||||
|
||||
func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMessage) (string, error) {
|
||||
// Verify this is a system message
|
||||
if msg.Channel != "system" {
|
||||
return "", fmt.Errorf("processSystemMessage called with non-system message channel: %s", msg.Channel)
|
||||
}
|
||||
|
||||
logger.InfoCF("agent", "Processing system message",
|
||||
map[string]interface{}{
|
||||
"sender_id": msg.SenderID,
|
||||
"chat_id": msg.ChatID,
|
||||
})
|
||||
|
||||
// Parse origin from chat_id (format: "channel:chat_id")
|
||||
var originChannel, originChatID string
|
||||
if idx := strings.Index(msg.ChatID, ":"); idx > 0 {
|
||||
originChannel = msg.ChatID[:idx]
|
||||
originChatID = msg.ChatID[idx+1:]
|
||||
} else {
|
||||
// Fallback
|
||||
originChannel = "cli"
|
||||
originChatID = msg.ChatID
|
||||
}
|
||||
|
||||
// Use the origin session for context
|
||||
sessionKey := fmt.Sprintf("%s:%s", originChannel, originChatID)
|
||||
|
||||
// Update tool contexts to original channel/chatID
|
||||
if tool, ok := al.tools.Get("message"); ok {
|
||||
if mt, ok := tool.(*tools.MessageTool); ok {
|
||||
mt.SetContext(originChannel, originChatID)
|
||||
}
|
||||
}
|
||||
if tool, ok := al.tools.Get("spawn"); ok {
|
||||
if st, ok := tool.(*tools.SpawnTool); ok {
|
||||
st.SetContext(originChannel, originChatID)
|
||||
}
|
||||
}
|
||||
|
||||
// Build messages with the announce content
|
||||
history := al.sessions.GetHistory(sessionKey)
|
||||
summary := al.sessions.GetSummary(sessionKey)
|
||||
messages := al.contextBuilder.BuildMessages(
|
||||
history,
|
||||
summary,
|
||||
msg.Content,
|
||||
nil,
|
||||
originChannel,
|
||||
originChatID,
|
||||
)
|
||||
|
||||
iteration := 0
|
||||
var finalContent string
|
||||
|
||||
for iteration < al.maxIterations {
|
||||
iteration++
|
||||
|
||||
toolDefs := al.tools.GetDefinitions()
|
||||
providerToolDefs := make([]providers.ToolDefinition, 0, len(toolDefs))
|
||||
for _, td := range toolDefs {
|
||||
providerToolDefs = append(providerToolDefs, providers.ToolDefinition{
|
||||
Type: td["type"].(string),
|
||||
Function: providers.ToolFunctionDefinition{
|
||||
Name: td["function"].(map[string]interface{})["name"].(string),
|
||||
Description: td["function"].(map[string]interface{})["description"].(string),
|
||||
Parameters: td["function"].(map[string]interface{})["parameters"].(map[string]interface{}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Log LLM request details
|
||||
logger.DebugCF("agent", "LLM request",
|
||||
map[string]interface{}{
|
||||
"iteration": iteration,
|
||||
"model": al.model,
|
||||
"messages_count": len(messages),
|
||||
"tools_count": len(providerToolDefs),
|
||||
"max_tokens": 8192,
|
||||
"temperature": 0.7,
|
||||
"system_prompt_len": len(messages[0].Content),
|
||||
})
|
||||
|
||||
// Log full messages (detailed)
|
||||
logger.DebugCF("agent", "Full LLM request",
|
||||
map[string]interface{}{
|
||||
"iteration": iteration,
|
||||
"messages_json": formatMessagesForLog(messages),
|
||||
"tools_json": formatToolsForLog(providerToolDefs),
|
||||
})
|
||||
|
||||
response, err := al.provider.Chat(ctx, messages, providerToolDefs, al.model, map[string]interface{}{
|
||||
"max_tokens": 8192,
|
||||
"temperature": 0.7,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
logger.ErrorCF("agent", "LLM call failed in system message",
|
||||
map[string]interface{}{
|
||||
"iteration": iteration,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return "", fmt.Errorf("LLM call failed: %w", err)
|
||||
}
|
||||
|
||||
if len(response.ToolCalls) == 0 {
|
||||
finalContent = response.Content
|
||||
break
|
||||
}
|
||||
|
||||
assistantMsg := providers.Message{
|
||||
Role: "assistant",
|
||||
Content: response.Content,
|
||||
}
|
||||
|
||||
for _, tc := range response.ToolCalls {
|
||||
argumentsJSON, _ := json.Marshal(tc.Arguments)
|
||||
assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, providers.ToolCall{
|
||||
ID: tc.ID,
|
||||
Type: "function",
|
||||
Function: &providers.FunctionCall{
|
||||
Name: tc.Name,
|
||||
Arguments: string(argumentsJSON),
|
||||
},
|
||||
})
|
||||
}
|
||||
messages = append(messages, assistantMsg)
|
||||
|
||||
for _, tc := range response.ToolCalls {
|
||||
result, err := al.tools.Execute(ctx, tc.Name, tc.Arguments)
|
||||
if err != nil {
|
||||
result = fmt.Sprintf("Error: %v", err)
|
||||
}
|
||||
|
||||
toolResultMsg := providers.Message{
|
||||
Role: "tool",
|
||||
Content: result,
|
||||
ToolCallID: tc.ID,
|
||||
}
|
||||
messages = append(messages, toolResultMsg)
|
||||
}
|
||||
}
|
||||
|
||||
if finalContent == "" {
|
||||
finalContent = "Background task completed."
|
||||
}
|
||||
|
||||
// Save to session with system message marker
|
||||
al.sessions.AddMessage(sessionKey, "user", fmt.Sprintf("[System: %s] %s", msg.SenderID, msg.Content))
|
||||
al.sessions.AddMessage(sessionKey, "assistant", finalContent)
|
||||
al.sessions.Save(al.sessions.GetOrCreate(sessionKey))
|
||||
|
||||
logger.InfoCF("agent", "System message processing completed",
|
||||
map[string]interface{}{
|
||||
"iterations": iteration,
|
||||
"final_length": len(finalContent),
|
||||
})
|
||||
|
||||
return finalContent, nil
|
||||
}
|
||||
|
||||
// truncate returns a truncated version of s with at most maxLen characters.
|
||||
// If the string is truncated, "..." is appended to indicate truncation.
|
||||
// If the string fits within maxLen, it is returned unchanged.
|
||||
func truncate(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
// Reserve 3 chars for "..."
|
||||
if maxLen <= 3 {
|
||||
return s[:maxLen]
|
||||
}
|
||||
return s[:maxLen-3] + "..."
|
||||
}
|
||||
|
||||
// GetStartupInfo returns information about loaded tools and skills for logging.
|
||||
func (al *AgentLoop) GetStartupInfo() map[string]interface{} {
|
||||
info := make(map[string]interface{})
|
||||
|
||||
// Tools info
|
||||
tools := al.tools.List()
|
||||
info["tools"] = map[string]interface{}{
|
||||
"count": len(tools),
|
||||
"names": tools,
|
||||
}
|
||||
|
||||
// Skills info
|
||||
info["skills"] = al.contextBuilder.GetSkillsInfo()
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
// formatMessagesForLog formats messages for logging
|
||||
func formatMessagesForLog(messages []providers.Message) string {
|
||||
if len(messages) == 0 {
|
||||
return "[]"
|
||||
}
|
||||
|
||||
var result string
|
||||
result += "[\n"
|
||||
for i, msg := range messages {
|
||||
result += fmt.Sprintf(" [%d] Role: %s\n", i, msg.Role)
|
||||
if msg.ToolCalls != nil && len(msg.ToolCalls) > 0 {
|
||||
result += " ToolCalls:\n"
|
||||
for _, tc := range msg.ToolCalls {
|
||||
result += fmt.Sprintf(" - ID: %s, Type: %s, Name: %s\n", tc.ID, tc.Type, tc.Name)
|
||||
if tc.Function != nil {
|
||||
result += fmt.Sprintf(" Arguments: %s\n", truncateString(tc.Function.Arguments, 200))
|
||||
}
|
||||
}
|
||||
}
|
||||
if msg.Content != "" {
|
||||
content := truncateString(msg.Content, 200)
|
||||
result += fmt.Sprintf(" Content: %s\n", content)
|
||||
}
|
||||
if msg.ToolCallID != "" {
|
||||
result += fmt.Sprintf(" ToolCallID: %s\n", msg.ToolCallID)
|
||||
}
|
||||
result += "\n"
|
||||
}
|
||||
result += "]"
|
||||
return result
|
||||
}
|
||||
|
||||
// formatToolsForLog formats tool definitions for logging
|
||||
func formatToolsForLog(tools []providers.ToolDefinition) string {
|
||||
if len(tools) == 0 {
|
||||
return "[]"
|
||||
}
|
||||
|
||||
var result string
|
||||
result += "[\n"
|
||||
for i, tool := range tools {
|
||||
result += fmt.Sprintf(" [%d] Type: %s, Name: %s\n", i, tool.Type, tool.Function.Name)
|
||||
result += fmt.Sprintf(" Description: %s\n", tool.Function.Description)
|
||||
if len(tool.Function.Parameters) > 0 {
|
||||
result += fmt.Sprintf(" Parameters: %s\n", truncateString(fmt.Sprintf("%v", tool.Function.Parameters), 200))
|
||||
}
|
||||
}
|
||||
result += "]"
|
||||
return result
|
||||
}
|
||||
|
||||
// truncateString truncates a string to max length
|
||||
func truncateString(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
if maxLen <= 3 {
|
||||
return s[:maxLen]
|
||||
}
|
||||
return s[:maxLen-3] + "..."
|
||||
}
|
||||
Reference in New Issue
Block a user