添加ai支持
This commit is contained in:
@@ -0,0 +1,89 @@
|
|||||||
|
package agents
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ToolConfig struct {
|
||||||
|
Name string
|
||||||
|
Enabled bool
|
||||||
|
Description string
|
||||||
|
}
|
||||||
|
|
||||||
|
type TraceFunc func(tool string, stage string, status string, message string, data map[string]interface{})
|
||||||
|
|
||||||
|
type Tool interface {
|
||||||
|
Name() string
|
||||||
|
Enabled(config ToolConfig) bool
|
||||||
|
ShouldUse(messages []ChatMessage) bool
|
||||||
|
Enrich(ctx context.Context, messages []ChatMessage, config ToolConfig, trace TraceFunc) ([]ChatMessage, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChatMessage struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var registry []Tool
|
||||||
|
|
||||||
|
func Register(tool Tool) {
|
||||||
|
registry = append(registry, tool)
|
||||||
|
}
|
||||||
|
|
||||||
|
func EnabledTools(configs []ToolConfig) []Tool {
|
||||||
|
tools := make([]Tool, 0)
|
||||||
|
for _, tool := range registry {
|
||||||
|
config := FindToolConfig(configs, tool.Name())
|
||||||
|
if tool.Enabled(config) {
|
||||||
|
tools = append(tools, tool)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tools
|
||||||
|
}
|
||||||
|
|
||||||
|
func EnrichMessages(ctx context.Context, messages []ChatMessage, configs []ToolConfig, trace TraceFunc) []ChatMessage {
|
||||||
|
enriched := append([]ChatMessage{}, messages...)
|
||||||
|
for _, tool := range EnabledTools(configs) {
|
||||||
|
if !tool.ShouldUse(enriched) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
config := FindToolConfig(configs, tool.Name())
|
||||||
|
var err error
|
||||||
|
enriched, err = tool.Enrich(ctx, enriched, config, trace)
|
||||||
|
if err != nil && trace != nil {
|
||||||
|
trace(tool.Name(), "execute", "error", err.Error(), nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return enriched
|
||||||
|
}
|
||||||
|
|
||||||
|
func FindToolConfig(configs []ToolConfig, name string) ToolConfig {
|
||||||
|
for _, config := range configs {
|
||||||
|
if strings.EqualFold(config.Name, name) {
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ToolConfig{Name: name, Enabled: false}
|
||||||
|
}
|
||||||
|
|
||||||
|
func LastUserContent(messages []ChatMessage) string {
|
||||||
|
for i := len(messages) - 1; i >= 0; i-- {
|
||||||
|
if messages[i].Role == "user" {
|
||||||
|
return messages[i].Content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func SystemMessage(content string) ChatMessage {
|
||||||
|
return ChatMessage{Role: "system", Content: content}
|
||||||
|
}
|
||||||
|
|
||||||
|
func SafeString(value interface{}) string {
|
||||||
|
if value == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprint(value)
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package agents
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TimeTool struct{}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Register(TimeTool{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (TimeTool) Name() string {
|
||||||
|
return "time"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (TimeTool) Enabled(config ToolConfig) bool {
|
||||||
|
return config.Enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
func (TimeTool) ShouldUse(messages []ChatMessage) bool {
|
||||||
|
content := strings.ToLower(LastUserContent(messages))
|
||||||
|
keywords := []string{
|
||||||
|
"时间", "日期", "今天", "昨天", "明天", "本周", "这周", "上周", "下周", "本月", "这个月", "上月", "下月", "今年", "去年", "明年",
|
||||||
|
"time", "date", "today", "yesterday", "tomorrow", "week", "month", "year", "now",
|
||||||
|
}
|
||||||
|
for _, keyword := range keywords {
|
||||||
|
if strings.Contains(content, keyword) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (TimeTool) Enrich(ctx context.Context, messages []ChatMessage, config ToolConfig, trace TraceFunc) ([]ChatMessage, error) {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return messages, ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
content := buildTimeContext(now)
|
||||||
|
if trace != nil {
|
||||||
|
trace("time", "execute", "success", "已获取当前时间上下文", map[string]interface{}{
|
||||||
|
"now": now.Format("2006-01-02 15:04:05"),
|
||||||
|
"today": now.Format("2006-01-02"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
enriched := append([]ChatMessage{}, messages...)
|
||||||
|
enriched = append(enriched, SystemMessage(content))
|
||||||
|
return enriched, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildTimeContext(now time.Time) string {
|
||||||
|
todayStart := dateStart(now)
|
||||||
|
weekStart := todayStart.AddDate(0, 0, -int((int(todayStart.Weekday())+6)%7))
|
||||||
|
monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
|
||||||
|
yearStart := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, now.Location())
|
||||||
|
|
||||||
|
return fmt.Sprintf(`以下是当前时间上下文,请在回答涉及相对日期/时间的问题时使用:
|
||||||
|
- 当前时间:%s
|
||||||
|
- 今天:%s,范围 %s 至 %s
|
||||||
|
- 昨天:%s
|
||||||
|
- 明天:%s
|
||||||
|
- 本周:%s 至 %s
|
||||||
|
- 本月:%s 至 %s
|
||||||
|
- 今年:%s 至 %s`,
|
||||||
|
now.Format("2006-01-02 15:04:05 MST"),
|
||||||
|
todayStart.Format("2006-01-02"), todayStart.Format("2006-01-02 15:04:05"), todayStart.AddDate(0, 0, 1).Add(-time.Second).Format("2006-01-02 15:04:05"),
|
||||||
|
todayStart.AddDate(0, 0, -1).Format("2006-01-02"),
|
||||||
|
todayStart.AddDate(0, 0, 1).Format("2006-01-02"),
|
||||||
|
weekStart.Format("2006-01-02"), weekStart.AddDate(0, 0, 7).Add(-time.Second).Format("2006-01-02"),
|
||||||
|
monthStart.Format("2006-01-02"), monthStart.AddDate(0, 1, 0).Add(-time.Second).Format("2006-01-02"),
|
||||||
|
yearStart.Format("2006-01-02"), yearStart.AddDate(1, 0, 0).Add(-time.Second).Format("2006-01-02"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func dateStart(t time.Time) time.Time {
|
||||||
|
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
|
||||||
|
}
|
||||||
@@ -82,6 +82,7 @@ func main() {
|
|||||||
routers.ApiWarehouseInit()
|
routers.ApiWarehouseInit()
|
||||||
routers.ApiCustomerInit()
|
routers.ApiCustomerInit()
|
||||||
routers.ApiCalendarInit()
|
routers.ApiCalendarInit()
|
||||||
|
routers.ApiAIChatInit()
|
||||||
|
|
||||||
routers.BindsInit() //最后初始化绑定数据表
|
routers.BindsInit() //最后初始化绑定数据表
|
||||||
|
|
||||||
|
|||||||
@@ -26,9 +26,41 @@ type ConfigsFile_ struct {
|
|||||||
AllowPdfMime map[string]string `mapstructure:"allowPdfMime"`
|
AllowPdfMime map[string]string `mapstructure:"allowPdfMime"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ConfigsAIChatOpenAI_ struct {
|
||||||
|
Name string `mapstructure:"name"`
|
||||||
|
Active bool `mapstructure:"active"`
|
||||||
|
ApiKey string `mapstructure:"apiKey"`
|
||||||
|
BaseUrl string `mapstructure:"baseUrl"`
|
||||||
|
Model string `mapstructure:"model"`
|
||||||
|
Timeout int `mapstructure:"timeout"`
|
||||||
|
MaxTokens int `mapstructure:"maxTokens"`
|
||||||
|
SystemPrompt string `mapstructure:"systemPrompt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConfigsAIChatTool_ struct {
|
||||||
|
Name string `mapstructure:"name"`
|
||||||
|
Enabled bool `mapstructure:"enabled"`
|
||||||
|
Description string `mapstructure:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConfigsAIChatToolRouter_ struct {
|
||||||
|
Enabled bool `mapstructure:"enabled"`
|
||||||
|
OpenAIName string `mapstructure:"openaiName"`
|
||||||
|
Timeout int `mapstructure:"timeout"`
|
||||||
|
MaxTokens int `mapstructure:"maxTokens"`
|
||||||
|
Tools []ConfigsAIChatTool_ `mapstructure:"tools"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConfigsAIChat_ struct {
|
||||||
|
Enabled bool `mapstructure:"enabled"`
|
||||||
|
OpenAI []ConfigsAIChatOpenAI_ `mapstructure:"openai"`
|
||||||
|
ToolRouter ConfigsAIChatToolRouter_ `mapstructure:"toolRouter"`
|
||||||
|
}
|
||||||
|
|
||||||
var ConfigsWed ConfigsWeb_
|
var ConfigsWed ConfigsWeb_
|
||||||
var ConfigsUser ConfigsUser_
|
var ConfigsUser ConfigsUser_
|
||||||
var ConfigsFile ConfigsFile_
|
var ConfigsFile ConfigsFile_
|
||||||
|
var ConfigsAIChat ConfigsAIChat_
|
||||||
|
|
||||||
func ConfigAllInit() error {
|
func ConfigAllInit() error {
|
||||||
|
|
||||||
@@ -53,5 +85,35 @@ func ConfigAllInit() error {
|
|||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//初始化aichat config
|
||||||
|
ConfigsAIChat = ConfigsAIChat_{
|
||||||
|
Enabled: false,
|
||||||
|
OpenAI: []ConfigsAIChatOpenAI_{
|
||||||
|
{
|
||||||
|
Name: "default",
|
||||||
|
Active: true,
|
||||||
|
BaseUrl: "https://ark.cn-beijing.volces.com/api/v3",
|
||||||
|
Timeout: 120,
|
||||||
|
MaxTokens: 4096,
|
||||||
|
SystemPrompt: "你是一个有帮助的 AI 助手。",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ToolRouter: ConfigsAIChatToolRouter_{
|
||||||
|
Enabled: true,
|
||||||
|
OpenAIName: "default",
|
||||||
|
Timeout: 30,
|
||||||
|
MaxTokens: 512,
|
||||||
|
Tools: []ConfigsAIChatTool_{
|
||||||
|
{Name: "time", Enabled: true, Description: "提供当前日期、时间和相对日期换算。"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if aiChatConfig, ok := Configs["aichat"].(map[string]interface{}); ok {
|
||||||
|
err = mapstructure.Decode(aiChatConfig, &ConfigsAIChat)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ func ApiRoot(r *gin.RouterGroup) {
|
|||||||
ApiSysAdmin(r.Group("/admin"))
|
ApiSysAdmin(r.Group("/admin"))
|
||||||
ApiCustomer(r.Group("/customer"))
|
ApiCustomer(r.Group("/customer"))
|
||||||
ApiCalendar(r.Group("/calendar"))
|
ApiCalendar(r.Group("/calendar"))
|
||||||
|
ApiAIChat(r.Group("/aichat"))
|
||||||
r.GET("/", func(ctx *gin.Context) {
|
r.GET("/", func(ctx *gin.Context) {
|
||||||
ReturnJson(ctx, "apiOK", gin.H{
|
ReturnJson(ctx, "apiOK", gin.H{
|
||||||
"isOpsApiRoot": true,
|
"isOpsApiRoot": true,
|
||||||
|
|||||||
@@ -0,0 +1,481 @@
|
|||||||
|
package routers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"ops/agents"
|
||||||
|
"ops/models"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SSE frame types sent to frontend
|
||||||
|
type sseEvent struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
Tool string `json:"tool,omitempty"`
|
||||||
|
Stage string `json:"stage,omitempty"`
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
Data map[string]interface{} `json:"data,omitempty"`
|
||||||
|
Stats *tokenUsageStats `json:"stats,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type tokenUsageStats struct {
|
||||||
|
PromptTokens int `json:"prompt_tokens"`
|
||||||
|
CompletionTokens int `json:"completion_tokens"`
|
||||||
|
TotalTokens int `json:"total_tokens"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// chatRequestFromFrontend is the expected POST body
|
||||||
|
type chatRequest struct {
|
||||||
|
Messages []chatMessage `json:"messages"`
|
||||||
|
OpenAIName string `json:"openaiName,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type chatMessage struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// openaiChatRequest is the request sent to the upstream OpenAI-compatible API
|
||||||
|
type openaiChatRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Messages []openaiMessage `json:"messages"`
|
||||||
|
Stream bool `json:"stream"`
|
||||||
|
MaxTokens int `json:"max_tokens,omitempty"`
|
||||||
|
Temperature float64 `json:"temperature,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type openaiMessage struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// openaiStreamChunk is one SSE data line from the upstream
|
||||||
|
type openaiStreamChunk struct {
|
||||||
|
ID string `json:"id,omitempty"`
|
||||||
|
Object string `json:"object,omitempty"`
|
||||||
|
Created int64 `json:"created,omitempty"`
|
||||||
|
Model string `json:"model,omitempty"`
|
||||||
|
Choices []openaiChoice `json:"choices"`
|
||||||
|
Usage *openaiUsage `json:"usage,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type openaiChatResponse struct {
|
||||||
|
Choices []openaiResponseChoice `json:"choices"`
|
||||||
|
Usage *openaiUsage `json:"usage,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type openaiResponseChoice struct {
|
||||||
|
Message openaiMessage `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type toolRouteResponse struct {
|
||||||
|
Tools []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
} `json:"tools"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type openaiChoice struct {
|
||||||
|
Index int `json:"index"`
|
||||||
|
Delta openaiDelta `json:"delta"`
|
||||||
|
Finish *string `json:"finish_reason,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type openaiDelta struct {
|
||||||
|
Role string `json:"role,omitempty"`
|
||||||
|
Content string `json:"content,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type openaiUsage struct {
|
||||||
|
PromptTokens int `json:"prompt_tokens"`
|
||||||
|
CompletionTokens int `json:"completion_tokens"`
|
||||||
|
TotalTokens int `json:"total_tokens"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func ApiAIChat(r *gin.RouterGroup) {
|
||||||
|
r.GET("/openai", handleOpenAIProfiles)
|
||||||
|
r.POST("/chat", handleChat)
|
||||||
|
|
||||||
|
admin := r.Group("/admin")
|
||||||
|
admin.POST("/config", handleAIChatAdminGetConfig)
|
||||||
|
admin.POST("/config/update", handleAIChatAdminUpdateConfig)
|
||||||
|
admin.POST("/refresh", handleAIChatAdminRefreshCache)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleOpenAIProfiles(ctx *gin.Context) {
|
||||||
|
cfg := getAIChatConfig()
|
||||||
|
active := ""
|
||||||
|
profiles := make([]map[string]interface{}, 0, len(cfg.OpenAI))
|
||||||
|
for _, profile := range cfg.OpenAI {
|
||||||
|
if profile.Active {
|
||||||
|
active = profile.Name
|
||||||
|
}
|
||||||
|
profiles = append(profiles, map[string]interface{}{
|
||||||
|
"name": profile.Name,
|
||||||
|
"active": profile.Active,
|
||||||
|
"baseUrl": profile.BaseUrl,
|
||||||
|
"model": profile.Model,
|
||||||
|
"timeout": profile.Timeout,
|
||||||
|
"maxTokens": profile.MaxTokens,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
ReturnJson(ctx, "apiOK", gin.H{
|
||||||
|
"enabled": cfg.Enabled,
|
||||||
|
"active": active,
|
||||||
|
"profiles": profiles,
|
||||||
|
"toolRouter": gin.H{
|
||||||
|
"enabled": cfg.ToolRouter.Enabled,
|
||||||
|
"openaiName": cfg.ToolRouter.OpenAIName,
|
||||||
|
"timeout": cfg.ToolRouter.Timeout,
|
||||||
|
"maxTokens": cfg.ToolRouter.MaxTokens,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleChat(ctx *gin.Context) {
|
||||||
|
data, _ := SeparateData(ctx)
|
||||||
|
|
||||||
|
if data == nil {
|
||||||
|
sendSSEError(ctx, "请求数据为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req chatRequest
|
||||||
|
if err := decodeJSON(data, &req); err != nil {
|
||||||
|
sendSSEError(ctx, "解析消息失败: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(req.Messages) == 0 {
|
||||||
|
sendSSEError(ctx, "消息不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check ai config
|
||||||
|
cfg := getAIChatConfig()
|
||||||
|
profile, ok := selectOpenAIProfile(cfg, req.OpenAIName)
|
||||||
|
if !cfg.Enabled || !ok || profile.Model == "" || profile.ApiKey == "" {
|
||||||
|
sendSSEError(ctx, "AI 聊天未配置,请在后台配置 API Key 和模型")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
toolRouterProfile, hasToolRouterProfile := selectOpenAIProfile(cfg, cfg.ToolRouter.OpenAIName)
|
||||||
|
|
||||||
|
// Convert to agent messages and enrich with tools
|
||||||
|
chatMsgs := convertToChatMessages(req.Messages)
|
||||||
|
toolConfigs := []agents.ToolConfig{}
|
||||||
|
if cfg.ToolRouter.Enabled {
|
||||||
|
toolConfigs = buildToolConfigs(cfg.ToolRouter.Tools)
|
||||||
|
if hasToolRouterProfile && toolRouterProfile.Model != "" && toolRouterProfile.ApiKey != "" {
|
||||||
|
selected, err := routeTools(ctx.Request.Context(), toolRouterProfile, cfg.ToolRouter, chatMsgs)
|
||||||
|
if err == nil && selected != nil {
|
||||||
|
toolConfigs = filterToolConfigs(toolConfigs, selected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up SSE headers
|
||||||
|
ctx.Writer.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
ctx.Writer.Header().Set("Cache-Control", "no-cache")
|
||||||
|
ctx.Writer.Header().Set("Connection", "keep-alive")
|
||||||
|
ctx.Writer.WriteHeader(http.StatusOK)
|
||||||
|
flusher, _ := ctx.Writer.(http.Flusher)
|
||||||
|
|
||||||
|
// Enrich messages with tools (pre-process)
|
||||||
|
chatMsgs = agents.EnrichMessages(ctx.Request.Context(), chatMsgs, toolConfigs, func(tool, stage, status, message string, data map[string]interface{}) {
|
||||||
|
sendSSE(ctx, flusher, sseEvent{
|
||||||
|
Type: "trace",
|
||||||
|
Tool: tool,
|
||||||
|
Stage: stage,
|
||||||
|
Status: status,
|
||||||
|
Message: message,
|
||||||
|
Data: data,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Build OpenAI-compatible request
|
||||||
|
openaiMsgs := convertToOpenAIMessages(chatMsgs)
|
||||||
|
apiReq := openaiChatRequest{
|
||||||
|
Model: profile.Model,
|
||||||
|
Messages: openaiMsgs,
|
||||||
|
Stream: true,
|
||||||
|
MaxTokens: profile.MaxTokens,
|
||||||
|
Temperature: 0.7,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add system prompt if configured
|
||||||
|
if profile.SystemPrompt != "" {
|
||||||
|
apiReq.Messages = append([]openaiMessage{{Role: "system", Content: profile.SystemPrompt}}, apiReq.Messages...)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := streamOpenAI(ctx.Request.Context(), profile, apiReq, func(chunk openaiStreamChunk) {
|
||||||
|
for _, choice := range chunk.Choices {
|
||||||
|
if choice.Delta.Content != "" {
|
||||||
|
sendSSE(ctx, flusher, sseEvent{
|
||||||
|
Type: "delta",
|
||||||
|
Text: choice.Delta.Content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if chunk.Usage != nil {
|
||||||
|
sendSSE(ctx, flusher, sseEvent{
|
||||||
|
Type: "stats",
|
||||||
|
Stats: &tokenUsageStats{
|
||||||
|
PromptTokens: chunk.Usage.PromptTokens,
|
||||||
|
CompletionTokens: chunk.Usage.CompletionTokens,
|
||||||
|
TotalTokens: chunk.Usage.TotalTokens,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
sendSSE(ctx, flusher, sseEvent{Type: "error", Error: "请求失败: " + err.Error()})
|
||||||
|
sendSSEDone(ctx, flusher)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sendSSEDone(ctx, flusher)
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func streamOpenAI(ctx context.Context, cfg models.ConfigsAIChatOpenAI_, req openaiChatRequest, onData func(openaiStreamChunk)) error {
|
||||||
|
bodyBytes, err := json.Marshal(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("序列化请求失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
url := strings.TrimRight(cfg.BaseUrl, "/") + "/chat/completions"
|
||||||
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyBytes))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("创建请求失败: %w", err)
|
||||||
|
}
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
httpReq.Header.Set("Authorization", "Bearer "+cfg.ApiKey)
|
||||||
|
httpReq.Header.Set("Accept", "text/event-stream")
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: time.Duration(cfg.Timeout) * time.Second}
|
||||||
|
resp, err := client.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("连接上游服务失败: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return fmt.Errorf("上游返回 %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(resp.Body)
|
||||||
|
scanner.Buffer(make([]byte, 0, 64*1024), 256*1024)
|
||||||
|
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
|
||||||
|
if strings.TrimSpace(line) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(line, "data: ") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := strings.TrimPrefix(line, "data: ")
|
||||||
|
payload = strings.TrimSpace(payload)
|
||||||
|
|
||||||
|
if payload == "[DONE]" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var chunk openaiStreamChunk
|
||||||
|
if err := json.Unmarshal([]byte(payload), &chunk); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
onData(chunk)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return fmt.Errorf("读取流失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize with system prompt if present
|
||||||
|
func sendSSE(ctx *gin.Context, flusher http.Flusher, event sseEvent) {
|
||||||
|
data, err := json.Marshal(event)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintf(ctx.Writer, "data: %s\n\n", string(data))
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendSSEDone(ctx *gin.Context, flusher http.Flusher) {
|
||||||
|
_, _ = fmt.Fprint(ctx.Writer, "data: [DONE]\n\n")
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendSSEError(ctx *gin.Context, message string) {
|
||||||
|
ctx.Writer.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
ctx.Writer.Header().Set("Cache-Control", "no-cache")
|
||||||
|
ctx.Writer.Header().Set("Connection", "keep-alive")
|
||||||
|
ctx.Writer.WriteHeader(http.StatusOK)
|
||||||
|
flusher, _ := ctx.Writer.(http.Flusher)
|
||||||
|
|
||||||
|
sendSSE(ctx, flusher, sseEvent{
|
||||||
|
Type: "error",
|
||||||
|
Error: message,
|
||||||
|
})
|
||||||
|
|
||||||
|
sendSSEDone(ctx, flusher)
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertToChatMessages(msgs []chatMessage) []agents.ChatMessage {
|
||||||
|
result := make([]agents.ChatMessage, 0, len(msgs))
|
||||||
|
for _, m := range msgs {
|
||||||
|
result = append(result, agents.ChatMessage{Role: m.Role, Content: m.Content})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertToOpenAIMessages(msgs []agents.ChatMessage) []openaiMessage {
|
||||||
|
result := make([]openaiMessage, 0, len(msgs))
|
||||||
|
for _, m := range msgs {
|
||||||
|
result = append(result, openaiMessage{Role: m.Role, Content: m.Content})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildToolConfigs(configs []models.ConfigsAIChatTool_) []agents.ToolConfig {
|
||||||
|
result := make([]agents.ToolConfig, 0, len(configs))
|
||||||
|
for _, c := range configs {
|
||||||
|
result = append(result, agents.ToolConfig{
|
||||||
|
Name: c.Name,
|
||||||
|
Enabled: c.Enabled,
|
||||||
|
Description: c.Description,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectOpenAIProfile(cfg models.ConfigsAIChat_, name string) (models.ConfigsAIChatOpenAI_, bool) {
|
||||||
|
if name != "" {
|
||||||
|
for _, p := range cfg.OpenAI {
|
||||||
|
if p.Name == name {
|
||||||
|
return p, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return models.ConfigsAIChatOpenAI_{}, false
|
||||||
|
}
|
||||||
|
for _, p := range cfg.OpenAI {
|
||||||
|
if p.Active {
|
||||||
|
return p, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(cfg.OpenAI) > 0 {
|
||||||
|
return cfg.OpenAI[0], true
|
||||||
|
}
|
||||||
|
return models.ConfigsAIChatOpenAI_{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func routeTools(ctx context.Context, profile models.ConfigsAIChatOpenAI_, router models.ConfigsAIChatToolRouter_, messages []agents.ChatMessage) ([]string, error) {
|
||||||
|
openaiMsgs := []openaiMessage{}
|
||||||
|
lastUserContent := agents.LastUserContent(messages)
|
||||||
|
if lastUserContent != "" {
|
||||||
|
openaiMsgs = append(openaiMsgs, openaiMessage{Role: "user", Content: lastUserContent})
|
||||||
|
}
|
||||||
|
|
||||||
|
toolNames := make([]string, 0, len(router.Tools))
|
||||||
|
for _, t := range router.Tools {
|
||||||
|
if t.Enabled {
|
||||||
|
toolNames = append(toolNames, t.Name+" - "+t.Description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(toolNames) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sysPrompt := "请根据用户的最新一条消息,判断需要启用哪些工具来完成用户需求。\n可选工具:\n" + strings.Join(toolNames, "\n") + "\n\n回复格式要求:\n```json\n{\"tools\":[{\"name\":\"工具名称\",\"reason\":\"选择原因\"}],\"reason\":\"整体判断理由\"}\n```\n仅输出 JSON 代码块。如果没有需要启用的工具,返回 {\"tools\":[]}。"
|
||||||
|
openaiMsgs = append([]openaiMessage{{Role: "system", Content: sysPrompt}}, openaiMsgs...)
|
||||||
|
|
||||||
|
req := openaiChatRequest{
|
||||||
|
Model: profile.Model,
|
||||||
|
Messages: openaiMsgs,
|
||||||
|
Stream: false,
|
||||||
|
MaxTokens: router.MaxTokens,
|
||||||
|
Temperature: 0.1,
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyBytes, _ := json.Marshal(req)
|
||||||
|
url := strings.TrimRight(profile.BaseUrl, "/") + "/chat/completions"
|
||||||
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyBytes))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
httpReq.Header.Set("Authorization", "Bearer "+profile.ApiKey)
|
||||||
|
client := &http.Client{Timeout: time.Duration(router.Timeout) * time.Second}
|
||||||
|
resp, err := client.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var result openaiChatResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(result.Choices) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
response := result.Choices[0].Message.Content
|
||||||
|
toolRouteResponse := extractToolsFromResponse(response)
|
||||||
|
return toolRouteResponse, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractToolsFromResponse(response string) []string {
|
||||||
|
start := strings.Index(response, "{")
|
||||||
|
end := strings.LastIndex(response, "}")
|
||||||
|
if start == -1 || end == -1 || end <= start {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var parsed toolRouteResponse
|
||||||
|
if err := json.Unmarshal([]byte(response[start:end+1]), &parsed); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
tools := make([]string, 0, len(parsed.Tools))
|
||||||
|
for _, t := range parsed.Tools {
|
||||||
|
tools = append(tools, t.Name)
|
||||||
|
}
|
||||||
|
return tools
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterToolConfigs(configs []agents.ToolConfig, selected []string) []agents.ToolConfig {
|
||||||
|
if len(selected) == 0 {
|
||||||
|
return []agents.ToolConfig{}
|
||||||
|
}
|
||||||
|
selectedSet := make(map[string]bool, len(selected))
|
||||||
|
for _, s := range selected {
|
||||||
|
selectedSet[s] = true
|
||||||
|
}
|
||||||
|
filtered := make([]agents.ToolConfig, 0, len(configs))
|
||||||
|
for _, c := range configs {
|
||||||
|
if selectedSet[c.Name] {
|
||||||
|
filtered = append(filtered, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
@@ -0,0 +1,457 @@
|
|||||||
|
package routers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"ops/models"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TabAIChatSetting struct {
|
||||||
|
ID uint `gorm:"primaryKey;autoIncrement"`
|
||||||
|
Enabled bool `gorm:"default:false;index"`
|
||||||
|
CreatedAt *time.Time `gorm:"type:datetime;autoCreateTime"`
|
||||||
|
UpdatedAt *time.Time `gorm:"type:datetime;autoUpdateTime"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TabAIChatOpenAIProfile struct {
|
||||||
|
ID uint `gorm:"primaryKey;autoIncrement"`
|
||||||
|
Name string `gorm:"size:100;not null;uniqueIndex"`
|
||||||
|
Active bool `gorm:"default:false;index"`
|
||||||
|
ApiKey string `gorm:"type:text"`
|
||||||
|
BaseUrl string `gorm:"size:500"`
|
||||||
|
Model string `gorm:"size:200"`
|
||||||
|
Timeout int `gorm:"default:120"`
|
||||||
|
MaxTokens int `gorm:"default:4096"`
|
||||||
|
SystemPrompt string `gorm:"type:text"`
|
||||||
|
SortOrder int `gorm:"default:0;index"`
|
||||||
|
CreatedAt *time.Time `gorm:"type:datetime;autoCreateTime"`
|
||||||
|
UpdatedAt *time.Time `gorm:"type:datetime;autoUpdateTime"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TabAIChatToolRouter struct {
|
||||||
|
ID uint `gorm:"primaryKey;autoIncrement"`
|
||||||
|
Enabled bool `gorm:"default:true"`
|
||||||
|
OpenAIName string `gorm:"size:100;index"`
|
||||||
|
Timeout int `gorm:"default:30"`
|
||||||
|
MaxTokens int `gorm:"default:512"`
|
||||||
|
CreatedAt *time.Time `gorm:"type:datetime;autoCreateTime"`
|
||||||
|
UpdatedAt *time.Time `gorm:"type:datetime;autoUpdateTime"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TabAIChatTool struct {
|
||||||
|
ID uint `gorm:"primaryKey;autoIncrement"`
|
||||||
|
Name string `gorm:"size:100;not null;uniqueIndex"`
|
||||||
|
Enabled bool `gorm:"default:true;index"`
|
||||||
|
Description string `gorm:"type:text"`
|
||||||
|
SortOrder int `gorm:"default:0;index"`
|
||||||
|
CreatedAt *time.Time `gorm:"type:datetime;autoCreateTime"`
|
||||||
|
UpdatedAt *time.Time `gorm:"type:datetime;autoUpdateTime"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var aiChatConfigMu sync.RWMutex
|
||||||
|
|
||||||
|
func ApiAIChatInit() {
|
||||||
|
err := models.DB.AutoMigrate(
|
||||||
|
&TabAIChatSetting{},
|
||||||
|
&TabAIChatOpenAIProfile{},
|
||||||
|
&TabAIChatToolRouter{},
|
||||||
|
&TabAIChatTool{},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := seedAIChatConfigFromYAMLIfEmpty(); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
if err := RefreshAIChatConfigCache(); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedAIChatConfigFromYAMLIfEmpty() error {
|
||||||
|
var settingCount int64
|
||||||
|
var profileCount int64
|
||||||
|
models.DB.Model(&TabAIChatSetting{}).Count(&settingCount)
|
||||||
|
models.DB.Model(&TabAIChatOpenAIProfile{}).Count(&profileCount)
|
||||||
|
if settingCount > 0 || profileCount > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := models.ConfigsAIChat
|
||||||
|
return models.DB.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Create(&TabAIChatSetting{ID: 1, Enabled: cfg.Enabled}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
profiles := cfg.OpenAI
|
||||||
|
if len(profiles) == 0 {
|
||||||
|
profiles = []models.ConfigsAIChatOpenAI_{{
|
||||||
|
Name: "default",
|
||||||
|
Active: true,
|
||||||
|
BaseUrl: "https://ark.cn-beijing.volces.com/api/v3",
|
||||||
|
Timeout: 120,
|
||||||
|
MaxTokens: 4096,
|
||||||
|
SystemPrompt: "你是一个有帮助的 AI 助手。",
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
for i, profile := range profiles {
|
||||||
|
if profile.Name == "" {
|
||||||
|
profile.Name = "default"
|
||||||
|
}
|
||||||
|
if profile.Timeout <= 0 {
|
||||||
|
profile.Timeout = 120
|
||||||
|
}
|
||||||
|
if profile.MaxTokens <= 0 {
|
||||||
|
profile.MaxTokens = 4096
|
||||||
|
}
|
||||||
|
if err := tx.Create(&TabAIChatOpenAIProfile{
|
||||||
|
Name: profile.Name,
|
||||||
|
Active: profile.Active,
|
||||||
|
ApiKey: profile.ApiKey,
|
||||||
|
BaseUrl: profile.BaseUrl,
|
||||||
|
Model: profile.Model,
|
||||||
|
Timeout: profile.Timeout,
|
||||||
|
MaxTokens: profile.MaxTokens,
|
||||||
|
SystemPrompt: profile.SystemPrompt,
|
||||||
|
SortOrder: i,
|
||||||
|
}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toolRouter := cfg.ToolRouter
|
||||||
|
if toolRouter.Timeout <= 0 {
|
||||||
|
toolRouter.Timeout = 30
|
||||||
|
}
|
||||||
|
if toolRouter.MaxTokens <= 0 {
|
||||||
|
toolRouter.MaxTokens = 512
|
||||||
|
}
|
||||||
|
if toolRouter.OpenAIName == "" && len(profiles) > 0 {
|
||||||
|
toolRouter.OpenAIName = profiles[0].Name
|
||||||
|
}
|
||||||
|
if err := tx.Create(&TabAIChatToolRouter{
|
||||||
|
ID: 1,
|
||||||
|
Enabled: toolRouter.Enabled,
|
||||||
|
OpenAIName: toolRouter.OpenAIName,
|
||||||
|
Timeout: toolRouter.Timeout,
|
||||||
|
MaxTokens: toolRouter.MaxTokens,
|
||||||
|
}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
tools := toolRouter.Tools
|
||||||
|
if len(tools) == 0 {
|
||||||
|
tools = []models.ConfigsAIChatTool_{{Name: "time", Enabled: true, Description: "提供当前日期、时间和相对日期换算。"}}
|
||||||
|
}
|
||||||
|
for i, tool := range tools {
|
||||||
|
if tool.Name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := tx.Create(&TabAIChatTool{
|
||||||
|
Name: tool.Name,
|
||||||
|
Enabled: tool.Enabled,
|
||||||
|
Description: tool.Description,
|
||||||
|
SortOrder: i,
|
||||||
|
}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func RefreshAIChatConfigCache() error {
|
||||||
|
var setting TabAIChatSetting
|
||||||
|
if err := models.DB.First(&setting, 1).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var profiles []TabAIChatOpenAIProfile
|
||||||
|
if err := models.DB.Order("sort_order asc, id asc").Find(&profiles).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var toolRouter TabAIChatToolRouter
|
||||||
|
if err := models.DB.First(&toolRouter, 1).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var tools []TabAIChatTool
|
||||||
|
if err := models.DB.Order("sort_order asc, id asc").Find(&tools).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := models.ConfigsAIChat_{
|
||||||
|
Enabled: setting.Enabled,
|
||||||
|
OpenAI: make([]models.ConfigsAIChatOpenAI_, 0, len(profiles)),
|
||||||
|
ToolRouter: models.ConfigsAIChatToolRouter_{
|
||||||
|
Enabled: toolRouter.Enabled,
|
||||||
|
OpenAIName: toolRouter.OpenAIName,
|
||||||
|
Timeout: defaultInt(toolRouter.Timeout, 30),
|
||||||
|
MaxTokens: defaultInt(toolRouter.MaxTokens, 512),
|
||||||
|
Tools: make([]models.ConfigsAIChatTool_, 0, len(tools)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, profile := range profiles {
|
||||||
|
cfg.OpenAI = append(cfg.OpenAI, models.ConfigsAIChatOpenAI_{
|
||||||
|
Name: profile.Name,
|
||||||
|
Active: profile.Active,
|
||||||
|
ApiKey: profile.ApiKey,
|
||||||
|
BaseUrl: profile.BaseUrl,
|
||||||
|
Model: profile.Model,
|
||||||
|
Timeout: defaultInt(profile.Timeout, 120),
|
||||||
|
MaxTokens: defaultInt(profile.MaxTokens, 4096),
|
||||||
|
SystemPrompt: profile.SystemPrompt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, tool := range tools {
|
||||||
|
cfg.ToolRouter.Tools = append(cfg.ToolRouter.Tools, models.ConfigsAIChatTool_{
|
||||||
|
Name: tool.Name,
|
||||||
|
Enabled: tool.Enabled,
|
||||||
|
Description: tool.Description,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
aiChatConfigMu.Lock()
|
||||||
|
models.ConfigsAIChat = cfg
|
||||||
|
aiChatConfigMu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getAIChatConfig() models.ConfigsAIChat_ {
|
||||||
|
aiChatConfigMu.RLock()
|
||||||
|
defer aiChatConfigMu.RUnlock()
|
||||||
|
return models.ConfigsAIChat
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultInt(value int, fallback int) int {
|
||||||
|
if value <= 0 {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleAIChatAdminGetConfig(ctx *gin.Context) {
|
||||||
|
if ok, _ := requireSysAdmin(ctx); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg := getAIChatConfig()
|
||||||
|
ReturnJson(ctx, "apiOK", gin.H{
|
||||||
|
"enabled": cfg.Enabled,
|
||||||
|
"openai": maskAIChatProfiles(cfg.OpenAI),
|
||||||
|
"toolRouter": gin.H{
|
||||||
|
"enabled": cfg.ToolRouter.Enabled,
|
||||||
|
"openaiName": cfg.ToolRouter.OpenAIName,
|
||||||
|
"timeout": cfg.ToolRouter.Timeout,
|
||||||
|
"maxTokens": cfg.ToolRouter.MaxTokens,
|
||||||
|
"tools": maskAIChatTools(cfg.ToolRouter.Tools),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleAIChatAdminRefreshCache(ctx *gin.Context) {
|
||||||
|
if ok, _ := requireSysAdmin(ctx); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := RefreshAIChatConfigCache(); err != nil {
|
||||||
|
ReturnJson(ctx, "apiErr", gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ReturnJson(ctx, "apiOK", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleAIChatAdminUpdateConfig(ctx *gin.Context) {
|
||||||
|
ok, data := requireSysAdmin(ctx)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if data == nil {
|
||||||
|
ReturnJson(ctx, "apiErr", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req models.ConfigsAIChat_
|
||||||
|
if err := decodeJSON(data, &req); err != nil {
|
||||||
|
ReturnJson(ctx, "apiErr", gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.OpenAI) == 0 {
|
||||||
|
ReturnJson(ctx, "apiErr", gin.H{"error": "openai profiles cannot be empty"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := saveAIChatConfig(req); err != nil {
|
||||||
|
ReturnJson(ctx, "apiErr", gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := RefreshAIChatConfigCache(); err != nil {
|
||||||
|
ReturnJson(ctx, "apiErr", gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ReturnJson(ctx, "apiOK", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveAIChatConfig(req models.ConfigsAIChat_) error {
|
||||||
|
return models.DB.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Save(&TabAIChatSetting{ID: 1, Enabled: req.Enabled}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingProfiles []TabAIChatOpenAIProfile
|
||||||
|
if err := tx.Find(&existingProfiles).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
existingByName := make(map[string]TabAIChatOpenAIProfile, len(existingProfiles))
|
||||||
|
for _, profile := range existingProfiles {
|
||||||
|
existingByName[profile.Name] = profile
|
||||||
|
}
|
||||||
|
|
||||||
|
incomingNames := make(map[string]bool, len(req.OpenAI))
|
||||||
|
activeSet := false
|
||||||
|
for i, profile := range req.OpenAI {
|
||||||
|
if profile.Name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
incomingNames[profile.Name] = true
|
||||||
|
if profile.Timeout <= 0 {
|
||||||
|
profile.Timeout = 120
|
||||||
|
}
|
||||||
|
if profile.MaxTokens <= 0 {
|
||||||
|
profile.MaxTokens = 4096
|
||||||
|
}
|
||||||
|
if profile.Active {
|
||||||
|
if activeSet {
|
||||||
|
profile.Active = false
|
||||||
|
} else {
|
||||||
|
activeSet = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tab := TabAIChatOpenAIProfile{
|
||||||
|
Name: profile.Name,
|
||||||
|
Active: profile.Active,
|
||||||
|
ApiKey: profile.ApiKey,
|
||||||
|
BaseUrl: profile.BaseUrl,
|
||||||
|
Model: profile.Model,
|
||||||
|
Timeout: profile.Timeout,
|
||||||
|
MaxTokens: profile.MaxTokens,
|
||||||
|
SystemPrompt: profile.SystemPrompt,
|
||||||
|
SortOrder: i,
|
||||||
|
}
|
||||||
|
if old, ok := existingByName[profile.Name]; ok {
|
||||||
|
tab.ID = old.ID
|
||||||
|
if tab.ApiKey == "" {
|
||||||
|
tab.ApiKey = old.ApiKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.Save(&tab).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, old := range existingProfiles {
|
||||||
|
if !incomingNames[old.Name] {
|
||||||
|
if err := tx.Delete(&old).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !activeSet && len(req.OpenAI) > 0 {
|
||||||
|
if err := tx.Model(&TabAIChatOpenAIProfile{}).Where("name = ?", req.OpenAI[0].Name).Update("active", true).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toolRouter := req.ToolRouter
|
||||||
|
if toolRouter.Timeout <= 0 {
|
||||||
|
toolRouter.Timeout = 30
|
||||||
|
}
|
||||||
|
if toolRouter.MaxTokens <= 0 {
|
||||||
|
toolRouter.MaxTokens = 512
|
||||||
|
}
|
||||||
|
if err := tx.Save(&TabAIChatToolRouter{ID: 1, Enabled: toolRouter.Enabled, OpenAIName: toolRouter.OpenAIName, Timeout: toolRouter.Timeout, MaxTokens: toolRouter.MaxTokens}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingTools []TabAIChatTool
|
||||||
|
if err := tx.Find(&existingTools).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
existingToolByName := make(map[string]TabAIChatTool, len(existingTools))
|
||||||
|
for _, tool := range existingTools {
|
||||||
|
existingToolByName[tool.Name] = tool
|
||||||
|
}
|
||||||
|
incomingToolNames := make(map[string]bool, len(toolRouter.Tools))
|
||||||
|
for i, tool := range toolRouter.Tools {
|
||||||
|
if tool.Name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
incomingToolNames[tool.Name] = true
|
||||||
|
tab := TabAIChatTool{Name: tool.Name, Enabled: tool.Enabled, Description: tool.Description, SortOrder: i}
|
||||||
|
if old, ok := existingToolByName[tool.Name]; ok {
|
||||||
|
tab.ID = old.ID
|
||||||
|
}
|
||||||
|
if err := tx.Save(&tab).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, old := range existingTools {
|
||||||
|
if !incomingToolNames[old.Name] {
|
||||||
|
if err := tx.Delete(&old).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func maskAIChatProfiles(profiles []models.ConfigsAIChatOpenAI_) []gin.H {
|
||||||
|
items := make([]gin.H, 0, len(profiles))
|
||||||
|
for _, profile := range profiles {
|
||||||
|
items = append(items, gin.H{
|
||||||
|
"name": profile.Name,
|
||||||
|
"active": profile.Active,
|
||||||
|
"apiKeySet": profile.ApiKey != "",
|
||||||
|
"baseUrl": profile.BaseUrl,
|
||||||
|
"model": profile.Model,
|
||||||
|
"timeout": profile.Timeout,
|
||||||
|
"maxTokens": profile.MaxTokens,
|
||||||
|
"systemPrompt": profile.SystemPrompt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func maskAIChatTools(tools []models.ConfigsAIChatTool_) []gin.H {
|
||||||
|
items := make([]gin.H, 0, len(tools))
|
||||||
|
for _, tool := range tools {
|
||||||
|
items = append(items, gin.H{
|
||||||
|
"name": tool.Name,
|
||||||
|
"enabled": tool.Enabled,
|
||||||
|
"description": tool.Description,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireSysAdmin(ctx *gin.Context) (bool, map[string]interface{}) {
|
||||||
|
isAuth, user, data := AuthenticationAuthority(ctx)
|
||||||
|
if !isAuth {
|
||||||
|
ReturnJson(ctx, "userCookieError", nil)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if !SysAdminCheck(user.ID) {
|
||||||
|
ReturnJson(ctx, "permission_denied", nil)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return true, data
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { api } from './index'
|
||||||
|
import { useUserStore } from '@/stores/user'
|
||||||
|
|
||||||
|
export async function fetchOpenAIProfiles() {
|
||||||
|
return api.get('/aichat/openai')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAIChatAdminConfig() {
|
||||||
|
return api.post('/aichat/admin/config', {})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAIChatAdminConfig(config) {
|
||||||
|
return api.post('/aichat/admin/config/update', config)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshAIChatAdminConfig() {
|
||||||
|
return api.post('/aichat/admin/refresh', {})
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSSEBlock(block) {
|
||||||
|
const lines = block.split('\n')
|
||||||
|
const dataLines = []
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('data:')) {
|
||||||
|
dataLines.push(line.slice(5).trimStart())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dataLines.length === 0) return null
|
||||||
|
return dataLines.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function streamChat(messages, options = {}, handlers = {}) {
|
||||||
|
if (typeof options.onDelta === 'function' || typeof options.onError === 'function') {
|
||||||
|
handlers = options
|
||||||
|
options = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const response = await fetch('/api/aichat/chat', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
userCookieValue: userStore.cookieValue || '',
|
||||||
|
data: { messages, openaiName: options.openaiName || '' },
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
handlers.onError?.(`HTTP ${response.status}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!response.body) {
|
||||||
|
handlers.onError?.('ReadableStream is not supported')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader()
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
let buffer = ''
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true })
|
||||||
|
const blocks = buffer.split('\n\n')
|
||||||
|
buffer = blocks.pop() || ''
|
||||||
|
|
||||||
|
for (const block of blocks) {
|
||||||
|
const payload = parseSSEBlock(block)
|
||||||
|
if (!payload) continue
|
||||||
|
if (payload === '[DONE]') {
|
||||||
|
handlers.onDone?.()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const frame = JSON.parse(payload)
|
||||||
|
switch (frame.type) {
|
||||||
|
case 'delta':
|
||||||
|
handlers.onDelta?.(frame.text || '')
|
||||||
|
break
|
||||||
|
case 'trace':
|
||||||
|
handlers.onTrace?.(frame)
|
||||||
|
break
|
||||||
|
case 'stats':
|
||||||
|
handlers.onStats?.(frame.stats || null)
|
||||||
|
break
|
||||||
|
case 'error':
|
||||||
|
handlers.onError?.(frame.error || frame.message || 'AI request failed')
|
||||||
|
break
|
||||||
|
case '[DONE]':
|
||||||
|
handlers.onDone?.()
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
handlers.onFrame?.(frame)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
handlers.onError?.(error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handlers.onDone?.()
|
||||||
|
}
|
||||||
@@ -58,6 +58,7 @@ const navItems = computed(() => [
|
|||||||
{ label: t("appname.purchase"), to: "/purchase" },
|
{ label: t("appname.purchase"), to: "/purchase" },
|
||||||
{ label: t("appname.work_order"), to: "/work_order" },
|
{ label: t("appname.work_order"), to: "/work_order" },
|
||||||
{ label: t("appname.warehouse"), to: "/warehouse" },
|
{ label: t("appname.warehouse"), to: "/warehouse" },
|
||||||
|
{ label: t("appname.aichat"), to: "/aichat" },
|
||||||
]);
|
]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,66 @@
|
|||||||
"warehouse": "Warehouse",
|
"warehouse": "Warehouse",
|
||||||
"warehouse_items": "Items Overview",
|
"warehouse_items": "Items Overview",
|
||||||
"work_order": "Work Order",
|
"work_order": "Work Order",
|
||||||
"calendar": "Calendar"
|
"calendar": "Calendar",
|
||||||
|
"aichat": "AI Chat"
|
||||||
|
},
|
||||||
|
"aichat": {
|
||||||
|
"title": "AI Chat",
|
||||||
|
"subtitle": "Chat with the AI assistant using streaming responses and backend tool traces.",
|
||||||
|
"empty_title": "Start an AI chat",
|
||||||
|
"empty_hint": "Type a question and press Enter to send. Use Shift + Enter for a new line.",
|
||||||
|
"input_placeholder": "Type a message...",
|
||||||
|
"send": "Send",
|
||||||
|
"clear": "Clear",
|
||||||
|
"thinking": "Thinking...",
|
||||||
|
"streaming": "Generating response",
|
||||||
|
"tokens": "Token usage",
|
||||||
|
"profile": "AI profile",
|
||||||
|
"default_profile": "Default profile",
|
||||||
|
"tool_router": "Tool router",
|
||||||
|
"enter_hint": "Enter to send, Shift + Enter for a new line",
|
||||||
|
"error_prefix": "Request failed: "
|
||||||
|
},
|
||||||
|
"aiconfig": {
|
||||||
|
"title": "AI Config",
|
||||||
|
"subtitle": "Configure AI profiles, tool routing, and tool list stored in the database.",
|
||||||
|
"reload": "Reload",
|
||||||
|
"refresh_cache": "Refresh Cache",
|
||||||
|
"save": "Save",
|
||||||
|
"enabled": "Enabled",
|
||||||
|
"profiles": "AI Profiles",
|
||||||
|
"add_profile": "Add Profile",
|
||||||
|
"remove": "Remove",
|
||||||
|
"active": "Default Profile",
|
||||||
|
"name": "Name",
|
||||||
|
"api_key": "API Key",
|
||||||
|
"api_key_keep": "Already configured; leave blank to keep it",
|
||||||
|
"base_url": "Base URL",
|
||||||
|
"model": "Model",
|
||||||
|
"timeout": "Timeout (seconds)",
|
||||||
|
"max_tokens": "Max Tokens",
|
||||||
|
"system_prompt": "System Prompt",
|
||||||
|
"tool_router": "Tool Router",
|
||||||
|
"router_profile": "Router Profile",
|
||||||
|
"none": "None",
|
||||||
|
"tools": "Tools",
|
||||||
|
"add_tool": "Add Tool",
|
||||||
|
"description": "Description",
|
||||||
|
"load_failed": "Failed to load AI config",
|
||||||
|
"save_success": "AI config saved",
|
||||||
|
"save_failed": "Failed to save AI config",
|
||||||
|
"refresh_success": "AI config cache refreshed",
|
||||||
|
"refresh_failed": "Failed to refresh AI config cache",
|
||||||
|
"error_profile_required": "At least one AI profile is required",
|
||||||
|
"error_profile_name_required": "Profile name is required",
|
||||||
|
"error_profile_name_duplicate": "Profile names must be unique",
|
||||||
|
"error_active_profile_required": "When AI is enabled, the default profile must have Base URL and model configured",
|
||||||
|
"error_api_key_required": "When AI is enabled, the default profile must have an API key",
|
||||||
|
"error_timeout": "Timeout must be a positive integer",
|
||||||
|
"error_max_tokens": "Max tokens must be a positive integer",
|
||||||
|
"error_router_profile": "Tool router profile must exist in profile list",
|
||||||
|
"error_tool_name_required": "Tool name is required",
|
||||||
|
"error_tool_name_duplicate": "Tool names must be unique"
|
||||||
},
|
},
|
||||||
"tagadder": {
|
"tagadder": {
|
||||||
"not_fund_item": "No matching items found",
|
"not_fund_item": "No matching items found",
|
||||||
|
|||||||
@@ -36,7 +36,66 @@
|
|||||||
"warehouse": "仓库",
|
"warehouse": "仓库",
|
||||||
"warehouse_items": "物品总览",
|
"warehouse_items": "物品总览",
|
||||||
"work_order": "工单",
|
"work_order": "工单",
|
||||||
"calendar": "日历"
|
"calendar": "日历",
|
||||||
|
"aichat": "AI 对话"
|
||||||
|
},
|
||||||
|
"aichat": {
|
||||||
|
"title": "AI 对话",
|
||||||
|
"subtitle": "通过流式响应与 AI 助手对话,可显示后端工具执行过程。",
|
||||||
|
"empty_title": "开始一次 AI 对话",
|
||||||
|
"empty_hint": "输入问题后按 Enter 发送,Shift + Enter 换行。",
|
||||||
|
"input_placeholder": "输入消息...",
|
||||||
|
"send": "发送",
|
||||||
|
"clear": "清空",
|
||||||
|
"thinking": "正在思考...",
|
||||||
|
"streaming": "正在生成回复",
|
||||||
|
"tokens": "Token 用量",
|
||||||
|
"profile": "AI 接口",
|
||||||
|
"default_profile": "默认接口",
|
||||||
|
"tool_router": "工具路由",
|
||||||
|
"enter_hint": "Enter 发送,Shift + Enter 换行",
|
||||||
|
"error_prefix": "请求失败:"
|
||||||
|
},
|
||||||
|
"aiconfig": {
|
||||||
|
"title": "AI 配置",
|
||||||
|
"subtitle": "配置数据库中的 AI 接口、工具路由和工具列表。",
|
||||||
|
"reload": "重新加载",
|
||||||
|
"refresh_cache": "刷新缓存",
|
||||||
|
"save": "保存",
|
||||||
|
"enabled": "启用",
|
||||||
|
"profiles": "AI 接口 Profiles",
|
||||||
|
"add_profile": "添加接口",
|
||||||
|
"remove": "删除",
|
||||||
|
"active": "默认接口",
|
||||||
|
"name": "名称",
|
||||||
|
"api_key": "API Key",
|
||||||
|
"api_key_keep": "已配置,留空保留原密钥",
|
||||||
|
"base_url": "Base URL",
|
||||||
|
"model": "模型",
|
||||||
|
"timeout": "超时(秒)",
|
||||||
|
"max_tokens": "最大 Token",
|
||||||
|
"system_prompt": "系统提示词",
|
||||||
|
"tool_router": "工具路由",
|
||||||
|
"router_profile": "路由接口",
|
||||||
|
"none": "不指定",
|
||||||
|
"tools": "工具",
|
||||||
|
"add_tool": "添加工具",
|
||||||
|
"description": "描述",
|
||||||
|
"load_failed": "加载 AI 配置失败",
|
||||||
|
"save_success": "AI 配置已保存",
|
||||||
|
"save_failed": "保存 AI 配置失败",
|
||||||
|
"refresh_success": "AI 配置缓存已刷新",
|
||||||
|
"refresh_failed": "刷新 AI 配置缓存失败",
|
||||||
|
"error_profile_required": "至少需要一个 AI 接口",
|
||||||
|
"error_profile_name_required": "接口名称不能为空",
|
||||||
|
"error_profile_name_duplicate": "接口名称不能重复",
|
||||||
|
"error_active_profile_required": "启用 AI 时默认接口必须配置 Base URL 和模型",
|
||||||
|
"error_api_key_required": "启用 AI 时默认接口必须配置 API Key",
|
||||||
|
"error_timeout": "超时必须是正整数",
|
||||||
|
"error_max_tokens": "最大 Token 必须是正整数",
|
||||||
|
"error_router_profile": "工具路由接口必须来自现有接口列表",
|
||||||
|
"error_tool_name_required": "工具名称不能为空",
|
||||||
|
"error_tool_name_duplicate": "工具名称不能重复"
|
||||||
},
|
},
|
||||||
"tagadder": {
|
"tagadder": {
|
||||||
"not_fund_item": "没有找到匹配项",
|
"not_fund_item": "没有找到匹配项",
|
||||||
|
|||||||
@@ -109,6 +109,12 @@ const router = createRouter({
|
|||||||
component: () => import('@/views/sysadmin/SysAdminView.vue'),
|
component: () => import('@/views/sysadmin/SysAdminView.vue'),
|
||||||
meta: { requireSysAdmin: true },
|
meta: { requireSysAdmin: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'admin/aiconfig',
|
||||||
|
name: 'admin-aiconfig',
|
||||||
|
component: () => import('@/views/admin/AIConfigView.vue'),
|
||||||
|
meta: { requireSysAdmin: true },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'customer',
|
path: 'customer',
|
||||||
name: 'customer',
|
name: 'customer',
|
||||||
@@ -150,6 +156,11 @@ const router = createRouter({
|
|||||||
name: 'user-my',
|
name: 'user-my',
|
||||||
component: () => import('@/views/user/MyProfile.vue'),
|
component: () => import('@/views/user/MyProfile.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'aichat',
|
||||||
|
name: 'aichat',
|
||||||
|
component: () => import('@/views/aichat/AiChatView.vue'),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -206,7 +217,7 @@ router.beforeEach((to) => {
|
|||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
// 不需要登录的页面(精确匹配或前缀匹配)
|
// 不需要登录的页面(精确匹配或前缀匹配)
|
||||||
const publicPages = ['/', '/login', '/register', '/forgot_password', '/schedule', '/calendars', '/404']
|
const publicPages = ['/', '/login', '/register', '/forgot_password', '/schedule', '/calendars', '/aichat', '/404']
|
||||||
const publicPrefixes = ['/calendar/']
|
const publicPrefixes = ['/calendar/']
|
||||||
if (publicPages.includes(to.path) || publicPrefixes.some(p => to.path.startsWith(p))) return true
|
if (publicPages.includes(to.path) || publicPrefixes.some(p => to.path.startsWith(p))) return true
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,513 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { IconDeviceFloppy, IconPlus, IconRefresh, IconTrash } from '@tabler/icons-vue'
|
||||||
|
import { fetchAIChatAdminConfig, refreshAIChatAdminConfig, updateAIChatAdminConfig } from '@/api/aichat'
|
||||||
|
import { usePageTitle } from '@/composables/usePageTitle'
|
||||||
|
import { useToastStore } from '@/stores/toast'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const toast = useToastStore()
|
||||||
|
|
||||||
|
usePageTitle('aiconfig.title')
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const refreshing = ref(false)
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
enabled: false,
|
||||||
|
openai: [],
|
||||||
|
toolRouter: {
|
||||||
|
enabled: true,
|
||||||
|
openaiName: '',
|
||||||
|
timeout: 30,
|
||||||
|
maxTokens: 512,
|
||||||
|
tools: [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const profileNames = computed(() => form.openai.map((profile) => profile.name.trim()).filter(Boolean))
|
||||||
|
const activeProfileName = computed(() => form.openai.find((profile) => profile.active)?.name || form.openai[0]?.name || '')
|
||||||
|
|
||||||
|
onMounted(loadConfig)
|
||||||
|
|
||||||
|
function normalizeProfile(profile = {}) {
|
||||||
|
return {
|
||||||
|
name: profile.name || '',
|
||||||
|
active: !!profile.active,
|
||||||
|
apiKey: '',
|
||||||
|
apiKeySet: !!profile.apiKeySet,
|
||||||
|
baseUrl: profile.baseUrl || '',
|
||||||
|
model: profile.model || '',
|
||||||
|
timeout: Number(profile.timeout || 120),
|
||||||
|
maxTokens: Number(profile.maxTokens || 4096),
|
||||||
|
systemPrompt: profile.systemPrompt || '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTool(tool = {}) {
|
||||||
|
return {
|
||||||
|
name: tool.name || '',
|
||||||
|
enabled: tool.enabled !== false,
|
||||||
|
description: tool.description || '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyConfig(data) {
|
||||||
|
form.enabled = !!data?.enabled
|
||||||
|
form.openai = Array.isArray(data?.openai) ? data.openai.map(normalizeProfile) : []
|
||||||
|
if (form.openai.length === 0) {
|
||||||
|
addProfile()
|
||||||
|
}
|
||||||
|
|
||||||
|
const router = data?.toolRouter || {}
|
||||||
|
form.toolRouter = {
|
||||||
|
enabled: router.enabled !== false,
|
||||||
|
openaiName: router.openaiName || '',
|
||||||
|
timeout: Number(router.timeout || 30),
|
||||||
|
maxTokens: Number(router.maxTokens || 512),
|
||||||
|
tools: Array.isArray(router.tools) ? router.tools.map(normalizeTool) : [],
|
||||||
|
}
|
||||||
|
if (form.toolRouter.tools.length === 0) {
|
||||||
|
addTool()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadConfig() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await fetchAIChatAdminConfig()
|
||||||
|
if (res.errCode === 0) {
|
||||||
|
applyConfig(res.data || {})
|
||||||
|
} else {
|
||||||
|
toast.error(t('aiconfig.load_failed'))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : String(error))
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshCache() {
|
||||||
|
refreshing.value = true
|
||||||
|
try {
|
||||||
|
const res = await refreshAIChatAdminConfig()
|
||||||
|
if (res.errCode === 0) {
|
||||||
|
toast.success(t('aiconfig.refresh_success'))
|
||||||
|
await loadConfig()
|
||||||
|
} else {
|
||||||
|
toast.error(t('aiconfig.refresh_failed'))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : String(error))
|
||||||
|
} finally {
|
||||||
|
refreshing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addProfile() {
|
||||||
|
form.openai.push({
|
||||||
|
name: '',
|
||||||
|
active: form.openai.length === 0,
|
||||||
|
apiKey: '',
|
||||||
|
apiKeySet: false,
|
||||||
|
baseUrl: 'https://ark.cn-beijing.volces.com/api/v3',
|
||||||
|
model: '',
|
||||||
|
timeout: 120,
|
||||||
|
maxTokens: 4096,
|
||||||
|
systemPrompt: '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeProfile(index) {
|
||||||
|
const removed = form.openai[index]
|
||||||
|
form.openai.splice(index, 1)
|
||||||
|
if (form.openai.length === 0) {
|
||||||
|
addProfile()
|
||||||
|
}
|
||||||
|
if (!form.openai.some((profile) => profile.active)) {
|
||||||
|
form.openai[0].active = true
|
||||||
|
}
|
||||||
|
if (removed?.name && form.toolRouter.openaiName === removed.name) {
|
||||||
|
form.toolRouter.openaiName = activeProfileName.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActiveProfile(index) {
|
||||||
|
form.openai.forEach((profile, i) => {
|
||||||
|
profile.active = i === index
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTool() {
|
||||||
|
form.toolRouter.tools.push({ name: '', enabled: true, description: '' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTool(index) {
|
||||||
|
form.toolRouter.tools.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function validate() {
|
||||||
|
if (form.openai.length === 0) {
|
||||||
|
toast.error(t('aiconfig.error_profile_required'))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const names = new Set()
|
||||||
|
let hasActive = false
|
||||||
|
for (const profile of form.openai) {
|
||||||
|
profile.name = profile.name.trim()
|
||||||
|
profile.baseUrl = profile.baseUrl.trim()
|
||||||
|
profile.model = profile.model.trim()
|
||||||
|
profile.apiKey = profile.apiKey.trim()
|
||||||
|
|
||||||
|
if (!profile.name) {
|
||||||
|
toast.error(t('aiconfig.error_profile_name_required'))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (names.has(profile.name)) {
|
||||||
|
toast.error(t('aiconfig.error_profile_name_duplicate'))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
names.add(profile.name)
|
||||||
|
if (profile.active) {
|
||||||
|
hasActive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
profile.timeout = Number(profile.timeout)
|
||||||
|
profile.maxTokens = Number(profile.maxTokens)
|
||||||
|
if (!Number.isInteger(profile.timeout) || profile.timeout <= 0) {
|
||||||
|
toast.error(t('aiconfig.error_timeout'))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(profile.maxTokens) || profile.maxTokens <= 0) {
|
||||||
|
toast.error(t('aiconfig.error_max_tokens'))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasActive) {
|
||||||
|
form.openai[0].active = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const active = form.openai.find((profile) => profile.active)
|
||||||
|
if (form.enabled && active) {
|
||||||
|
if (!active.baseUrl || !active.model) {
|
||||||
|
toast.error(t('aiconfig.error_active_profile_required'))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!active.apiKeySet && !active.apiKey) {
|
||||||
|
toast.error(t('aiconfig.error_api_key_required'))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form.toolRouter.openaiName && !names.has(form.toolRouter.openaiName)) {
|
||||||
|
toast.error(t('aiconfig.error_router_profile'))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
form.toolRouter.timeout = Number(form.toolRouter.timeout)
|
||||||
|
form.toolRouter.maxTokens = Number(form.toolRouter.maxTokens)
|
||||||
|
if (!Number.isInteger(form.toolRouter.timeout) || form.toolRouter.timeout <= 0) {
|
||||||
|
toast.error(t('aiconfig.error_timeout'))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(form.toolRouter.maxTokens) || form.toolRouter.maxTokens <= 0) {
|
||||||
|
toast.error(t('aiconfig.error_max_tokens'))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolNames = new Set()
|
||||||
|
for (const tool of form.toolRouter.tools) {
|
||||||
|
tool.name = tool.name.trim()
|
||||||
|
if (!tool.name) {
|
||||||
|
toast.error(t('aiconfig.error_tool_name_required'))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (toolNames.has(tool.name)) {
|
||||||
|
toast.error(t('aiconfig.error_tool_name_duplicate'))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
toolNames.add(tool.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPayload() {
|
||||||
|
return {
|
||||||
|
enabled: form.enabled,
|
||||||
|
openai: form.openai.map((profile) => ({
|
||||||
|
name: profile.name.trim(),
|
||||||
|
active: !!profile.active,
|
||||||
|
apiKey: profile.apiKey.trim(),
|
||||||
|
baseUrl: profile.baseUrl.trim(),
|
||||||
|
model: profile.model.trim(),
|
||||||
|
timeout: Number(profile.timeout),
|
||||||
|
maxTokens: Number(profile.maxTokens),
|
||||||
|
systemPrompt: profile.systemPrompt || '',
|
||||||
|
})),
|
||||||
|
toolRouter: {
|
||||||
|
enabled: !!form.toolRouter.enabled,
|
||||||
|
openaiName: form.toolRouter.openaiName || '',
|
||||||
|
timeout: Number(form.toolRouter.timeout),
|
||||||
|
maxTokens: Number(form.toolRouter.maxTokens),
|
||||||
|
tools: form.toolRouter.tools.map((tool) => ({
|
||||||
|
name: tool.name.trim(),
|
||||||
|
enabled: !!tool.enabled,
|
||||||
|
description: tool.description || '',
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveConfig() {
|
||||||
|
if (!validate()) return
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const res = await updateAIChatAdminConfig(buildPayload())
|
||||||
|
if (res.errCode === 0) {
|
||||||
|
toast.success(t('aiconfig.save_success'))
|
||||||
|
await loadConfig()
|
||||||
|
} else {
|
||||||
|
toast.error(t('aiconfig.save_failed'))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : String(error))
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-gray-50 p-6 dark:bg-dk-base">
|
||||||
|
<div class="mx-auto max-w-6xl space-y-6">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-dk-text">{{ t('aiconfig.title') }}</h1>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-dk-subtle">{{ t('aiconfig.subtitle') }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button class="btn-secondary" :disabled="loading" @click="loadConfig">
|
||||||
|
<IconRefresh :size="16" />
|
||||||
|
{{ t('aiconfig.reload') }}
|
||||||
|
</button>
|
||||||
|
<button class="btn-secondary" :disabled="refreshing" @click="refreshCache">
|
||||||
|
<IconRefresh :size="16" />
|
||||||
|
{{ t('aiconfig.refresh_cache') }}
|
||||||
|
</button>
|
||||||
|
<button class="btn-primary" :disabled="saving" @click="saveConfig">
|
||||||
|
<IconDeviceFloppy :size="16" />
|
||||||
|
{{ t('aiconfig.save') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<label class="flex items-center gap-3 text-sm font-medium text-gray-700 dark:text-dk-text">
|
||||||
|
<input v-model="form.enabled" type="checkbox" class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" />
|
||||||
|
{{ t('aiconfig.enabled') }}
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="section-title">{{ t('aiconfig.profiles') }}</h2>
|
||||||
|
<button class="btn-secondary" @click="addProfile">
|
||||||
|
<IconPlus :size="16" />
|
||||||
|
{{ t('aiconfig.add_profile') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-for="(profile, index) in form.openai" :key="index" class="rounded-lg border border-gray-200 p-4 dark:border-dk-muted">
|
||||||
|
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-dk-text">
|
||||||
|
<input :checked="profile.active" type="radio" name="active-profile" class="text-blue-600" @change="setActiveProfile(index)" />
|
||||||
|
{{ t('aiconfig.active') }}
|
||||||
|
</label>
|
||||||
|
<button class="text-sm text-red-600 hover:text-red-700" @click="removeProfile(index)">
|
||||||
|
<IconTrash :size="16" class="inline" />
|
||||||
|
{{ t('aiconfig.remove') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
|
<label class="field">
|
||||||
|
<span>{{ t('aiconfig.name') }}</span>
|
||||||
|
<input v-model="profile.name" class="input" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>{{ t('aiconfig.api_key') }}</span>
|
||||||
|
<input v-model="profile.apiKey" class="input" type="password" :placeholder="profile.apiKeySet ? t('aiconfig.api_key_keep') : ''" />
|
||||||
|
</label>
|
||||||
|
<label class="field md:col-span-2">
|
||||||
|
<span>{{ t('aiconfig.base_url') }}</span>
|
||||||
|
<input v-model="profile.baseUrl" class="input" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>{{ t('aiconfig.model') }}</span>
|
||||||
|
<input v-model="profile.model" class="input" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>{{ t('aiconfig.timeout') }}</span>
|
||||||
|
<input v-model.number="profile.timeout" class="input" type="number" min="1" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>{{ t('aiconfig.max_tokens') }}</span>
|
||||||
|
<input v-model.number="profile.maxTokens" class="input" type="number" min="1" />
|
||||||
|
</label>
|
||||||
|
<label class="field md:col-span-2">
|
||||||
|
<span>{{ t('aiconfig.system_prompt') }}</span>
|
||||||
|
<textarea v-model="profile.systemPrompt" class="input min-h-24 resize-y" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card space-y-4">
|
||||||
|
<h2 class="section-title">{{ t('aiconfig.tool_router') }}</h2>
|
||||||
|
<div class="grid gap-4 md:grid-cols-4">
|
||||||
|
<label class="flex items-center gap-3 text-sm font-medium text-gray-700 dark:text-dk-text">
|
||||||
|
<input v-model="form.toolRouter.enabled" type="checkbox" class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" />
|
||||||
|
{{ t('aiconfig.enabled') }}
|
||||||
|
</label>
|
||||||
|
<label class="field md:col-span-1">
|
||||||
|
<span>{{ t('aiconfig.router_profile') }}</span>
|
||||||
|
<select v-model="form.toolRouter.openaiName" class="input">
|
||||||
|
<option value="">{{ t('aiconfig.none') }}</option>
|
||||||
|
<option v-for="name in profileNames" :key="name" :value="name">{{ name }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>{{ t('aiconfig.timeout') }}</span>
|
||||||
|
<input v-model.number="form.toolRouter.timeout" class="input" type="number" min="1" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>{{ t('aiconfig.max_tokens') }}</span>
|
||||||
|
<input v-model.number="form.toolRouter.maxTokens" class="input" type="number" min="1" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="section-title">{{ t('aiconfig.tools') }}</h2>
|
||||||
|
<button class="btn-secondary" @click="addTool">
|
||||||
|
<IconPlus :size="16" />
|
||||||
|
{{ t('aiconfig.add_tool') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-for="(tool, index) in form.toolRouter.tools" :key="index" class="grid gap-4 rounded-lg border border-gray-200 p-4 dark:border-dk-muted md:grid-cols-12">
|
||||||
|
<label class="field md:col-span-3">
|
||||||
|
<span>{{ t('aiconfig.name') }}</span>
|
||||||
|
<input v-model="tool.name" class="input" />
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-3 text-sm font-medium text-gray-700 dark:text-dk-text md:col-span-2 md:pt-6">
|
||||||
|
<input v-model="tool.enabled" type="checkbox" class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" />
|
||||||
|
{{ t('aiconfig.enabled') }}
|
||||||
|
</label>
|
||||||
|
<label class="field md:col-span-6">
|
||||||
|
<span>{{ t('aiconfig.description') }}</span>
|
||||||
|
<input v-model="tool.description" class="input" />
|
||||||
|
</label>
|
||||||
|
<div class="flex items-end md:col-span-1">
|
||||||
|
<button class="text-sm text-red-600 hover:text-red-700" @click="removeTool(index)">
|
||||||
|
<IconTrash :size="18" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.card {
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
border: 1px solid rgb(229 231 235);
|
||||||
|
background: white;
|
||||||
|
padding: 1.25rem;
|
||||||
|
box-shadow: 0 1px 2px rgb(0 0 0 / 0.05);
|
||||||
|
}
|
||||||
|
.section-title {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgb(17 24 39);
|
||||||
|
}
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: rgb(55 65 81);
|
||||||
|
}
|
||||||
|
.input {
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
border: 1px solid rgb(209 213 219);
|
||||||
|
background: white;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: rgb(17 24 39);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
.input:focus {
|
||||||
|
border-color: rgb(59 130 246);
|
||||||
|
box-shadow: 0 0 0 2px rgb(59 130 246 / 0.2);
|
||||||
|
}
|
||||||
|
.btn-primary,
|
||||||
|
.btn-secondary {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: background-color 0.15s ease, color 0.15s ease;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
background: rgb(37 99 235);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: rgb(29 78 216);
|
||||||
|
}
|
||||||
|
.btn-secondary {
|
||||||
|
border: 1px solid rgb(229 231 235);
|
||||||
|
color: rgb(75 85 99);
|
||||||
|
}
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: rgb(243 244 246);
|
||||||
|
}
|
||||||
|
.btn-primary:disabled,
|
||||||
|
.btn-secondary:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
:global(.dark) .card {
|
||||||
|
border-color: var(--color-dk-muted);
|
||||||
|
background: var(--color-dk-card);
|
||||||
|
}
|
||||||
|
:global(.dark) .section-title,
|
||||||
|
:global(.dark) .field {
|
||||||
|
color: var(--color-dk-text);
|
||||||
|
}
|
||||||
|
:global(.dark) .input {
|
||||||
|
border-color: var(--color-dk-muted);
|
||||||
|
background: var(--color-dk-base);
|
||||||
|
color: var(--color-dk-text);
|
||||||
|
}
|
||||||
|
:global(.dark) .btn-secondary {
|
||||||
|
border-color: var(--color-dk-muted);
|
||||||
|
color: var(--color-dk-subtle);
|
||||||
|
}
|
||||||
|
:global(.dark) .btn-secondary:hover {
|
||||||
|
background: var(--color-dk-card);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
<script setup>
|
||||||
|
import { nextTick, onMounted, ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { IconRobot, IconSend, IconTrash, IconUser, IconLoader2 } from '@tabler/icons-vue'
|
||||||
|
import { fetchOpenAIProfiles, streamChat } from '@/api/aichat'
|
||||||
|
import { usePageTitle } from '@/composables/usePageTitle'
|
||||||
|
import { useToastStore } from '@/stores/toast'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const toast = useToastStore()
|
||||||
|
|
||||||
|
usePageTitle('appname.aichat')
|
||||||
|
|
||||||
|
const messages = ref([])
|
||||||
|
const inputText = ref('')
|
||||||
|
const pending = ref(false)
|
||||||
|
const traces = ref([])
|
||||||
|
const stats = ref(null)
|
||||||
|
const profiles = ref([])
|
||||||
|
const activeProfile = ref('')
|
||||||
|
const toolRouter = ref(null)
|
||||||
|
const messageListRef = ref(null)
|
||||||
|
|
||||||
|
onMounted(loadProfiles)
|
||||||
|
|
||||||
|
async function loadProfiles() {
|
||||||
|
try {
|
||||||
|
const res = await fetchOpenAIProfiles()
|
||||||
|
if (res.errCode === 0 && res.data) {
|
||||||
|
profiles.value = res.data.profiles || []
|
||||||
|
activeProfile.value = res.data.active || profiles.value[0]?.name || ''
|
||||||
|
toolRouter.value = res.data.toolRouter || null
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
toast.error(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToBottom() {
|
||||||
|
nextTick(() => {
|
||||||
|
const el = messageListRef.value
|
||||||
|
if (el) {
|
||||||
|
el.scrollTop = el.scrollHeight
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(event) {
|
||||||
|
if (event.key === 'Enter' && !event.shiftKey) {
|
||||||
|
event.preventDefault()
|
||||||
|
sendMessage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearChat() {
|
||||||
|
if (pending.value) return
|
||||||
|
messages.value = []
|
||||||
|
traces.value = []
|
||||||
|
stats.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendMessage() {
|
||||||
|
const text = inputText.value.trim()
|
||||||
|
if (!text || pending.value) return
|
||||||
|
|
||||||
|
inputText.value = ''
|
||||||
|
traces.value = []
|
||||||
|
stats.value = null
|
||||||
|
|
||||||
|
messages.value.push({ role: 'user', content: text })
|
||||||
|
const assistantMessage = { role: 'assistant', content: '' }
|
||||||
|
messages.value.push(assistantMessage)
|
||||||
|
pending.value = true
|
||||||
|
scrollToBottom()
|
||||||
|
|
||||||
|
const history = messages.value
|
||||||
|
.filter((message) => message.role === 'user' || message.role === 'assistant')
|
||||||
|
.map((message) => ({ role: message.role, content: message.content }))
|
||||||
|
.slice(0, -1)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await streamChat(history, { openaiName: activeProfile.value }, {
|
||||||
|
onDelta(delta) {
|
||||||
|
assistantMessage.content += delta
|
||||||
|
scrollToBottom()
|
||||||
|
},
|
||||||
|
onTrace(frame) {
|
||||||
|
traces.value.push(frame)
|
||||||
|
scrollToBottom()
|
||||||
|
},
|
||||||
|
onStats(value) {
|
||||||
|
stats.value = value
|
||||||
|
},
|
||||||
|
onError(message) {
|
||||||
|
if (!assistantMessage.content) {
|
||||||
|
assistantMessage.content = t('aichat.error_prefix') + message
|
||||||
|
}
|
||||||
|
toast.error(message)
|
||||||
|
scrollToBottom()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
assistantMessage.content = t('aichat.error_prefix') + message
|
||||||
|
toast.error(message)
|
||||||
|
} finally {
|
||||||
|
pending.value = false
|
||||||
|
scrollToBottom()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mx-auto flex h-[calc(100vh-7rem)] max-w-5xl flex-col px-4 py-6">
|
||||||
|
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-dk-text">
|
||||||
|
{{ t('aichat.title') }}
|
||||||
|
</h1>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-dk-subtle">
|
||||||
|
{{ t('aichat.subtitle') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center gap-2 rounded-md border border-gray-200 px-3 py-2 text-sm text-gray-600 transition-colors hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-60 dark:border-dk-muted dark:text-dk-subtle dark:hover:bg-dk-card"
|
||||||
|
:disabled="pending || messages.length === 0"
|
||||||
|
@click="clearChat"
|
||||||
|
>
|
||||||
|
<IconTrash :size="16" />
|
||||||
|
{{ t('aichat.clear') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border border-gray-200 bg-white shadow-lg dark:border-dk-muted dark:bg-dk-card">
|
||||||
|
<div ref="messageListRef" class="min-h-0 flex-1 overflow-y-auto px-4 py-5 sm:px-6">
|
||||||
|
<div v-if="messages.length === 0" class="flex h-full items-center justify-center text-center">
|
||||||
|
<div class="max-w-md">
|
||||||
|
<div class="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-blue-50 text-blue-600 dark:bg-blue-900/20 dark:text-blue-300">
|
||||||
|
<IconRobot :size="30" />
|
||||||
|
</div>
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-dk-text">
|
||||||
|
{{ t('aichat.empty_title') }}
|
||||||
|
</h2>
|
||||||
|
<p class="mt-2 text-sm text-gray-500 dark:text-dk-subtle">
|
||||||
|
{{ t('aichat.empty_hint') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-5">
|
||||||
|
<div
|
||||||
|
v-for="(message, index) in messages"
|
||||||
|
:key="index"
|
||||||
|
:class="['flex gap-3', message.role === 'user' ? 'justify-end' : 'justify-start']"
|
||||||
|
>
|
||||||
|
<div v-if="message.role !== 'user'" class="mt-1 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-blue-50 text-blue-600 dark:bg-blue-900/20 dark:text-blue-300">
|
||||||
|
<IconRobot :size="18" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div :class="['max-w-[82%] rounded-2xl px-4 py-3 text-sm leading-6 shadow-sm', message.role === 'user' ? 'bg-blue-600 text-white' : 'border border-gray-200 bg-gray-50 text-gray-800 dark:border-dk-muted dark:bg-dk-base dark:text-dk-text']">
|
||||||
|
<div v-if="message.role !== 'user' && index === messages.length - 1 && traces.length" class="mb-3 space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="(trace, traceIndex) in traces"
|
||||||
|
:key="traceIndex"
|
||||||
|
class="rounded-lg border border-blue-100 bg-blue-50 px-3 py-2 text-xs text-blue-700 dark:border-blue-900/40 dark:bg-blue-900/20 dark:text-blue-200"
|
||||||
|
>
|
||||||
|
<div class="font-medium">
|
||||||
|
{{ trace.tool || 'tool' }} · {{ trace.status || trace.stage || 'trace' }}
|
||||||
|
</div>
|
||||||
|
<div v-if="trace.message" class="mt-1 opacity-90">
|
||||||
|
{{ trace.message }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="whitespace-pre-wrap break-words">
|
||||||
|
{{ message.content || (message.role === 'assistant' && pending ? t('aichat.thinking') : '') }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-if="message.role !== 'user' && index === messages.length - 1 && pending" class="mt-2 inline-flex items-center gap-1 text-xs text-gray-500 dark:text-dk-subtle">
|
||||||
|
<IconLoader2 :size="14" class="animate-spin" />
|
||||||
|
{{ t('aichat.streaming') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="message.role !== 'user' && index === messages.length - 1 && stats" class="mt-3 text-xs text-gray-500 dark:text-dk-subtle">
|
||||||
|
{{ t('aichat.tokens') }}: {{ stats.total_tokens || 0 }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="message.role === 'user'" class="mt-1 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gray-100 text-gray-600 dark:bg-dk-muted dark:text-dk-text">
|
||||||
|
<IconUser :size="18" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-t border-gray-200 bg-gray-50 p-4 dark:border-dk-muted dark:bg-dk-base">
|
||||||
|
<div class="flex items-end gap-3">
|
||||||
|
<textarea
|
||||||
|
v-model="inputText"
|
||||||
|
rows="2"
|
||||||
|
class="min-h-[52px] flex-1 resize-none rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 disabled:cursor-not-allowed disabled:opacity-60 dark:border-dk-muted dark:bg-dk-card dark:text-dk-text"
|
||||||
|
:placeholder="t('aichat.input_placeholder')"
|
||||||
|
:disabled="pending"
|
||||||
|
@keydown="onKeydown"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex h-[52px] items-center gap-2 rounded-lg bg-blue-600 px-4 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
:disabled="pending || !inputText.trim()"
|
||||||
|
@click="sendMessage"
|
||||||
|
>
|
||||||
|
<IconLoader2 v-if="pending" :size="18" class="animate-spin" />
|
||||||
|
<IconSend v-else :size="18" />
|
||||||
|
{{ t('aichat.send') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-xs text-gray-500 dark:text-dk-subtle">
|
||||||
|
{{ t('aichat.enter_hint') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -32,6 +32,7 @@ const tabs = [
|
|||||||
{ id: 'operation_logs', label: t('sysadmin.tab_operation_logs') },
|
{ id: 'operation_logs', label: t('sysadmin.tab_operation_logs') },
|
||||||
{ id: 'customer', label: t('customer.title'), to: '/customer' },
|
{ id: 'customer', label: t('customer.title'), to: '/customer' },
|
||||||
{ id: 'calendar', label: t('calendar.admin_title'), to: '/calendars/admin' },
|
{ id: 'calendar', label: t('calendar.admin_title'), to: '/calendars/admin' },
|
||||||
|
{ id: 'aiconfig', label: t('aiconfig.title'), to: '/admin/aiconfig' },
|
||||||
]
|
]
|
||||||
|
|
||||||
async function fetchSysAdmins() {
|
async function fetchSysAdmins() {
|
||||||
|
|||||||
Reference in New Issue
Block a user