diff --git a/README.md b/README.md index 45d070b..9b883ea 100644 --- a/README.md +++ b/README.md @@ -62,3 +62,6 @@ MonkeyCode 充分考虑了隐私和安全,支持**完全私有化和离线使 - 如果你通过网络提供服务,也必须开源你的代码 - 商业使用需要遵守相同的开源要求 +## Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=chaitin/MonkeyCode&type=Timeline)](https://www.star-history.com/#chaitin/MonkeyCode&Timeline) diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index df12a93..5916cc4 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -61,7 +61,7 @@ func newServer() (*Server, error) { redisClient := store.NewRedisCli(configConfig) proxyRepo := repo.NewProxyRepo(client, redisClient) modelRepo := repo2.NewModelRepo(client) - proxyUsecase := usecase.NewProxyUsecase(proxyRepo, modelRepo) + proxyUsecase := usecase.NewProxyUsecase(proxyRepo, modelRepo, slogLogger) llmProxy := proxy.NewLLMProxy(slogLogger, configConfig, proxyUsecase) openAIRepo := repo3.NewOpenAIRepo(client) openAIUsecase := openai.NewOpenAIUsecase(configConfig, openAIRepo, slogLogger) @@ -73,7 +73,8 @@ func newServer() (*Server, error) { modelUsecase := usecase3.NewModelUsecase(slogLogger, modelRepo, configConfig) sessionSession := session.NewSession(configConfig) authMiddleware := middleware.NewAuthMiddleware(sessionSession, slogLogger) - modelHandler := v1_2.NewModelHandler(web, modelUsecase, authMiddleware, activeMiddleware, slogLogger) + readOnlyMiddleware := middleware.NewReadOnlyMiddleware(configConfig) + modelHandler := v1_2.NewModelHandler(web, modelUsecase, authMiddleware, activeMiddleware, readOnlyMiddleware, slogLogger) ipdbIPDB, err := ipdb.NewIPDB(slogLogger) if err != nil { return nil, err @@ -84,13 +85,13 @@ func newServer() (*Server, error) { dashboardUsecase := usecase5.NewDashboardUsecase(dashboardRepo) billingRepo := repo7.NewBillingRepo(client) billingUsecase := usecase6.NewBillingUsecase(billingRepo) - userHandler := v1_3.NewUserHandler(web, userUsecase, extensionUsecase, dashboardUsecase, billingUsecase, authMiddleware, activeMiddleware, sessionSession, slogLogger, configConfig) + userHandler := v1_3.NewUserHandler(web, userUsecase, extensionUsecase, dashboardUsecase, billingUsecase, authMiddleware, activeMiddleware, readOnlyMiddleware, sessionSession, slogLogger, configConfig) dashboardHandler := v1_4.NewDashboardHandler(web, dashboardUsecase, authMiddleware, activeMiddleware) billingHandler := v1_5.NewBillingHandler(web, billingUsecase, authMiddleware, activeMiddleware) versionInfo := version.NewVersionInfo() reporter := report.NewReport(slogLogger, configConfig, versionInfo) reportRepo := repo8.NewReportRepo(client) - reportUsecase := usecase7.NewReportUsecase(reportRepo, slogLogger, reporter) + reportUsecase := usecase7.NewReportUsecase(reportRepo, slogLogger, reporter, redisClient) server := &Server{ config: configConfig, web: web, diff --git a/backend/config/config.go b/backend/config/config.go index 00c58ef..8fc1953 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -17,6 +17,8 @@ var ConfigTmpl []byte type Config struct { Debug bool `mapstructure:"debug"` + ReadOnly bool `mapstructure:"read_only"` + Logger *logger.Config `mapstructure:"logger"` Server struct { @@ -97,6 +99,7 @@ func Init() (*Config, error) { v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) v.SetDefault("debug", false) + v.SetDefault("read_only", false) v.SetDefault("logger.level", "info") v.SetDefault("server.addr", ":8888") v.SetDefault("server.port", "") diff --git a/backend/consts/proxy.go b/backend/consts/proxy.go index aa3dabe..02f6a92 100644 --- a/backend/consts/proxy.go +++ b/backend/consts/proxy.go @@ -3,8 +3,11 @@ package consts type ReportAction string const ( - ReportActionAccept ReportAction = "accept" - ReportActionSuggest ReportAction = "suggest" - ReportActionFileWritten ReportAction = "file_written" - ReportActionReject ReportAction = "reject" + ReportActionAccept ReportAction = "accept" + ReportActionSuggest ReportAction = "suggest" + ReportActionFileWritten ReportAction = "file_written" + ReportActionReject ReportAction = "reject" + ReportActionNewTask ReportAction = "new_task" + ReportActionFeedbackTask ReportAction = "feedback_task" + ReportActionAbortTask ReportAction = "abort_task" ) diff --git a/backend/db/migrate/schema.go b/backend/db/migrate/schema.go index b9e953d..c6fea4c 100644 --- a/backend/db/migrate/schema.go +++ b/backend/db/migrate/schema.go @@ -305,9 +305,9 @@ var ( {Name: "id", Type: field.TypeUUID, Unique: true}, {Name: "prompt", Type: field.TypeString, Nullable: true}, {Name: "role", Type: field.TypeString}, - {Name: "completion", Type: field.TypeString}, - {Name: "output_tokens", Type: field.TypeInt64}, - {Name: "code_lines", Type: field.TypeInt64}, + {Name: "completion", Type: field.TypeString, Nullable: true}, + {Name: "output_tokens", Type: field.TypeInt64, Default: 0}, + {Name: "code_lines", Type: field.TypeInt64, Default: 0}, {Name: "code", Type: field.TypeString, Nullable: true}, {Name: "created_at", Type: field.TypeTime}, {Name: "updated_at", Type: field.TypeTime}, diff --git a/backend/db/mutation.go b/backend/db/mutation.go index c451827..c78690e 100644 --- a/backend/db/mutation.go +++ b/backend/db/mutation.go @@ -11994,9 +11994,22 @@ func (m *TaskRecordMutation) OldCompletion(ctx context.Context) (v string, err e return oldValue.Completion, nil } +// ClearCompletion clears the value of the "completion" field. +func (m *TaskRecordMutation) ClearCompletion() { + m.completion = nil + m.clearedFields[taskrecord.FieldCompletion] = struct{}{} +} + +// CompletionCleared returns if the "completion" field was cleared in this mutation. +func (m *TaskRecordMutation) CompletionCleared() bool { + _, ok := m.clearedFields[taskrecord.FieldCompletion] + return ok +} + // ResetCompletion resets all changes to the "completion" field. func (m *TaskRecordMutation) ResetCompletion() { m.completion = nil + delete(m.clearedFields, taskrecord.FieldCompletion) } // SetOutputTokens sets the "output_tokens" field. @@ -12509,6 +12522,9 @@ func (m *TaskRecordMutation) ClearedFields() []string { if m.FieldCleared(taskrecord.FieldPrompt) { fields = append(fields, taskrecord.FieldPrompt) } + if m.FieldCleared(taskrecord.FieldCompletion) { + fields = append(fields, taskrecord.FieldCompletion) + } if m.FieldCleared(taskrecord.FieldCode) { fields = append(fields, taskrecord.FieldCode) } @@ -12532,6 +12548,9 @@ func (m *TaskRecordMutation) ClearField(name string) error { case taskrecord.FieldPrompt: m.ClearPrompt() return nil + case taskrecord.FieldCompletion: + m.ClearCompletion() + return nil case taskrecord.FieldCode: m.ClearCode() return nil diff --git a/backend/db/runtime/runtime.go b/backend/db/runtime/runtime.go index af72064..134ae82 100644 --- a/backend/db/runtime/runtime.go +++ b/backend/db/runtime/runtime.go @@ -272,6 +272,14 @@ func init() { task.UpdateDefaultUpdatedAt = taskDescUpdatedAt.UpdateDefault.(func() time.Time) taskrecordFields := schema.TaskRecord{}.Fields() _ = taskrecordFields + // taskrecordDescOutputTokens is the schema descriptor for output_tokens field. + taskrecordDescOutputTokens := taskrecordFields[5].Descriptor() + // taskrecord.DefaultOutputTokens holds the default value on creation for the output_tokens field. + taskrecord.DefaultOutputTokens = taskrecordDescOutputTokens.Default.(int64) + // taskrecordDescCodeLines is the schema descriptor for code_lines field. + taskrecordDescCodeLines := taskrecordFields[6].Descriptor() + // taskrecord.DefaultCodeLines holds the default value on creation for the code_lines field. + taskrecord.DefaultCodeLines = taskrecordDescCodeLines.Default.(int64) // taskrecordDescCreatedAt is the schema descriptor for created_at field. taskrecordDescCreatedAt := taskrecordFields[8].Descriptor() // taskrecord.DefaultCreatedAt holds the default value on creation for the created_at field. diff --git a/backend/db/taskrecord/taskrecord.go b/backend/db/taskrecord/taskrecord.go index e290321..907b8f5 100644 --- a/backend/db/taskrecord/taskrecord.go +++ b/backend/db/taskrecord/taskrecord.go @@ -70,6 +70,10 @@ func ValidColumn(column string) bool { } var ( + // DefaultOutputTokens holds the default value on creation for the "output_tokens" field. + DefaultOutputTokens int64 + // DefaultCodeLines holds the default value on creation for the "code_lines" field. + DefaultCodeLines int64 // DefaultCreatedAt holds the default value on creation for the "created_at" field. DefaultCreatedAt func() time.Time // DefaultUpdatedAt holds the default value on creation for the "updated_at" field. diff --git a/backend/db/taskrecord/where.go b/backend/db/taskrecord/where.go index a8736f8..7d1a575 100644 --- a/backend/db/taskrecord/where.go +++ b/backend/db/taskrecord/where.go @@ -347,6 +347,16 @@ func CompletionHasSuffix(v string) predicate.TaskRecord { return predicate.TaskRecord(sql.FieldHasSuffix(FieldCompletion, v)) } +// CompletionIsNil applies the IsNil predicate on the "completion" field. +func CompletionIsNil() predicate.TaskRecord { + return predicate.TaskRecord(sql.FieldIsNull(FieldCompletion)) +} + +// CompletionNotNil applies the NotNil predicate on the "completion" field. +func CompletionNotNil() predicate.TaskRecord { + return predicate.TaskRecord(sql.FieldNotNull(FieldCompletion)) +} + // CompletionEqualFold applies the EqualFold predicate on the "completion" field. func CompletionEqualFold(v string) predicate.TaskRecord { return predicate.TaskRecord(sql.FieldEqualFold(FieldCompletion, v)) diff --git a/backend/db/taskrecord_create.go b/backend/db/taskrecord_create.go index e7b89bc..28970ef 100644 --- a/backend/db/taskrecord_create.go +++ b/backend/db/taskrecord_create.go @@ -66,18 +66,42 @@ func (trc *TaskRecordCreate) SetCompletion(s string) *TaskRecordCreate { return trc } +// SetNillableCompletion sets the "completion" field if the given value is not nil. +func (trc *TaskRecordCreate) SetNillableCompletion(s *string) *TaskRecordCreate { + if s != nil { + trc.SetCompletion(*s) + } + return trc +} + // SetOutputTokens sets the "output_tokens" field. func (trc *TaskRecordCreate) SetOutputTokens(i int64) *TaskRecordCreate { trc.mutation.SetOutputTokens(i) return trc } +// SetNillableOutputTokens sets the "output_tokens" field if the given value is not nil. +func (trc *TaskRecordCreate) SetNillableOutputTokens(i *int64) *TaskRecordCreate { + if i != nil { + trc.SetOutputTokens(*i) + } + return trc +} + // SetCodeLines sets the "code_lines" field. func (trc *TaskRecordCreate) SetCodeLines(i int64) *TaskRecordCreate { trc.mutation.SetCodeLines(i) return trc } +// SetNillableCodeLines sets the "code_lines" field if the given value is not nil. +func (trc *TaskRecordCreate) SetNillableCodeLines(i *int64) *TaskRecordCreate { + if i != nil { + trc.SetCodeLines(*i) + } + return trc +} + // SetCode sets the "code" field. func (trc *TaskRecordCreate) SetCode(s string) *TaskRecordCreate { trc.mutation.SetCode(s) @@ -166,6 +190,14 @@ func (trc *TaskRecordCreate) ExecX(ctx context.Context) { // defaults sets the default values of the builder before save. func (trc *TaskRecordCreate) defaults() { + if _, ok := trc.mutation.OutputTokens(); !ok { + v := taskrecord.DefaultOutputTokens + trc.mutation.SetOutputTokens(v) + } + if _, ok := trc.mutation.CodeLines(); !ok { + v := taskrecord.DefaultCodeLines + trc.mutation.SetCodeLines(v) + } if _, ok := trc.mutation.CreatedAt(); !ok { v := taskrecord.DefaultCreatedAt() trc.mutation.SetCreatedAt(v) @@ -181,9 +213,6 @@ func (trc *TaskRecordCreate) check() error { if _, ok := trc.mutation.Role(); !ok { return &ValidationError{Name: "role", err: errors.New(`db: missing required field "TaskRecord.role"`)} } - if _, ok := trc.mutation.Completion(); !ok { - return &ValidationError{Name: "completion", err: errors.New(`db: missing required field "TaskRecord.completion"`)} - } if _, ok := trc.mutation.OutputTokens(); !ok { return &ValidationError{Name: "output_tokens", err: errors.New(`db: missing required field "TaskRecord.output_tokens"`)} } @@ -393,6 +422,12 @@ func (u *TaskRecordUpsert) UpdateCompletion() *TaskRecordUpsert { return u } +// ClearCompletion clears the value of the "completion" field. +func (u *TaskRecordUpsert) ClearCompletion() *TaskRecordUpsert { + u.SetNull(taskrecord.FieldCompletion) + return u +} + // SetOutputTokens sets the "output_tokens" field. func (u *TaskRecordUpsert) SetOutputTokens(v int64) *TaskRecordUpsert { u.Set(taskrecord.FieldOutputTokens, v) @@ -589,6 +624,13 @@ func (u *TaskRecordUpsertOne) UpdateCompletion() *TaskRecordUpsertOne { }) } +// ClearCompletion clears the value of the "completion" field. +func (u *TaskRecordUpsertOne) ClearCompletion() *TaskRecordUpsertOne { + return u.Update(func(s *TaskRecordUpsert) { + s.ClearCompletion() + }) +} + // SetOutputTokens sets the "output_tokens" field. func (u *TaskRecordUpsertOne) SetOutputTokens(v int64) *TaskRecordUpsertOne { return u.Update(func(s *TaskRecordUpsert) { @@ -965,6 +1007,13 @@ func (u *TaskRecordUpsertBulk) UpdateCompletion() *TaskRecordUpsertBulk { }) } +// ClearCompletion clears the value of the "completion" field. +func (u *TaskRecordUpsertBulk) ClearCompletion() *TaskRecordUpsertBulk { + return u.Update(func(s *TaskRecordUpsert) { + s.ClearCompletion() + }) +} + // SetOutputTokens sets the "output_tokens" field. func (u *TaskRecordUpsertBulk) SetOutputTokens(v int64) *TaskRecordUpsertBulk { return u.Update(func(s *TaskRecordUpsert) { diff --git a/backend/db/taskrecord_update.go b/backend/db/taskrecord_update.go index 1c5be23..6e85d4d 100644 --- a/backend/db/taskrecord_update.go +++ b/backend/db/taskrecord_update.go @@ -100,6 +100,12 @@ func (tru *TaskRecordUpdate) SetNillableCompletion(s *string) *TaskRecordUpdate return tru } +// ClearCompletion clears the value of the "completion" field. +func (tru *TaskRecordUpdate) ClearCompletion() *TaskRecordUpdate { + tru.mutation.ClearCompletion() + return tru +} + // SetOutputTokens sets the "output_tokens" field. func (tru *TaskRecordUpdate) SetOutputTokens(i int64) *TaskRecordUpdate { tru.mutation.ResetOutputTokens() @@ -261,6 +267,9 @@ func (tru *TaskRecordUpdate) sqlSave(ctx context.Context) (n int, err error) { if value, ok := tru.mutation.Completion(); ok { _spec.SetField(taskrecord.FieldCompletion, field.TypeString, value) } + if tru.mutation.CompletionCleared() { + _spec.ClearField(taskrecord.FieldCompletion, field.TypeString) + } if value, ok := tru.mutation.OutputTokens(); ok { _spec.SetField(taskrecord.FieldOutputTokens, field.TypeInt64, value) } @@ -404,6 +413,12 @@ func (truo *TaskRecordUpdateOne) SetNillableCompletion(s *string) *TaskRecordUpd return truo } +// ClearCompletion clears the value of the "completion" field. +func (truo *TaskRecordUpdateOne) ClearCompletion() *TaskRecordUpdateOne { + truo.mutation.ClearCompletion() + return truo +} + // SetOutputTokens sets the "output_tokens" field. func (truo *TaskRecordUpdateOne) SetOutputTokens(i int64) *TaskRecordUpdateOne { truo.mutation.ResetOutputTokens() @@ -595,6 +610,9 @@ func (truo *TaskRecordUpdateOne) sqlSave(ctx context.Context) (_node *TaskRecord if value, ok := truo.mutation.Completion(); ok { _spec.SetField(taskrecord.FieldCompletion, field.TypeString, value) } + if truo.mutation.CompletionCleared() { + _spec.ClearField(taskrecord.FieldCompletion, field.TypeString) + } if value, ok := truo.mutation.OutputTokens(); ok { _spec.SetField(taskrecord.FieldOutputTokens, field.TypeInt64, value) } diff --git a/backend/domain/billing.go b/backend/domain/billing.go index c412b1b..20d2b81 100644 --- a/backend/domain/billing.go +++ b/backend/domain/billing.go @@ -118,7 +118,7 @@ func (c *CompletionInfo) From(e *db.Task) *CompletionInfo { } type ChatContent struct { - Role consts.ChatRole `json:"role"` // 角色,如user: 用户的提问 assistant: 机器人回复 + Role consts.ChatRole `json:"role"` // 角色,如user: 用户的提问 assistant: 机器人回复 system: 系统消息 Content string `json:"content"` // 内容 CreatedAt int64 `json:"created_at"` } @@ -133,6 +133,8 @@ func (c *ChatContent) From(e *db.TaskRecord) *ChatContent { c.Content = e.Prompt case consts.ChatRoleAssistant: c.Content = e.Completion + case consts.ChatRoleSystem: + c.Content = e.Completion } c.CreatedAt = e.CreatedAt.Unix() return c @@ -151,6 +153,9 @@ func (c *ChatInfo) From(e *db.Task) *ChatInfo { c.Contents = cvt.Iter(e.Edges.TaskRecords, func(_ int, r *db.TaskRecord) *ChatContent { return cvt.From(r, &ChatContent{}) }) + c.Contents = cvt.Filter(c.Contents, func(_ int, r *ChatContent) (*ChatContent, bool) { + return r, r.Content != "" + }) return c } diff --git a/backend/domain/proxy.go b/backend/domain/proxy.go index 2d51a22..745eacb 100644 --- a/backend/domain/proxy.go +++ b/backend/domain/proxy.go @@ -29,7 +29,7 @@ type ProxyRepo interface { Record(ctx context.Context, record *RecordParam) error UpdateByTaskID(ctx context.Context, taskID string, fn func(*db.TaskUpdateOne)) error AcceptCompletion(ctx context.Context, req *AcceptCompletionReq) error - Report(ctx context.Context, req *ReportReq) error + Report(ctx context.Context, model *db.Model, req *ReportReq) error SelectModelWithLoadBalancing(modelName string, modelType consts.ModelType) (*db.Model, error) ValidateApiKey(ctx context.Context, key string) (*db.ApiKey, error) } @@ -52,6 +52,8 @@ type ReportReq struct { UserInput string `json:"user_input"` // 用户输入的新文本(用于reject action) SourceCode string `json:"source_code"` // 当前文件的原文(用于reject action) CursorPosition map[string]any `json:"cursor_position"` // 光标位置(用于reject action) + Mode string `json:"mode"` // 模式 + UserID string `json:"-"` } type RecordParam struct { diff --git a/backend/domain/user.go b/backend/domain/user.go index e1f01b6..3940568 100644 --- a/backend/domain/user.go +++ b/backend/domain/user.go @@ -36,7 +36,7 @@ type UserUsecase interface { type UserRepo interface { List(ctx context.Context, page *web.Pagination) ([]*db.User, *db.PageInfo, error) - Update(ctx context.Context, id string, fn func(*db.User, *db.UserUpdateOne) error) (*db.User, error) + Update(ctx context.Context, id string, fn func(*db.Tx, *db.User, *db.UserUpdateOne) error) (*db.User, error) Delete(ctx context.Context, id string) error InitAdmin(ctx context.Context, username, password string) error CreateUser(ctx context.Context, user *db.User) (*db.User, error) diff --git a/backend/ent/schema/taskrecord.go b/backend/ent/schema/taskrecord.go index 206fac6..c363938 100644 --- a/backend/ent/schema/taskrecord.go +++ b/backend/ent/schema/taskrecord.go @@ -33,9 +33,9 @@ func (TaskRecord) Fields() []ent.Field { field.UUID("task_id", uuid.UUID{}).Optional(), field.String("prompt").Optional(), field.String("role").GoType(consts.ChatRole("")), - field.String("completion"), - field.Int64("output_tokens"), - field.Int64("code_lines"), + field.String("completion").Optional(), + field.Int64("output_tokens").Default(0), + field.Int64("code_lines").Default(0), field.String("code").Optional(), field.Time("created_at").Default(time.Now), field.Time("updated_at").Default(time.Now).UpdateDefault(time.Now), diff --git a/backend/errcode/errcode.go b/backend/errcode/errcode.go index 9d168b7..ad72689 100644 --- a/backend/errcode/errcode.go +++ b/backend/errcode/errcode.go @@ -12,6 +12,7 @@ var LocalFS embed.FS var ( ErrPermission = web.NewBadRequestErr("err-permission") ErrUserNotFound = web.NewBadRequestErr("err-user-not-found") + ErrUserLock = web.NewBadRequestErr("err-user-lock") ErrPassword = web.NewBadRequestErr("err-password") ErrInviteCodeInvalid = web.NewBadRequestErr("err-invite-code-invalid") ErrEmailInvalid = web.NewBadRequestErr("err-email-invalid") diff --git a/backend/errcode/locale.zh.toml b/backend/errcode/locale.zh.toml index 536e37b..6dab11d 100644 --- a/backend/errcode/locale.zh.toml +++ b/backend/errcode/locale.zh.toml @@ -4,6 +4,9 @@ other = "无权操作" [err-user-not-found] other = "用户不存在" +[err-user-lock] +other = "用户已锁定" + [err-password] other = "密码错误" diff --git a/backend/internal/billing/repo/billing.go b/backend/internal/billing/repo/billing.go index 8e7c7c9..4beb61a 100644 --- a/backend/internal/billing/repo/billing.go +++ b/backend/internal/billing/repo/billing.go @@ -29,7 +29,6 @@ func (b *BillingRepo) ChatInfo(ctx context.Context, id, userID string) (*domain. q := b.db.Task.Query(). WithTaskRecords(func(trq *db.TaskRecordQuery) { trq.Order(taskrecord.ByCreatedAt(sql.OrderAsc())) - trq.Where(taskrecord.RoleNEQ(consts.ChatRoleSystem)) }). Where(task.TaskID(id)) if userID != "" { diff --git a/backend/internal/dashboard/repo/dashboard.go b/backend/internal/dashboard/repo/dashboard.go index 6aaf5b1..288b0fe 100644 --- a/backend/internal/dashboard/repo/dashboard.go +++ b/backend/internal/dashboard/repo/dashboard.go @@ -110,7 +110,7 @@ func (d *DashboardRepo) TimeStat(ctx context.Context, req domain.StatisticsFilte sql.As(fmt.Sprintf("date_trunc('%s', created_at)", req.Precision), "date"), sql.As("COUNT(DISTINCT user_id)", "user_count"), sql.As("COUNT(*) FILTER (WHERE model_type = 'llm')", "llm_count"), - sql.As("COUNT(*) FILTER (WHERE model_type = 'coder')", "code_count"), + sql.As("COUNT(*) FILTER (WHERE is_suggested = true AND model_type = 'coder')", "code_count"), sql.As("COUNT(*) FILTER (WHERE is_accept = true AND model_type = 'coder')", "accepted_count"), sql.As(sql.Sum(task.FieldCodeLines), "code_lines"), ).GroupBy("date"). @@ -154,7 +154,10 @@ func (d *DashboardRepo) TimeStat(ctx context.Context, req domain.StatisticsFilte }) } + totalAccepted, totalSuggested := int64(0), int64(0) for _, v := range ds { + totalAccepted += v.AcceptedCount + totalSuggested += v.CodeCount ts.TotalChats += v.LlmCount ts.TotalCompletions += v.CodeCount ts.TotalLinesOfCode += v.CodeLines @@ -182,6 +185,10 @@ func (d *DashboardRepo) TimeStat(ctx context.Context, req domain.StatisticsFilte } } + if totalSuggested > 0 { + ts.TotalAcceptedPer = float64(totalAccepted) / float64(totalSuggested) * 100 + } + return ts, nil } @@ -280,7 +287,7 @@ func (d *DashboardRepo) UserStat(ctx context.Context, req domain.StatisticsFilte sql.As(fmt.Sprintf("date_trunc('%s', created_at)", req.Precision), "date"), sql.As("COUNT(DISTINCT user_id)", "user_count"), sql.As("COUNT(*) FILTER (WHERE model_type = 'llm')", "llm_count"), - sql.As("COUNT(*) FILTER (WHERE model_type = 'coder')", "code_count"), + sql.As("COUNT(*) FILTER (WHERE is_suggested = true AND model_type = 'coder')", "code_count"), sql.As("COUNT(*) FILTER (WHERE is_accept = true AND model_type = 'coder')", "accepted_count"), sql.As(sql.Sum(task.FieldCodeLines), "code_lines"), ).GroupBy("date"). diff --git a/backend/internal/middleware/readonly.go b/backend/internal/middleware/readonly.go new file mode 100644 index 0000000..eff14f6 --- /dev/null +++ b/backend/internal/middleware/readonly.go @@ -0,0 +1,31 @@ +package middleware + +import ( + "net/http" + + "github.com/labstack/echo/v4" + + "github.com/chaitin/MonkeyCode/backend/config" +) + +type ReadOnlyMiddleware struct { + cfg *config.Config +} + +func NewReadOnlyMiddleware(cfg *config.Config) *ReadOnlyMiddleware { + return &ReadOnlyMiddleware{cfg: cfg} +} + +func (m *ReadOnlyMiddleware) Guard() echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + if m.cfg.ReadOnly && c.Request().Method != http.MethodGet { + return c.JSON(http.StatusOK, echo.Map{ + "code": -1, + "message": "只读模式下不支持该操作", + }) + } + return next(c) + } + } +} diff --git a/backend/internal/model/handler/http/v1/model.go b/backend/internal/model/handler/http/v1/model.go index e8fb9ef..a1717bc 100644 --- a/backend/internal/model/handler/http/v1/model.go +++ b/backend/internal/model/handler/http/v1/model.go @@ -20,12 +20,13 @@ func NewModelHandler( usecase domain.ModelUsecase, auth *middleware.AuthMiddleware, active *middleware.ActiveMiddleware, + readonly *middleware.ReadOnlyMiddleware, logger *slog.Logger, ) *ModelHandler { m := &ModelHandler{usecase: usecase, logger: logger.With("handler", "model")} g := w.Group("/api/v1/model") - g.Use(auth.Auth(), active.Active("admin")) + g.Use(auth.Auth(), active.Active("admin"), readonly.Guard()) g.GET("", web.BaseHandler(m.List)) g.GET("/provider/supported", web.BindHandler(m.GetProviderModelList)) diff --git a/backend/internal/openai/handler/v1/v1.go b/backend/internal/openai/handler/v1/v1.go index 74a75f5..bf263a2 100644 --- a/backend/internal/openai/handler/v1/v1.go +++ b/backend/internal/openai/handler/v1/v1.go @@ -110,6 +110,7 @@ func (h *V1Handler) AcceptCompletion(c *web.Context, req domain.AcceptCompletion // @Router /v1/report [post] func (h *V1Handler) Report(c *web.Context, req domain.ReportReq) error { h.logger.DebugContext(c.Request().Context(), "Report", slog.Any("req", req)) + req.UserID = middleware.GetApiKey(c).UserID if err := h.proxyUse.Report(c.Request().Context(), &req); err != nil { return err } diff --git a/backend/internal/provider.go b/backend/internal/provider.go index c7350c0..b493657 100644 --- a/backend/internal/provider.go +++ b/backend/internal/provider.go @@ -45,6 +45,7 @@ var Provider = wire.NewSet( middleware.NewProxyMiddleware, middleware.NewAuthMiddleware, middleware.NewActiveMiddleware, + middleware.NewReadOnlyMiddleware, userV1.NewUserHandler, userrepo.NewUserRepo, userusecase.NewUserUsecase, diff --git a/backend/internal/proxy/recorder.go b/backend/internal/proxy/recorder.go index 18492f7..fb6974d 100644 --- a/backend/internal/proxy/recorder.go +++ b/backend/internal/proxy/recorder.go @@ -153,19 +153,8 @@ func (r *Recorder) handleShadow() { With("resp_header", formatHeader(r.ctx.RespHeader)). DebugContext(r.ctx.ctx, "handle shadow", "rc", rc) - // 记录用户的提问 - if r.ctx.Model.ModelType == consts.ModelTypeLLM && prompt != "" { - tmp := rc.Clone() - tmp.Role = consts.ChatRoleUser - tmp.Completion = "" - tmp.OutputTokens = 0 - if err := r.usecase.Record(context.Background(), tmp); err != nil { - r.logger.WarnContext(r.ctx.ctx, "记录请求失败", "error", err) - } - } - if err := r.usecase.Record(context.Background(), rc); err != nil { - r.logger.WarnContext(r.ctx.ctx, "记录请求失败", "error", err) + r.logger.With("record", rc).WarnContext(r.ctx.ctx, "记录请求失败", "error", err) } } diff --git a/backend/internal/proxy/repo/proxy.go b/backend/internal/proxy/repo/proxy.go index 552dcfb..98799aa 100644 --- a/backend/internal/proxy/repo/proxy.go +++ b/backend/internal/proxy/repo/proxy.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "strings" "time" @@ -76,6 +77,9 @@ func (r *ProxyRepo) ValidateApiKey(ctx context.Context, key string) (*db.ApiKey, } func (r *ProxyRepo) Record(ctx context.Context, record *domain.RecordParam) error { + if record.TaskID == "" { + return fmt.Errorf("task_id is empty") + } userID, err := uuid.Parse(record.UserID) if err != nil { return err @@ -212,14 +216,52 @@ func abs(x int64) int64 { return x } -func (r *ProxyRepo) Report(ctx context.Context, req *domain.ReportReq) error { +func (r *ProxyRepo) Report(ctx context.Context, model *db.Model, req *domain.ReportReq) error { return entx.WithTx(ctx, r.db, func(tx *db.Tx) error { rc, err := tx.Task.Query().Where(task.TaskID(req.ID)).Only(ctx) if err != nil { - return err + if req.Action == consts.ReportActionNewTask && db.IsNotFound(err) { + uid, err := uuid.Parse(req.UserID) + if err != nil { + return err + } + newTask, err := tx.Task.Create(). + SetTaskID(req.ID). + SetRequestID(uuid.NewString()). + SetUserID(uid). + SetModelID(model.ID). + SetModelType(model.ModelType). + SetWorkMode(req.Mode). + SetPrompt(req.Content). + Save(ctx) + if err != nil { + return err + } + rc = newTask + } else { + return err + } } switch req.Action { + case consts.ReportActionNewTask, consts.ReportActionFeedbackTask: + if err := tx.TaskRecord.Create(). + SetTaskID(rc.ID). + SetRole(consts.ChatRoleUser). + SetPrompt(req.Content). + Exec(ctx); err != nil { + return err + } + + case consts.ReportActionAbortTask: + if err := tx.TaskRecord.Create(). + SetTaskID(rc.ID). + SetRole(consts.ChatRoleSystem). + SetCompletion(req.Content). + Exec(ctx); err != nil { + return err + } + case consts.ReportActionAccept: if err := tx.Task.UpdateOneID(rc.ID). SetIsAccept(true). diff --git a/backend/internal/proxy/usecase/proxy.go b/backend/internal/proxy/usecase/proxy.go index 65dcc7b..635c98b 100644 --- a/backend/internal/proxy/usecase/proxy.go +++ b/backend/internal/proxy/usecase/proxy.go @@ -2,7 +2,9 @@ package usecase import ( "context" + "log/slog" + "github.com/chaitin/MonkeyCode/backend/db" "github.com/chaitin/MonkeyCode/backend/pkg/cvt" "github.com/chaitin/MonkeyCode/backend/consts" @@ -12,10 +14,19 @@ import ( type ProxyUsecase struct { repo domain.ProxyRepo modelRepo domain.ModelRepo + logger *slog.Logger } -func NewProxyUsecase(repo domain.ProxyRepo, modelRepo domain.ModelRepo) domain.ProxyUsecase { - return &ProxyUsecase{repo: repo, modelRepo: modelRepo} +func NewProxyUsecase( + repo domain.ProxyRepo, + modelRepo domain.ModelRepo, + logger *slog.Logger, +) domain.ProxyUsecase { + return &ProxyUsecase{ + repo: repo, + modelRepo: modelRepo, + logger: logger.With("module", "ProxyUsecase"), + } } func (p *ProxyUsecase) Record(ctx context.Context, record *domain.RecordParam) error { @@ -44,5 +55,14 @@ func (p *ProxyUsecase) AcceptCompletion(ctx context.Context, req *domain.AcceptC } func (p *ProxyUsecase) Report(ctx context.Context, req *domain.ReportReq) error { - return p.repo.Report(ctx, req) + var model *db.Model + var err error + if req.Action == consts.ReportActionNewTask { + model, err = p.modelRepo.GetWithCache(context.Background(), consts.ModelTypeLLM) + if err != nil { + p.logger.With("fn", "Report").With("error", err).ErrorContext(ctx, "failed to get model") + return err + } + } + return p.repo.Report(ctx, model, req) } diff --git a/backend/internal/report/usecase/report.go b/backend/internal/report/usecase/report.go index eefebd5..3b069e9 100644 --- a/backend/internal/report/usecase/report.go +++ b/backend/internal/report/usecase/report.go @@ -6,6 +6,8 @@ import ( "log/slog" "time" + "github.com/redis/go-redis/v9" + "github.com/chaitin/MonkeyCode/backend/domain" "github.com/chaitin/MonkeyCode/backend/pkg/report" "github.com/chaitin/MonkeyCode/backend/pkg/version" @@ -15,21 +17,80 @@ type ReportUsecase struct { repo domain.ReportRepo logger *slog.Logger reporter *report.Reporter + redis *redis.Client } -func NewReportUsecase(repo domain.ReportRepo, logger *slog.Logger, reporter *report.Reporter) domain.ReportUsecase { - r := &ReportUsecase{repo: repo, logger: logger, reporter: reporter} +func NewReportUsecase( + repo domain.ReportRepo, + logger *slog.Logger, + reporter *report.Reporter, + redis *redis.Client, +) domain.ReportUsecase { + r := &ReportUsecase{ + repo: repo, + logger: logger, + reporter: reporter, + redis: redis, + } go r.Report() return r } func (r *ReportUsecase) Report() { - ticker := time.NewTicker(24 * time.Hour) + ticker := time.NewTicker(1 * time.Hour) + defer ticker.Stop() + report := func() { + ok, err := r.shouldReport() + if err != nil { + r.logger.With("error", err).Error("check report time failed") + } + if ok { + if err := r.innerReport(); err != nil { + r.logger.With("error", err).Error("report failed") + } else { + if err := r.recordReportTime(); err != nil { + r.logger.With("error", err).Error("record report time failed") + } + } + } + } + report() + for range ticker.C { - if err := r.innerReport(); err != nil { - r.logger.With("error", err).Error("report failed") + report() + } +} + +func (r *ReportUsecase) shouldReport() (bool, error) { + ctx := context.Background() + key := "monkeycode:last_report_time" + + ts, err := r.redis.Get(ctx, key).Result() + if err != nil { + if err == redis.Nil { + return true, nil } + return false, fmt.Errorf("get last report time from redis failed: %w", err) } + + t, err := time.Parse(time.RFC3339, ts) + if err != nil { + return false, fmt.Errorf("parse last report time failed: %w", err) + } + + return time.Since(t) >= 24*time.Hour, nil +} + +func (r *ReportUsecase) recordReportTime() error { + ctx := context.Background() + key := "monkeycode:last_report_time" + now := time.Now().Format(time.RFC3339) + + err := r.redis.Set(ctx, key, now, 48*time.Hour).Err() + if err != nil { + return fmt.Errorf("set last report time to redis failed: %w", err) + } + return nil } func (r *ReportUsecase) innerReport() error { @@ -63,5 +124,7 @@ func (r *ReportUsecase) innerReport() error { if err := r.reporter.Report("monkeycode-metrics", data); err != nil { return fmt.Errorf("report failed: %w", err) } + + r.logger.With("data", data).Debug("上报数据成功") return nil } diff --git a/backend/internal/user/handler/v1/user.go b/backend/internal/user/handler/v1/user.go index 4a20ba6..daa17b4 100644 --- a/backend/internal/user/handler/v1/user.go +++ b/backend/internal/user/handler/v1/user.go @@ -49,6 +49,7 @@ func NewUserHandler( buse domain.BillingUsecase, auth *middleware.AuthMiddleware, active *middleware.ActiveMiddleware, + readonly *middleware.ReadOnlyMiddleware, session *session.Session, logger *slog.Logger, cfg *config.Config, @@ -74,7 +75,7 @@ func NewUserHandler( admin.POST("/login", web.BindHandler(u.AdminLogin)) admin.GET("/setting", web.BaseHandler(u.GetSetting)) - admin.Use(auth.Auth(), active.Active("admin")) + admin.Use(auth.Auth(), active.Active("admin"), readonly.Guard()) admin.GET("/profile", web.BaseHandler(u.AdminProfile)) admin.GET("/list", web.BaseHandler(u.AdminList, web.WithPage())) admin.GET("/login-history", web.BaseHandler(u.AdminLoginHistory, web.WithPage())) @@ -91,6 +92,7 @@ func NewUserHandler( g.POST("/register", web.BindHandler(u.Register)) g.POST("/login", web.BindHandler(u.Login)) + g.Use(readonly.Guard()) g.GET("/profile", web.BaseHandler(u.Profile), auth.UserAuth()) g.PUT("/profile", web.BindHandler(u.UpdateProfile), auth.UserAuth()) g.POST("/logout", web.BaseHandler(u.Logout), auth.UserAuth()) diff --git a/backend/internal/user/repo/user.go b/backend/internal/user/repo/user.go index 1c5d3dd..03b515c 100644 --- a/backend/internal/user/repo/user.go +++ b/backend/internal/user/repo/user.go @@ -74,12 +74,14 @@ func (r *UserRepo) AdminByName(ctx context.Context, username string) (*db.Admin, } func (r *UserRepo) GetByName(ctx context.Context, username string) (*db.User, error) { - return r.db.User.Query().Where( - user.Or( - user.Username(username), - user.Email(username), - ), - ).Only(ctx) + return r.db.User.Query(). + Where( + user.Or( + user.Username(username), + user.Email(username), + ), + ). + Only(ctx) } func (r *UserRepo) ValidateInviteCode(ctx context.Context, code string) (*db.InviteCode, error) { @@ -167,12 +169,12 @@ func (r *UserRepo) CreateInviteCode(ctx context.Context, userID string, code str } func (r *UserRepo) AdminList(ctx context.Context, page *web.Pagination) ([]*db.Admin, *db.PageInfo, error) { - q := r.db.Admin.Query() + q := r.db.Admin.Query().Order(admin.ByCreatedAt(sql.OrderDesc())) return q.Page(ctx, page.Page, page.Size) } func (r *UserRepo) List(ctx context.Context, page *web.Pagination) ([]*db.User, *db.PageInfo, error) { - q := r.db.User.Query() + q := r.db.User.Query().Order(user.ByCreatedAt(sql.OrderDesc())) return q.Page(ctx, page.Page, page.Size) } @@ -241,7 +243,7 @@ func (r *UserRepo) UpdateSetting(ctx context.Context, fn func(*db.Setting, *db.S return res, err } -func (r *UserRepo) Update(ctx context.Context, id string, fn func(*db.User, *db.UserUpdateOne) error) (*db.User, error) { +func (r *UserRepo) Update(ctx context.Context, id string, fn func(*db.Tx, *db.User, *db.UserUpdateOne) error) (*db.User, error) { uid, err := uuid.Parse(id) if err != nil { return nil, err @@ -253,10 +255,11 @@ func (r *UserRepo) Update(ctx context.Context, id string, fn func(*db.User, *db. if err != nil { return err } - if err := fn(u, u.Update()); err != nil { + up := tx.User.UpdateOneID(u.ID) + if err = fn(tx, u, up); err != nil { return err } - return u.Update().Exec(ctx) + return up.Exec(ctx) }) return u, err } @@ -371,6 +374,9 @@ func (r *UserRepo) OAuthLogin(ctx context.Context, platform consts.UserPlatform, if err != nil { return nil, errcode.ErrNotInvited.Wrap(err) } + if ui.Edges.User.Status != consts.UserStatusActive { + return nil, errcode.ErrUserLock + } if ui.AvatarURL != req.AvatarURL { if err = entx.WithTx(ctx, r.db, func(tx *db.Tx) error { return r.updateAvatar(ctx, tx, ui, req.AvatarURL) @@ -408,6 +414,9 @@ func (r *UserRepo) SignUpOrIn(ctx context.Context, platform consts.UserPlatform, First(ctx) if err == nil { u = ui.Edges.User + if u.Status != consts.UserStatusActive { + return errcode.ErrUserLock + } if ui.AvatarURL != req.AvatarURL { if err = r.updateAvatar(ctx, tx, ui, req.AvatarURL); err != nil { return err diff --git a/backend/internal/user/usecase/user.go b/backend/internal/user/usecase/user.go index 69b5c4d..12035e7 100644 --- a/backend/internal/user/usecase/user.go +++ b/backend/internal/user/usecase/user.go @@ -19,6 +19,7 @@ import ( "github.com/chaitin/MonkeyCode/backend/config" "github.com/chaitin/MonkeyCode/backend/consts" "github.com/chaitin/MonkeyCode/backend/db" + "github.com/chaitin/MonkeyCode/backend/db/apikey" "github.com/chaitin/MonkeyCode/backend/domain" "github.com/chaitin/MonkeyCode/backend/ent/types" "github.com/chaitin/MonkeyCode/backend/errcode" @@ -206,6 +207,9 @@ func (u *UserUsecase) Login(ctx context.Context, req *domain.LoginReq) (*domain. if err != nil { return nil, errcode.ErrUserNotFound.Wrap(err) } + if user.Status != consts.UserStatusActive { + return nil, errcode.ErrUserLock + } if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil { return nil, errcode.ErrPassword.Wrap(err) } @@ -394,17 +398,34 @@ func (u *UserUsecase) UpdateSetting(ctx context.Context, req *domain.UpdateSetti return cvt.From(s, &domain.Setting{}), nil } +func (u *UserUsecase) cleanApiKey(ctx context.Context, tx *db.Tx, user *db.User) error { + if apikey, err := tx.ApiKey.Query().Where(apikey.UserID(user.ID)).First(ctx); err == nil { + if err := tx.ApiKey.DeleteOneID(apikey.ID).Exec(ctx); err != nil { + return err + } + rkey := "sk-" + apikey.Key + return u.redis.Del(ctx, rkey).Err() + + } + return nil +} + func (u *UserUsecase) Update(ctx context.Context, req *domain.UpdateUserReq) (*domain.User, error) { - user, err := u.repo.Update(ctx, req.ID, func(_ *db.User, u *db.UserUpdateOne) error { + user, err := u.repo.Update(ctx, req.ID, func(tx *db.Tx, old *db.User, up *db.UserUpdateOne) error { if req.Status != nil { - u.SetStatus(*req.Status) + if *req.Status == consts.UserStatusLocked { + if err := u.cleanApiKey(ctx, tx, old); err != nil { + return err + } + } + up.SetStatus(*req.Status) } if req.Password != nil { hash, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost) if err != nil { return err } - u.SetPassword(string(hash)) + up.SetPassword(string(hash)) } return nil }) @@ -613,7 +634,7 @@ func (u *UserUsecase) WithOAuthCallback(ctx context.Context, req *domain.OAuthCa } func (u *UserUsecase) ProfileUpdate(ctx context.Context, req *domain.ProfileUpdateReq) (*domain.User, error) { - user, err := u.repo.Update(ctx, req.UID, func(old *db.User, uuo *db.UserUpdateOne) error { + user, err := u.repo.Update(ctx, req.UID, func(_ *db.Tx, old *db.User, uuo *db.UserUpdateOne) error { if req.Avatar != nil { uuo.SetAvatarURL(*req.Avatar) } diff --git a/ui/api-templates/http-client.ejs b/ui/api-templates/http-client.ejs index 387faba..92efaad 100644 --- a/ui/api-templates/http-client.ejs +++ b/ui/api-templates/http-client.ejs @@ -38,7 +38,7 @@ export enum ContentType { } -const whitePathnameList = ['/user/login', '/login', '/auth']; +const whitePathnameList = ['/user/login', '/login', '/auth', '/invite']; const whiteApiList = ['/api/v1/user/profile', '/api/v1/admin/profile']; const redirectToLogin = () => { @@ -76,7 +76,7 @@ export class HttpClient { }, (err) => { if (err?.response?.status === 401) { - if (whitePathnameList.includes(location.pathname)) { + if(whitePathnameList.find(item => location.pathname.startsWith(item))) { return Promise.reject('尚未登录'); } Message.error('尚未登录') diff --git a/ui/nginx.conf b/ui/nginx.conf index 255c145..43c1e29 100644 --- a/ui/nginx.conf +++ b/ui/nginx.conf @@ -28,7 +28,6 @@ http { server { listen 80; - listen [::]:80; server_name _; proxy_set_header Host $host; diff --git a/ui/package.json b/ui/package.json index a564cf5..a095f10 100644 --- a/ui/package.json +++ b/ui/package.json @@ -14,7 +14,7 @@ "@c-x/ui": "^1.0.9", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", - "@monaco-editor/react": "4.7.0-rc.0", + "@monaco-editor/react": "4.7.0", "@mui/icons-material": "^6.4.12", "@mui/lab": "6.0.0-beta.19", "@mui/material": "^6.4.12", @@ -24,6 +24,7 @@ "decimal.js": "^10.5.0", "echarts": "^5.6.0", "lottie-react": "^2.4.1", + "monaco-editor": "^0.52.2", "react": "^19.1.0", "react-activity-calendar": "^2.7.12", "react-copy-to-clipboard": "^5.1.0", diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 398e44c..e97a228 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -18,8 +18,8 @@ importers: specifier: ^11.14.0 version: 11.14.0(@emotion/react@11.14.0(@types/react@19.1.8)(react@19.1.0))(@types/react@19.1.8)(react@19.1.0) '@monaco-editor/react': - specifier: 4.7.0-rc.0 - version: 4.7.0-rc.0(monaco-editor@0.52.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + specifier: 4.7.0 + version: 4.7.0(monaco-editor@0.52.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@mui/icons-material': specifier: ^6.4.12 version: 6.4.12(@mui/material@6.4.12(@emotion/react@11.14.0(@types/react@19.1.8)(react@19.1.0))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@19.1.8)(react@19.1.0))(@types/react@19.1.8)(react@19.1.0))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@types/react@19.1.8)(react@19.1.0) @@ -47,6 +47,9 @@ importers: lottie-react: specifier: ^2.4.1 version: 2.4.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + monaco-editor: + specifier: ^0.52.2 + version: 0.52.2 react: specifier: ^19.1.0 version: 19.1.0 @@ -560,8 +563,8 @@ packages: '@monaco-editor/loader@1.5.0': resolution: {integrity: sha512-hKoGSM+7aAc7eRTRjpqAZucPmoNOC4UUbknb/VNoTkEIkCPhqV8LfbsgM1webRM7S/z21eHEx9Fkwx8Z/C/+Xw==} - '@monaco-editor/react@4.7.0-rc.0': - resolution: {integrity: sha512-YfjXkDK0bcwS0zo8PXptvQdCQfOPPtzGsAzmIv7PnoUGFdIohsR+NVDyjbajMddF+3cWUm/3q9NzP/DUke9a+w==} + '@monaco-editor/react@4.7.0': + resolution: {integrity: sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==} peerDependencies: monaco-editor: '>= 0.25.0 < 1' react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -2914,7 +2917,7 @@ snapshots: dependencies: state-local: 1.0.7 - '@monaco-editor/react@4.7.0-rc.0(monaco-editor@0.52.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@monaco-editor/react@4.7.0(monaco-editor@0.52.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@monaco-editor/loader': 1.5.0 monaco-editor: 0.52.2 diff --git a/ui/src/api/Admin.ts b/ui/src/api/Admin.ts index fbb2765..9a09b25 100644 --- a/ui/src/api/Admin.ts +++ b/ui/src/api/Admin.ts @@ -15,6 +15,7 @@ import { DeleteDeleteAdminParams, DomainAdminUser, DomainCreateAdminReq, + DomainExportCompletionDataResp, DomainListAdminLoginHistoryResp, DomainListAdminUserResp, DomainLoginReq, @@ -85,6 +86,29 @@ export const deleteDeleteAdmin = ( ...params, }); +/** + * @description 管理员导出所有补全相关数据 + * + * @tags admin + * @name V1AdminExportCompletionDataList + * @summary 导出补全数据 + * @request GET:/api/v1/admin/export-completion-data + * @secure + * @response `200` `DomainExportCompletionDataResp` OK + * @response `401` `WebResp` Unauthorized + * @response `500` `WebResp` Internal Server Error + */ + +export const v1AdminExportCompletionDataList = (params: RequestParams = {}) => + request({ + path: `/api/v1/admin/export-completion-data`, + method: "GET", + secure: true, + type: ContentType.Json, + format: "json", + ...params, + }); + /** * @description 获取管理员用户列表 * diff --git a/ui/src/api/httpClient.ts b/ui/src/api/httpClient.ts index b48c1d8..e1ca65f 100644 --- a/ui/src/api/httpClient.ts +++ b/ui/src/api/httpClient.ts @@ -58,7 +58,7 @@ export enum ContentType { Text = "text/plain", } -const whitePathnameList = ["/user/login", "/login", "/auth"]; +const whitePathnameList = ["/user/login", "/login", "/auth", "/invite"]; const whiteApiList = ["/api/v1/user/profile", "/api/v1/admin/profile"]; const redirectToLogin = () => { @@ -104,7 +104,9 @@ export class HttpClient { }, (err) => { if (err?.response?.status === 401) { - if (whitePathnameList.includes(location.pathname)) { + if ( + whitePathnameList.find((item) => location.pathname.startsWith(item)) + ) { return Promise.reject("尚未登录"); } Message.error("尚未登录"); diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index 5d287a7..66ef746 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -170,6 +170,49 @@ export interface DomainCheckModelReq { type: "llm" | "coder" | "embedding" | "rerank"; } +export interface DomainCompletionData { + /** 代码行数 */ + code_lines?: number; + /** LLM生成的补全代码 */ + completion?: string; + /** 创建时间戳 */ + created_at?: number; + /** 光标位置 {"line": 10, "column": 5} */ + cursor_position?: Record; + /** 输入token数 */ + input_tokens?: number; + /** 用户是否接受补全 */ + is_accept?: boolean; + /** 是否为建议模式 */ + is_suggested?: boolean; + /** 模型ID */ + model_id?: string; + /** 模型名称 */ + model_name?: string; + /** 模型类型 */ + model_type?: string; + /** 输出token数 */ + output_tokens?: number; + /** 编程语言 */ + program_language?: string; + /** 用户输入的提示 */ + prompt?: string; + /** 请求ID */ + request_id?: string; + /** 当前文件原文 */ + source_code?: string; + /** 任务ID */ + task_id?: string; + /** 更新时间戳 */ + updated_at?: number; + /** 用户ID */ + user_id?: string; + /** 用户最终输入的内容 */ + user_input?: string; + /** 工作模式 */ + work_mode?: string; +} + export interface DomainCompletionInfo { content?: string; created_at?: number; @@ -297,6 +340,13 @@ export interface DomainDingtalkOAuthReq { enable?: boolean; } +export interface DomainExportCompletionDataResp { + /** 补全数据列表 */ + data?: DomainCompletionData[]; + /** 总记录数 */ + total_count?: number; +} + export interface DomainGetProviderModelListResp { models?: DomainProviderModelListItem[]; } @@ -516,7 +566,7 @@ export interface DomainReportReq { /** 内容 */ content?: string; /** 光标位置(用于reject action) */ - cursor_position?: number; + cursor_position?: Record; /** task_id or resp_id */ id?: string; /** 当前文件的原文(用于reject action) */ diff --git a/ui/src/components/header/Bread.tsx b/ui/src/components/header/Bread.tsx index fbf2691..fa363ec 100644 --- a/ui/src/components/header/Bread.tsx +++ b/ui/src/components/header/Bread.tsx @@ -8,7 +8,7 @@ const ADMIN_BREADCRUMB_MAP: Record = { chat: { title: '对话记录', to: '/chat' }, completion: { title: '补全记录', to: '/completion' }, model: { title: '模型管理', to: '/model' }, - 'user-management': { title: '成员管理', to: '/user-management' }, + 'member-management': { title: '成员管理', to: '/member-management' }, admin: { title: '管理员', to: '/admin' }, }; diff --git a/ui/src/components/markDown/code.tsx b/ui/src/components/markDown/code.tsx index 648732b..a9e7682 100644 --- a/ui/src/components/markDown/code.tsx +++ b/ui/src/components/markDown/code.tsx @@ -1,7 +1,13 @@ import MonacoEditor from '@monaco-editor/react'; +import { loader } from '@monaco-editor/react'; +import * as monaco from 'monaco-editor'; import { getBaseLanguageId } from '@/utils'; import { useRef, useState, useEffect } from 'react'; +// 配置 Monaco Editor 从本地加载而不是 CDN +// 禁用默认的 CDN 加载 +loader.config({ monaco }); + const CHAR_WIDTH = 8; // 估算每个字符宽度,实际可根据字体调整 const MIN_WIDTH = 200; const MAX_WIDTH = 960; diff --git a/ui/src/components/markDown/diff.tsx b/ui/src/components/markDown/diff.tsx index 073bb68..d7abae2 100644 --- a/ui/src/components/markDown/diff.tsx +++ b/ui/src/components/markDown/diff.tsx @@ -1,5 +1,11 @@ import React, { useRef, useEffect } from 'react'; import { DiffEditor } from '@monaco-editor/react'; +import { loader } from '@monaco-editor/react'; +import * as monaco from 'monaco-editor'; + +// 配置 Monaco Editor 从本地加载而不是 CDN +// 禁用默认的 CDN 加载 +loader.config({ monaco }); interface DiffProps { original: string; diff --git a/ui/src/components/sidebar/index.tsx b/ui/src/components/sidebar/index.tsx index 384d1cb..9d75780 100644 --- a/ui/src/components/sidebar/index.tsx +++ b/ui/src/components/sidebar/index.tsx @@ -51,8 +51,8 @@ const ADMIN_MENUS = [ }, { label: '成员管理', - value: '/user-management', - pathname: 'user-management', + value: '/member-management', + pathname: 'member-management', icon: 'icon-yonghuguanli1', show: true, disabled: false, diff --git a/ui/src/main.tsx b/ui/src/main.tsx index 90f4051..068cc63 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -7,6 +7,35 @@ import '@/assets/fonts/iconfont'; import './index.css'; import '@/assets/styles/markdown.css'; import { ThemeProvider } from '@c-x/ui'; + +// 配置 Monaco Editor 环境 +import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'; +import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker'; +import cssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker'; +import htmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker'; +import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker'; + +window.MonacoEnvironment = { + getWorker: function (workerId: string, label: string) { + switch (label) { + case 'json': + return new jsonWorker(); + case 'css': + case 'scss': + case 'less': + return new cssWorker(); + case 'html': + case 'handlebars': + case 'razor': + return new htmlWorker(); + case 'typescript': + case 'javascript': + return new tsWorker(); + default: + return new editorWorker(); + } + }, +}; import { getUserProfile } from '@/api/UserManage'; import { getAdminProfile } from '@/api/Admin'; import { getMyModelList } from '@/api/Model'; diff --git a/ui/src/pages/completion/completionDetailModal.tsx b/ui/src/pages/completion/completionDetailModal.tsx index a1fa4f8..88d32b6 100644 --- a/ui/src/pages/completion/completionDetailModal.tsx +++ b/ui/src/pages/completion/completionDetailModal.tsx @@ -2,11 +2,17 @@ import Card from '@/components/card'; import { getCompletionInfo } from '@/api/Billing'; import { Modal } from '@c-x/ui'; import MonacoEditor from '@monaco-editor/react'; +import { loader } from '@monaco-editor/react'; +import * as monaco from 'monaco-editor'; import { useEffect, useState, useRef } from 'react'; import { DomainCompletionRecord } from '@/api/types'; import { getBaseLanguageId } from '@/utils'; +// 配置 Monaco Editor 从本地加载而不是 CDN +// 禁用默认的 CDN 加载 +loader.config({ monaco }); + // 删除 <|im_start|> 和 <|im_end|> 及其间内容的工具函数 const removeImBlocks = (text: string) => { // 匹配前后可能的换行符 diff --git a/ui/src/pages/dashboard/components/globalStatistic.tsx b/ui/src/pages/dashboard/components/globalStatistic.tsx index 2af58d2..b2b295d 100644 --- a/ui/src/pages/dashboard/components/globalStatistic.tsx +++ b/ui/src/pages/dashboard/components/globalStatistic.tsx @@ -205,6 +205,7 @@ const GlobalStatistic = ({ timeRange }: { timeRange: TimeRange }) => { `${value.toFixed(2)}%`} extra={ <> {timeRange === '90d' ? '最近 90 天' : '最近 24 小时'}平均采纳率为 diff --git a/ui/src/pages/dashboard/components/lineCharts.tsx b/ui/src/pages/dashboard/components/lineCharts.tsx index ec62a76..83001d6 100644 --- a/ui/src/pages/dashboard/components/lineCharts.tsx +++ b/ui/src/pages/dashboard/components/lineCharts.tsx @@ -9,9 +9,15 @@ interface ILineChartsProps { xData: (string | number)[]; yData: number[]; }; + formatValueTooltip?: (value: number) => string; } -const LineCharts: React.FC = ({ title, data, extra }) => { +const LineCharts: React.FC = ({ + title, + data, + extra, + formatValueTooltip, +}) => { const { xData, yData } = data; const domRef = useRef(null); const echartsRef = useRef(null); @@ -75,7 +81,9 @@ const LineCharts: React.FC = ({ title, data, extra }) => { ) => { if (params[0]) { const { name, seriesName, value } = params[0]; - return `
${name}
${seriesName} ${value}
`; + return `
${name}
${seriesName} ${ + formatValueTooltip ? formatValueTooltip(value) : value + }
`; } return ''; }, diff --git a/ui/src/pages/dashboard/components/memberStatistic.tsx b/ui/src/pages/dashboard/components/memberStatistic.tsx index bfd3948..472f531 100644 --- a/ui/src/pages/dashboard/components/memberStatistic.tsx +++ b/ui/src/pages/dashboard/components/memberStatistic.tsx @@ -202,6 +202,7 @@ const MemberStatistic = ({ `${value.toFixed(2)}%`} extra={ <> {timeRange === '90d' ? '最近 90 天' : '最近 24 小时'}平均采纳率为 diff --git a/ui/src/pages/user-management/index.tsx b/ui/src/pages/memberManage/index.tsx similarity index 100% rename from ui/src/pages/user-management/index.tsx rename to ui/src/pages/memberManage/index.tsx diff --git a/ui/src/pages/user-management/inviteUserModal.tsx b/ui/src/pages/memberManage/inviteUserModal.tsx similarity index 100% rename from ui/src/pages/user-management/inviteUserModal.tsx rename to ui/src/pages/memberManage/inviteUserModal.tsx diff --git a/ui/src/pages/user-management/loginHistory.tsx b/ui/src/pages/memberManage/loginHistory.tsx similarity index 100% rename from ui/src/pages/user-management/loginHistory.tsx rename to ui/src/pages/memberManage/loginHistory.tsx diff --git a/ui/src/pages/user-management/memberManage.tsx b/ui/src/pages/memberManage/memberManage.tsx similarity index 99% rename from ui/src/pages/user-management/memberManage.tsx rename to ui/src/pages/memberManage/memberManage.tsx index 47a1294..bd7ac5a 100644 --- a/ui/src/pages/user-management/memberManage.tsx +++ b/ui/src/pages/memberManage/memberManage.tsx @@ -305,7 +305,7 @@ const MemberManage = () => { {currentUser?.status === ConstsUserStatus.UserStatusActive && ( - 解锁成员 + 锁定成员 )} {currentUser?.status === ConstsUserStatus.UserStatusLocked && ( 解锁成员 diff --git a/ui/src/pages/user-management/thirdPartyLoginSettingModal.tsx b/ui/src/pages/memberManage/thirdPartyLoginSettingModal.tsx similarity index 100% rename from ui/src/pages/user-management/thirdPartyLoginSettingModal.tsx rename to ui/src/pages/memberManage/thirdPartyLoginSettingModal.tsx diff --git a/ui/src/pages/user/completion/completionDetailModal.tsx b/ui/src/pages/user/completion/completionDetailModal.tsx index 72026d4..79fadea 100644 --- a/ui/src/pages/user/completion/completionDetailModal.tsx +++ b/ui/src/pages/user/completion/completionDetailModal.tsx @@ -2,11 +2,17 @@ import Card from '@/components/card'; import { getUserCompletionInfo } from '@/api/UserRecord'; import { Modal } from '@c-x/ui'; import MonacoEditor from '@monaco-editor/react'; +import { loader } from '@monaco-editor/react'; +import * as monaco from 'monaco-editor'; import { useEffect, useState, useRef } from 'react'; import { DomainCompletionRecord } from '@/api/types'; import { getBaseLanguageId } from '@/utils'; +// 配置 Monaco Editor 从本地加载而不是 CDN +// 禁用默认的 CDN 加载 +loader.config({ monaco }); + // 删除 <|im_start|> 和 <|im_end|> 及其间内容的工具函数 const removeImBlocks = (text: string) => { // 匹配前后可能的换行符 diff --git a/ui/src/pages/user/dashboard/components/memberStatistic.tsx b/ui/src/pages/user/dashboard/components/memberStatistic.tsx index 7b8a06c..bdb66dc 100644 --- a/ui/src/pages/user/dashboard/components/memberStatistic.tsx +++ b/ui/src/pages/user/dashboard/components/memberStatistic.tsx @@ -178,6 +178,7 @@ const MemberStatistic = ({ `${value.toFixed(2)}%`} extra={ <> {timeRange === '90d' ? '最近 90 天' : '最近 24 小时'}平均采纳率为 diff --git a/ui/src/router.tsx b/ui/src/router.tsx index 240d2f0..44d237d 100644 --- a/ui/src/router.tsx +++ b/ui/src/router.tsx @@ -32,7 +32,7 @@ const Dashboard = LazyLoadable(lazy(() => import('@/pages/dashboard'))); const Chat = LazyLoadable(lazy(() => import('@/pages/chat'))); const Completion = LazyLoadable(lazy(() => import('@/pages/completion'))); const Model = LazyLoadable(lazy(() => import('@/pages/model'))); -const User = LazyLoadable(lazy(() => import('@/pages/user-management'))); +const MemberManage = LazyLoadable(lazy(() => import('@/pages/memberManage'))); const Admin = LazyLoadable(lazy(() => import('@/pages/admin'))); const Invite = LazyLoadable(lazy(() => import('@/pages/invite'))); const Auth = LazyLoadable(lazy(() => import('@/pages/auth'))); @@ -79,8 +79,8 @@ const routerConfig = [ element: , }, { - path: 'user-management', - element: , + path: 'member-management', + element: , }, { path: 'admin', diff --git a/ui/vite.config.ts b/ui/vite.config.ts index f46e18b..67a221d 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -21,5 +21,33 @@ export default defineConfig(({ mode }) => { host: '0.0.0.0', port: 3300, }, + // 手动配置 Monaco Editor 支持 + define: { + // 禁用 Monaco Editor 从 CDN 加载 + 'process.env.REACT_APP_MONACO_CDN': JSON.stringify('false'), + }, + // 优化构建配置 + // build: { + // rollupOptions: { + // output: { + // manualChunks: { + // 'monaco-editor': ['monaco-editor'], + // 'monaco-react': ['@monaco-editor/react'], + // }, + // }, + // }, + // // 复制 Monaco Editor 的静态资源 + // copyPublicDir: true, + // }, + // 确保 Monaco Editor 被正确优化 + // optimizeDeps: { + // include: ['monaco-editor', '@monaco-editor/react'], + // }, + // // 处理 worker 文件 + // worker: { + // format: 'es', + // }, + // 确保 Monaco Editor workers 能正确加载 + // assetsInclude: ['**/*.worker.js'], }; }); pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy