44406b72db
Moteva-style AI design workbench replica. Home prompt creates a project; the project page provides an infinite canvas with node drag, zoom/pan, a design chat panel, history replay, image asset upload, and project save/regenerate. - Backend: go-zero, DDD layering, sqlc/pgx, optional PostgreSQL; memory or Redis cache; asynq job queue; MinIO/S3/R2/OSS object storage; sky-valley/pi agent runtime adapter. - Frontend: Next.js App Router + Vite artifact build, TypeScript, i18n, shadcn/ui components, auth (OTP/Turnstile/Google/WeChat). - Deploy: Docker Compose and k3s manifests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
583 lines
16 KiB
Go
583 lines
16 KiB
Go
// Code scaffolded by goctl. Safe to edit.
|
|
// goctl 1.10.1
|
|
|
|
package svc
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"img_infinite_canvas/internal/application"
|
|
"img_infinite_canvas/internal/config"
|
|
"img_infinite_canvas/internal/domain/design"
|
|
"img_infinite_canvas/internal/infrastructure/backgroundremoval"
|
|
cacheinfra "img_infinite_canvas/internal/infrastructure/cache"
|
|
"img_infinite_canvas/internal/infrastructure/memory"
|
|
"img_infinite_canvas/internal/infrastructure/objectrecognition"
|
|
"img_infinite_canvas/internal/infrastructure/objectstorage"
|
|
"img_infinite_canvas/internal/infrastructure/piagent"
|
|
"img_infinite_canvas/internal/infrastructure/postgres"
|
|
"img_infinite_canvas/internal/infrastructure/textextraction"
|
|
"img_infinite_canvas/internal/infrastructure/websearch"
|
|
authmodule "img_infinite_canvas/internal/modules/auth"
|
|
jobmodule "img_infinite_canvas/internal/modules/job"
|
|
realtimemodule "img_infinite_canvas/internal/modules/realtime"
|
|
|
|
"github.com/zeromicro/go-zero/core/logx"
|
|
)
|
|
|
|
type ServiceContext struct {
|
|
Config config.Config
|
|
DesignService *application.DesignService
|
|
AuthService *authmodule.Service
|
|
RealtimeBus realtimemodule.Bus
|
|
jobQueue design.JobQueue
|
|
jobWorker backgroundWorker
|
|
outboxWorker backgroundWorker
|
|
}
|
|
|
|
type backgroundWorker interface {
|
|
Start() error
|
|
Run() error
|
|
Shutdown()
|
|
}
|
|
|
|
func NewServiceContext(c config.Config) *ServiceContext {
|
|
repo := newProjectRepository(c)
|
|
cacheStore := newCacheStore(c)
|
|
authService := newAuthService(c, cacheStore)
|
|
repo = withCache(c, repo, cacheStore)
|
|
realtimeBus := newRealtimeBus(c)
|
|
repo = withRealtime(repo, realtimeBus)
|
|
creativeAgent := newCreativeAgent(c)
|
|
agentRunner := newAgentRunner(c)
|
|
assets := newAssetStorage(c)
|
|
researcher := newResearcher(c)
|
|
textExtractor := newTextExtractor(c)
|
|
service := application.NewDesignService(repo, agentRunner, assets, researcher, textExtractor, creativeAgent)
|
|
service.SetBackgroundRemover(newBackgroundRemover(c))
|
|
service.SetObjectRecognizer(newObjectRecognizer(c))
|
|
if mockupAnalyzer, ok := creativeAgent.(design.MockupModelAnalyzer); ok {
|
|
service.SetMockupModelAnalyzer(mockupAnalyzer)
|
|
}
|
|
service.SetTextExtractionCache(cacheStore, textExtractionCacheTTL(c))
|
|
queue, worker := newJobQueue(c, service)
|
|
service.SetJobQueue(queue)
|
|
outboxWorker := newOutboxPublisher(c, realtimeBus)
|
|
if err := service.EnsureSeedData(context.Background()); err != nil {
|
|
logx.Must(err)
|
|
}
|
|
|
|
return &ServiceContext{
|
|
Config: c,
|
|
DesignService: service,
|
|
AuthService: authService,
|
|
RealtimeBus: realtimeBus,
|
|
jobQueue: queue,
|
|
jobWorker: worker,
|
|
outboxWorker: outboxWorker,
|
|
}
|
|
}
|
|
|
|
func (s *ServiceContext) StartJobWorker() error {
|
|
if s == nil || s.jobWorker == nil {
|
|
return nil
|
|
}
|
|
return s.jobWorker.Start()
|
|
}
|
|
|
|
func (s *ServiceContext) HasJobWorker() bool {
|
|
return s != nil && s.jobWorker != nil
|
|
}
|
|
|
|
func (s *ServiceContext) RunJobWorker() error {
|
|
if s == nil || s.jobWorker == nil {
|
|
return nil
|
|
}
|
|
return s.jobWorker.Run()
|
|
}
|
|
|
|
func (s *ServiceContext) ShutdownJobWorker() {
|
|
if s == nil || s.jobWorker == nil {
|
|
return
|
|
}
|
|
s.jobWorker.Shutdown()
|
|
}
|
|
|
|
func (s *ServiceContext) StartOutboxPublisher() error {
|
|
if s == nil || s.outboxWorker == nil {
|
|
return nil
|
|
}
|
|
return s.outboxWorker.Start()
|
|
}
|
|
|
|
func (s *ServiceContext) ShutdownOutboxPublisher() {
|
|
if s == nil || s.outboxWorker == nil {
|
|
return
|
|
}
|
|
s.outboxWorker.Shutdown()
|
|
}
|
|
|
|
func (s *ServiceContext) RealtimeFallbackPoll() time.Duration {
|
|
if s == nil {
|
|
return 10 * time.Second
|
|
}
|
|
interval := time.Duration(s.Config.Realtime.FallbackPollMillis) * time.Millisecond
|
|
if interval <= 0 {
|
|
return 10 * time.Second
|
|
}
|
|
return interval
|
|
}
|
|
|
|
func (s *ServiceContext) Close() {
|
|
s.ShutdownJobWorker()
|
|
s.ShutdownOutboxPublisher()
|
|
if closer, ok := s.jobQueue.(interface{ Close() error }); ok {
|
|
if err := closer.Close(); err != nil {
|
|
logx.Errorf("close job queue failed: %v", err)
|
|
}
|
|
}
|
|
if s.RealtimeBus != nil {
|
|
if err := s.RealtimeBus.Close(); err != nil {
|
|
logx.Errorf("close realtime bus failed: %v", err)
|
|
}
|
|
}
|
|
if s.AuthService != nil {
|
|
if err := s.AuthService.Close(); err != nil {
|
|
logx.Errorf("close auth service failed: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func newAuthService(c config.Config, cacheStore cacheinfra.Store) *authmodule.Service {
|
|
store := newAuthStore(c)
|
|
opts := c.Auth
|
|
codeTTL := time.Duration(opts.CodeTTLSeconds) * time.Second
|
|
codeStore := authmodule.NewCacheCodeStore(cacheStore, codeTTL)
|
|
humanVerifier := newAuthHumanVerifier(c)
|
|
return authmodule.NewServiceWithCodeStoreEmailAndHumanVerifier(store, codeStore, newAuthEmailSender(c), humanVerifier, authmodule.Config{
|
|
Region: opts.Region,
|
|
RegionMode: opts.RegionMode,
|
|
TokenSecret: opts.TokenSecret,
|
|
TokenTTL: time.Duration(opts.TokenTTLSeconds) * time.Second,
|
|
RefreshTokenTTL: time.Duration(opts.RefreshTTLSeconds) * time.Second,
|
|
CodeTTL: time.Duration(opts.CodeTTLSeconds) * time.Second,
|
|
DevReturnCode: opts.DevReturnCode,
|
|
EmailFromName: opts.Email.FromName,
|
|
EmailFromEmail: opts.Email.FromEmail,
|
|
BrandName: opts.Email.BrandName,
|
|
TurnstileRequired: opts.Turnstile.Enabled,
|
|
TurnstileSiteKey: opts.Turnstile.SiteKey,
|
|
GoogleClientID: opts.Google.ClientID,
|
|
GoogleTokenInfo: opts.Google.TokenInfoURL,
|
|
GoogleTimeout: time.Duration(opts.Google.TimeoutSeconds) * time.Second,
|
|
WechatAppID: opts.Wechat.AppID,
|
|
WechatAppSecret: opts.Wechat.AppSecret,
|
|
WechatRedirect: opts.Wechat.RedirectURI,
|
|
WechatScope: opts.Wechat.Scope,
|
|
WechatAuthURL: opts.Wechat.AuthURL,
|
|
WechatTokenURL: opts.Wechat.TokenURL,
|
|
WechatTimeout: time.Duration(opts.Wechat.TimeoutSeconds) * time.Second,
|
|
})
|
|
}
|
|
|
|
func newAuthHumanVerifier(c config.Config) authmodule.HumanVerifier {
|
|
opts := c.Auth.Turnstile
|
|
if !opts.Enabled {
|
|
return nil
|
|
}
|
|
secretKey := strings.TrimSpace(opts.SecretKey)
|
|
if envName := strings.TrimSpace(opts.SecretKeyEnv); envName != "" {
|
|
if envValue := strings.TrimSpace(os.Getenv(envName)); envValue != "" {
|
|
secretKey = envValue
|
|
}
|
|
}
|
|
verifier, err := authmodule.NewTurnstileVerifier(authmodule.TurnstileVerifierOptions{
|
|
SecretKey: secretKey,
|
|
SiteVerifyURL: opts.SiteVerifyURL,
|
|
Timeout: time.Duration(opts.TimeoutSeconds) * time.Second,
|
|
})
|
|
if err != nil {
|
|
logx.Must(err)
|
|
}
|
|
return verifier
|
|
}
|
|
|
|
func newAuthEmailSender(c config.Config) authmodule.EmailSender {
|
|
opts := c.Auth.Email
|
|
switch strings.ToLower(strings.TrimSpace(opts.Driver)) {
|
|
case "", "none", "disabled":
|
|
return nil
|
|
case "smtp":
|
|
sender, err := authmodule.NewSMTPSender(authmodule.SMTPOptions{
|
|
Host: opts.Host,
|
|
Port: opts.Port,
|
|
Username: opts.Username,
|
|
Password: opts.Password,
|
|
UseTLS: opts.UseTLS,
|
|
StartTLS: opts.StartTLS,
|
|
InsecureSkipVerify: opts.InsecureSkipVerify,
|
|
Timeout: time.Duration(opts.TimeoutSeconds) * time.Second,
|
|
})
|
|
if err != nil {
|
|
logx.Must(err)
|
|
}
|
|
return sender
|
|
default:
|
|
logx.Must(fmt.Errorf("unsupported auth email driver: %s", opts.Driver))
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func newAuthStore(c config.Config) authmodule.Store {
|
|
switch c.Storage.Driver {
|
|
case "", "memory":
|
|
return authmodule.NewMemoryStore()
|
|
case "postgres":
|
|
if c.Storage.DataSource == "" {
|
|
logx.Must(application.ErrMissingDataSource)
|
|
}
|
|
store, err := authmodule.NewPostgresStore(context.Background(), c.Storage.DataSource)
|
|
if err != nil {
|
|
logx.Must(err)
|
|
}
|
|
return store
|
|
default:
|
|
logx.Must(application.ErrUnsupportedStorage)
|
|
return authmodule.NewMemoryStore()
|
|
}
|
|
}
|
|
|
|
func newResearcher(c config.Config) design.Researcher {
|
|
opts := c.Agent.Research
|
|
switch opts.Driver {
|
|
case "", "duckduckgo":
|
|
return websearch.NewDuckDuckGoResearcher(websearch.DuckDuckGoOptions{
|
|
TimeoutSeconds: opts.TimeoutSeconds,
|
|
MaxResults: opts.MaxResults,
|
|
})
|
|
case "none", "disabled":
|
|
return nil
|
|
default:
|
|
logx.Must(application.ErrUnsupportedResearch)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func newAgentRunner(c config.Config) design.AgentRunner {
|
|
base := application.NewDeterministicAgent()
|
|
creativeAgent := newCreativeAgent(c)
|
|
switch c.Agent.Driver {
|
|
case "", "pi":
|
|
return piagent.NewDesignAgent(base, newImageGenerator(c), creativeAgent)
|
|
case "deterministic":
|
|
return base
|
|
default:
|
|
logx.Must(application.ErrUnsupportedAgent)
|
|
return base
|
|
}
|
|
}
|
|
|
|
func newCreativeAgent(c config.Config) design.CreativeAgent {
|
|
opts := c.Agent.Planner
|
|
switch opts.Driver {
|
|
case "", "vision":
|
|
apiKey := resolveImageAPIKey(c.Agent.Image)
|
|
return piagent.NewCreativeAgent(piagent.CreativeAgentOptions{
|
|
BaseURL: c.Agent.Image.BaseURL,
|
|
APIKey: apiKey,
|
|
Model: opts.Model,
|
|
TimeoutSeconds: opts.TimeoutSeconds,
|
|
})
|
|
case "none", "disabled":
|
|
return nil
|
|
default:
|
|
logx.Must(application.ErrUnsupportedAgent)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func newImageGenerator(c config.Config) piagent.ImageGenerator {
|
|
opts := c.Agent.Image
|
|
apiKey := resolveImageAPIKey(opts)
|
|
client := piagent.NewImageClient(piagent.ImageClientOptions{
|
|
BaseURL: opts.BaseURL,
|
|
APIKey: apiKey,
|
|
TimeoutSeconds: opts.TimeoutSeconds,
|
|
})
|
|
if client == nil {
|
|
return nil
|
|
}
|
|
return client
|
|
}
|
|
|
|
func newTextExtractor(c config.Config) design.TextExtractor {
|
|
opts := c.Agent.Text
|
|
switch opts.Driver {
|
|
case "", "vision":
|
|
apiKey := resolveImageAPIKey(c.Agent.Image)
|
|
return textextraction.NewTextExtractionClient(textextraction.TextExtractionClientOptions{
|
|
BaseURL: c.Agent.Image.BaseURL,
|
|
APIKey: apiKey,
|
|
Model: opts.Model,
|
|
TimeoutSeconds: opts.TimeoutSeconds,
|
|
})
|
|
case "none", "disabled":
|
|
return nil
|
|
default:
|
|
logx.Must(application.ErrUnsupportedAgent)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func newObjectRecognizer(c config.Config) design.ObjectRecognizer {
|
|
opts := c.Agent.Object
|
|
switch opts.Driver {
|
|
case "", "vision":
|
|
apiKey := resolveImageAPIKey(c.Agent.Image)
|
|
return objectrecognition.NewClient(objectrecognition.ClientOptions{
|
|
BaseURL: c.Agent.Image.BaseURL,
|
|
APIKey: apiKey,
|
|
Model: opts.Model,
|
|
TimeoutSeconds: opts.TimeoutSeconds,
|
|
})
|
|
case "none", "disabled":
|
|
return nil
|
|
default:
|
|
logx.Must(application.ErrUnsupportedAgent)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func newBackgroundRemover(c config.Config) design.BackgroundRemover {
|
|
opts := c.Agent.Background
|
|
switch opts.Driver {
|
|
case "", "onnxruntime":
|
|
return backgroundremoval.NewONNXRuntimeRemover(backgroundremoval.ONNXRuntimeOptions{
|
|
ModelPath: opts.ModelPath,
|
|
SharedLibraryPath: opts.SharedLibraryPath,
|
|
ExecutionProvider: opts.ExecutionProvider,
|
|
InputSize: opts.InputSize,
|
|
TimeoutSeconds: opts.TimeoutSeconds,
|
|
InputName: opts.InputName,
|
|
OutputName: opts.OutputName,
|
|
})
|
|
case "none", "disabled":
|
|
return nil
|
|
default:
|
|
logx.Must(application.ErrUnsupportedAgent)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func resolveImageAPIKey(opts config.ImageGenerationConfig) string {
|
|
if opts.APIKeyEnv != "" {
|
|
if value := strings.TrimSpace(os.Getenv(opts.APIKeyEnv)); value != "" {
|
|
return value
|
|
}
|
|
}
|
|
return strings.TrimSpace(opts.APIKey)
|
|
}
|
|
|
|
func newAssetStorage(c config.Config) design.AssetStorage {
|
|
opts := c.ObjectStorage
|
|
switch opts.Driver {
|
|
case "", "memory":
|
|
return objectstorage.NewMemoryStorage(opts.Bucket, opts.PublicBase, opts.ExpiresIn)
|
|
case "minio", "s3", "aws", "r2":
|
|
storage, err := objectstorage.NewS3Storage(context.Background(), objectstorage.S3Options{
|
|
Driver: opts.Driver,
|
|
Bucket: opts.Bucket,
|
|
Endpoint: opts.Endpoint,
|
|
Region: opts.Region,
|
|
AccessKey: opts.AccessKey,
|
|
SecretKey: opts.SecretKey,
|
|
PublicBase: opts.PublicBase,
|
|
UseSSL: opts.UseSSL,
|
|
ExpiresIn: opts.ExpiresIn,
|
|
})
|
|
if err != nil {
|
|
logx.Must(err)
|
|
}
|
|
return storage
|
|
case "aliyun":
|
|
storage, err := objectstorage.NewAliyunStorage(context.Background(), objectstorage.AliyunOptions{
|
|
Bucket: opts.Bucket,
|
|
Endpoint: opts.Endpoint,
|
|
AccessKey: opts.AccessKey,
|
|
SecretKey: opts.SecretKey,
|
|
PublicBase: opts.PublicBase,
|
|
ExpiresIn: opts.ExpiresIn,
|
|
})
|
|
if err != nil {
|
|
logx.Must(err)
|
|
}
|
|
return storage
|
|
default:
|
|
logx.Must(application.ErrUnsupportedObjectStorage)
|
|
return objectstorage.NewMemoryStorage(opts.Bucket, opts.PublicBase, opts.ExpiresIn)
|
|
}
|
|
}
|
|
|
|
func withCache(c config.Config, repo design.Repository, store cacheinfra.Store) design.Repository {
|
|
ttl := time.Duration(c.Cache.TTLSeconds) * time.Second
|
|
if ttl <= 0 {
|
|
ttl = 5 * time.Minute
|
|
}
|
|
return cacheinfra.NewProjectRepository(repo, store, ttl)
|
|
}
|
|
|
|
func withRealtime(repo design.Repository, bus realtimemodule.Bus) design.Repository {
|
|
if bus == nil {
|
|
return repo
|
|
}
|
|
return realtimemodule.NewProjectRepository(repo, bus)
|
|
}
|
|
|
|
func textExtractionCacheTTL(c config.Config) time.Duration {
|
|
ttl := time.Duration(c.Agent.Text.CacheTTLSeconds) * time.Second
|
|
if ttl <= 0 {
|
|
return 10 * time.Minute
|
|
}
|
|
return ttl
|
|
}
|
|
|
|
func newCacheStore(c config.Config) cacheinfra.Store {
|
|
switch c.Cache.Driver {
|
|
case "", "memory":
|
|
return cacheinfra.NewMemoryStore()
|
|
case "redis":
|
|
store, err := cacheinfra.NewRedisStore(context.Background(), cacheinfra.RedisOptions{
|
|
Addr: c.Cache.Addr,
|
|
Password: c.Cache.Password,
|
|
DB: c.Cache.DB,
|
|
})
|
|
if err != nil {
|
|
logx.Must(err)
|
|
}
|
|
return store
|
|
default:
|
|
logx.Must(cacheinfra.ErrUnsupportedDriver)
|
|
return cacheinfra.NewMemoryStore()
|
|
}
|
|
}
|
|
|
|
func newRealtimeBus(c config.Config) realtimemodule.Bus {
|
|
opts := c.Realtime
|
|
driver := strings.ToLower(strings.TrimSpace(opts.Driver))
|
|
switch driver {
|
|
case "", "memory":
|
|
return realtimemodule.NewMemoryBus()
|
|
case "redis":
|
|
if strings.TrimSpace(opts.Addr) == "" {
|
|
opts.Addr = c.Cache.Addr
|
|
}
|
|
if strings.TrimSpace(opts.Password) == "" {
|
|
opts.Password = c.Cache.Password
|
|
}
|
|
bus, err := realtimemodule.NewRedisBus(context.Background(), realtimemodule.RedisOptions{
|
|
Addr: opts.Addr,
|
|
Password: opts.Password,
|
|
DB: opts.DB,
|
|
Prefix: opts.ChannelPrefix,
|
|
})
|
|
if err != nil {
|
|
logx.Must(err)
|
|
}
|
|
return bus
|
|
case "none", "disabled":
|
|
return nil
|
|
default:
|
|
logx.Must(fmt.Errorf("unsupported realtime driver: %s", opts.Driver))
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func newOutboxPublisher(c config.Config, bus realtimemodule.Bus) backgroundWorker {
|
|
opts := c.Outbox
|
|
driver := strings.ToLower(strings.TrimSpace(opts.Driver))
|
|
switch driver {
|
|
case "", "memory", "none", "disabled":
|
|
return nil
|
|
case "postgres":
|
|
if strings.TrimSpace(opts.DataSource) == "" {
|
|
opts.DataSource = c.Storage.DataSource
|
|
}
|
|
publisher, err := realtimemodule.NewOutboxPublisher(context.Background(), realtimemodule.OutboxOptions{
|
|
DataSource: opts.DataSource,
|
|
IntervalMillis: opts.PollMillis,
|
|
BatchSize: opts.BatchSize,
|
|
}, bus)
|
|
if err != nil {
|
|
logx.Must(err)
|
|
}
|
|
return publisher
|
|
default:
|
|
logx.Must(fmt.Errorf("unsupported outbox driver: %s", opts.Driver))
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func newJobQueue(c config.Config, processor design.JobProcessor) (design.JobQueue, backgroundWorker) {
|
|
opts := c.JobQueue
|
|
driver := strings.ToLower(strings.TrimSpace(opts.Driver))
|
|
switch driver {
|
|
case "", "memory", "inline", "none", "disabled":
|
|
return nil, nil
|
|
case "asynq", "redis":
|
|
if strings.TrimSpace(opts.Addr) == "" {
|
|
opts.Addr = c.Cache.Addr
|
|
}
|
|
if strings.TrimSpace(opts.Password) == "" {
|
|
opts.Password = c.Cache.Password
|
|
}
|
|
if strings.TrimSpace(opts.Addr) == "" {
|
|
opts.Addr = "localhost:6379"
|
|
}
|
|
asynqOpts := jobmodule.AsynqOptions{
|
|
Addr: opts.Addr,
|
|
Password: opts.Password,
|
|
DB: opts.DB,
|
|
Queue: opts.Queue,
|
|
Concurrency: opts.Concurrency,
|
|
MaxRetry: opts.MaxRetry,
|
|
TimeoutSeconds: opts.TimeoutSeconds,
|
|
ShutdownTimeoutSeconds: opts.ShutdownTimeoutSeconds,
|
|
}
|
|
queue, err := jobmodule.NewAsynqQueue(context.Background(), asynqOpts)
|
|
if err != nil {
|
|
logx.Must(err)
|
|
}
|
|
worker, err := jobmodule.NewAsynqWorker(asynqOpts, processor)
|
|
if err != nil {
|
|
logx.Must(err)
|
|
}
|
|
return queue, worker
|
|
default:
|
|
logx.Must(fmt.Errorf("unsupported job queue driver: %s", opts.Driver))
|
|
return nil, nil
|
|
}
|
|
}
|
|
|
|
func newProjectRepository(c config.Config) design.Repository {
|
|
switch c.Storage.Driver {
|
|
case "", "memory":
|
|
return memory.NewProjectRepository()
|
|
case "postgres":
|
|
if c.Storage.DataSource == "" {
|
|
logx.Must(application.ErrMissingDataSource)
|
|
}
|
|
repo, err := postgres.NewProjectRepository(context.Background(), c.Storage.DataSource)
|
|
if err != nil {
|
|
logx.Must(err)
|
|
}
|
|
return repo
|
|
default:
|
|
logx.Must(application.ErrUnsupportedStorage)
|
|
return memory.NewProjectRepository()
|
|
}
|
|
}
|