Files
root 44406b72db Initial commit: img-infinite-canvas AI design workbench MVP
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>
2026-07-07 23:15:37 +08:00

597 lines
19 KiB
Go

package logic
import (
"net/url"
"time"
"img_infinite_canvas/internal/domain/design"
"img_infinite_canvas/internal/types"
)
func toProjectResponse(project design.Project) *types.ProjectResponse {
return &types.ProjectResponse{Project: toAPIProject(project)}
}
func toGeneratorTaskResponse(task design.GeneratorTask) *types.GeneratorTaskResponse {
data := types.GeneratorTaskData{
GeneratorTaskId: task.GeneratorTaskID,
GeneratorName: task.GeneratorName,
Status: task.Status,
OriginalInputArgs: toAPIGeneratorTaskInputArgs(task.OriginalInputArgs),
Artifacts: toAPIGeneratorTaskArtifacts(task.Artifacts),
}
if task.Status != "completed" {
queueInfo := toAPIGeneratorTaskQueueInfo(task.QueueInfo)
data.QueueInfo = &queueInfo
}
return &types.GeneratorTaskResponse{
Code: 0,
Message: "success",
Data: data,
}
}
func toAPIGeneratorTaskInputArgs(input design.GeneratorTaskInputArgs) types.GeneratorTaskInputArgs {
return types.GeneratorTaskInputArgs{
AspectRatio: input.AspectRatio,
Image: append([]string(nil), input.Image...),
ImageUrl: input.ImageURL,
Prompt: input.Prompt,
Resolution: input.Resolution,
SrcBox: toAPINormalizedBoxPtr(input.SrcBox),
DstBox: toAPINormalizedBoxPtr(input.DstBox),
}
}
func toDomainGeneratorTaskInputArgs(input types.GeneratorTaskInputArgs) design.GeneratorTaskInputArgs {
return design.GeneratorTaskInputArgs{
AspectRatio: input.AspectRatio,
Image: append([]string(nil), input.Image...),
ImageURL: input.ImageUrl,
Prompt: input.Prompt,
Resolution: input.Resolution,
SrcBox: toDomainNormalizedBoxPtr(input.SrcBox),
DstBox: toDomainNormalizedBoxPtr(input.DstBox),
}
}
func toDomainNormalizedBox(box types.NormalizedBox) design.NormalizedBox {
return design.NormalizedBox{
X: box.X,
Y: box.Y,
Width: box.Width,
Height: box.Height,
}
}
func toDomainNormalizedBoxPtr(box *types.NormalizedBox) *design.NormalizedBox {
if box == nil {
return nil
}
next := toDomainNormalizedBox(*box)
return &next
}
func toAPINormalizedBox(box design.NormalizedBox) types.NormalizedBox {
return types.NormalizedBox{
X: box.X,
Y: box.Y,
Width: box.Width,
Height: box.Height,
}
}
func toAPINormalizedBoxPtr(box *design.NormalizedBox) *types.NormalizedBox {
if box == nil {
return nil
}
next := toAPINormalizedBox(*box)
return &next
}
func toRecognizeObjectResponse(result design.ObjectRecognition) *types.RecognizeObjectResponse {
return &types.RecognizeObjectResponse{
Id: result.ID,
Label: result.Label,
Confidence: result.Confidence,
BBox: toAPINormalizedBox(result.BBox),
}
}
func toAPIGeneratorTaskQueueInfo(queue design.GeneratorTaskQueueInfo) types.GeneratorTaskQueueInfo {
return types.GeneratorTaskQueueInfo{
InQueue: queue.InQueue,
Position: queue.Position,
TotalQueueCount: queue.TotalQueueCount,
EstimatedEndTime: queue.EstimatedEndTime,
RemainingTimeSeconds: queue.RemainingTimeSeconds,
DisableUnlimitedPrice: types.GeneratorTaskDisableUnlimitedPrice{
Balance: queue.DisableUnlimitedPrice.Balance,
Price: queue.DisableUnlimitedPrice.Price,
DisableUnlimited: queue.DisableUnlimitedPrice.DisableUnlimited,
PriceDetail: types.GeneratorTaskPriceDetail{
BasePrice: queue.DisableUnlimitedPrice.PriceDetail.BasePrice,
ExtInfoForUnitCount: queue.DisableUnlimitedPrice.PriceDetail.ExtInfoForUnitCount,
GeneratorName: queue.DisableUnlimitedPrice.PriceDetail.GeneratorName,
InputArgs: toAPIGeneratorTaskInputArgs(queue.DisableUnlimitedPrice.PriceDetail.InputArgs),
OutputArgs: queue.DisableUnlimitedPrice.PriceDetail.OutputArgs,
SearchKey: queue.DisableUnlimitedPrice.PriceDetail.SearchKey,
TimeVariantPeriod: queue.DisableUnlimitedPrice.PriceDetail.TimeVariantPeriod,
TotalPrice: queue.DisableUnlimitedPrice.PriceDetail.TotalPrice,
UnitCount: queue.DisableUnlimitedPrice.PriceDetail.UnitCount,
UnitName: queue.DisableUnlimitedPrice.PriceDetail.UnitName,
UnitPrice: queue.DisableUnlimitedPrice.PriceDetail.UnitPrice,
},
},
}
}
func toAPIGeneratorTaskArtifacts(items []design.GeneratorTaskArtifact) []types.GeneratorTaskArtifact {
resp := make([]types.GeneratorTaskArtifact, 0, len(items))
for _, item := range items {
resp = append(resp, types.GeneratorTaskArtifact{
Type: item.Type,
Content: item.Content,
Metadata: types.GeneratorTaskArtifactMetadata{
ArtifactId: item.Metadata.ArtifactID,
Format: item.Metadata.Format,
Height: item.Metadata.Height,
Label: item.Metadata.Label,
NodeId: item.Metadata.NodeID,
NodeName: item.Metadata.NodeName,
SizeBytes: item.Metadata.SizeBytes,
Width: item.Metadata.Width,
},
})
}
return resp
}
func toExtractNodeTextResponse(extraction design.TextExtraction) *types.ExtractNodeTextResponse {
items := make([]types.ExtractedTextLayer, 0, len(extraction.Layers))
for _, item := range extraction.Layers {
originalText := item.OriginalText
if originalText == "" {
originalText = item.Text
}
items = append(items, types.ExtractedTextLayer{
Text: item.Text,
OriginalText: originalText,
X: item.X,
Y: item.Y,
Width: item.Width,
Height: item.Height,
FontSize: item.FontSize,
FontFamily: item.FontFamily,
FontWeight: item.FontWeight,
Color: item.Color,
TextAlign: item.TextAlign,
LineHeight: item.LineHeight,
LetterSpacing: item.LetterSpacing,
StrokeColor: item.StrokeColor,
StrokeWidth: item.StrokeWidth,
Opacity: item.Opacity,
Rotation: item.Rotation,
Confidence: item.Confidence,
})
}
return &types.ExtractNodeTextResponse{Items: items}
}
func toAPIProject(project design.Project) types.Project {
return types.Project{
Id: project.ID,
UserId: project.UserID,
Title: project.Title,
Brief: project.Brief,
Status: string(project.Status),
Thumbnail: project.Thumbnail,
UpdatedAt: formatTime(project.UpdatedAt),
Version: project.Version,
SnapshotId: project.SnapshotID,
Canvas: project.Canvas,
LastThreadId: project.LastThreadID,
Viewport: toAPIView(project.Viewport),
Nodes: toAPINodes(project.Nodes),
Connections: toAPIConnections(project.Connections),
Messages: toAPIMessages(project.Messages),
}
}
func toAPISummaries(items []design.ProjectSummary) []types.ProjectSummary {
resp := make([]types.ProjectSummary, 0, len(items))
for _, item := range items {
resp = append(resp, types.ProjectSummary{
Id: item.ID,
UserId: item.UserID,
Title: item.Title,
Brief: item.Brief,
Status: string(item.Status),
Thumbnail: item.Thumbnail,
UpdatedAt: formatTime(item.UpdatedAt),
})
}
return resp
}
func toAPIQuickActions(items []design.QuickAction) []types.QuickAction {
resp := make([]types.QuickAction, 0, len(items))
for _, item := range items {
resp = append(resp, types.QuickAction{
Id: item.ID,
Label: item.Label,
Prompt: item.Prompt,
Icon: item.Icon,
})
}
return resp
}
func toAPIInspirations(items []design.InspirationItem) []types.InspirationItem {
resp := make([]types.InspirationItem, 0, len(items))
for _, item := range items {
resp = append(resp, types.InspirationItem{
Id: item.ID,
Title: item.Title,
Category: item.Category,
Description: item.Description,
Accent: item.Accent,
})
}
return resp
}
func toAPINodes(items []design.Node) []types.CanvasNode {
resp := make([]types.CanvasNode, 0, len(items))
for _, item := range items {
resp = append(resp, types.CanvasNode{
Id: item.ID,
Type: string(item.Type),
Title: item.Title,
X: item.X,
Y: item.Y,
Width: item.Width,
Height: item.Height,
Content: item.Content,
Tone: item.Tone,
Status: item.Status,
ParentId: item.ParentID,
LayerRole: item.LayerRole,
FontSize: item.FontSize,
FontFamily: item.FontFamily,
FontWeight: item.FontWeight,
Color: item.Color,
TextAlign: item.TextAlign,
LineHeight: item.LineHeight,
LetterSpacing: item.LetterSpacing,
TextDecoration: item.TextDecoration,
TextTransform: item.TextTransform,
Opacity: item.Opacity,
FillColor: item.FillColor,
StrokeColor: item.StrokeColor,
StrokeWidth: item.StrokeWidth,
StrokeStyle: item.StrokeStyle,
FlipX: item.FlipX,
FlipY: item.FlipY,
Rotation: item.Rotation,
ImageAdjustments: item.ImageAdjustments,
MockupSurface: toAPIMockupSurface(item.MockupSurface),
MockupModel: toAPIMockupModel(item.MockupModel),
MockupSourceContent: item.MockupSourceContent,
})
}
return resp
}
func toDomainNodes(items []types.CanvasNode) []design.Node {
resp := make([]design.Node, 0, len(items))
for _, item := range items {
resp = append(resp, design.Node{
ID: item.Id,
Type: design.NodeType(item.Type),
Title: item.Title,
X: item.X,
Y: item.Y,
Width: item.Width,
Height: item.Height,
Content: item.Content,
Tone: item.Tone,
Status: item.Status,
ParentID: item.ParentId,
LayerRole: item.LayerRole,
FontSize: item.FontSize,
FontFamily: item.FontFamily,
FontWeight: item.FontWeight,
Color: item.Color,
TextAlign: item.TextAlign,
LineHeight: item.LineHeight,
LetterSpacing: item.LetterSpacing,
TextDecoration: item.TextDecoration,
TextTransform: item.TextTransform,
Opacity: item.Opacity,
FillColor: item.FillColor,
StrokeColor: item.StrokeColor,
StrokeWidth: item.StrokeWidth,
StrokeStyle: item.StrokeStyle,
FlipX: item.FlipX,
FlipY: item.FlipY,
Rotation: item.Rotation,
ImageAdjustments: item.ImageAdjustments,
MockupSurface: toDomainMockupSurface(item.MockupSurface),
MockupModel: toDomainMockupModel(item.MockupModel),
MockupSourceContent: item.MockupSourceContent,
})
}
return resp
}
func toAPIMockupSurface(surface design.MockupSurface) types.MockupSurface {
return types.MockupSurface{
TargetId: surface.TargetID,
Polygon: toAPIMockupPoints(surface.Polygon),
Mesh: types.MockupMesh{
Columns: int64(surface.Mesh.Columns),
Rows: int64(surface.Mesh.Rows),
Points: toAPIMockupPoints(surface.Mesh.Points),
},
}
}
func toDomainMockupSurface(surface types.MockupSurface) design.MockupSurface {
return design.MockupSurface{
TargetID: surface.TargetId,
Polygon: toDomainMockupPoints(surface.Polygon),
Mesh: design.MockupMesh{
Columns: int(surface.Mesh.Columns),
Rows: int(surface.Mesh.Rows),
Points: toDomainMockupPoints(surface.Mesh.Points),
},
}
}
func toAPIMockupPlacement(placement design.MockupPlacement) types.MockupPlacement {
return types.MockupPlacement{
X: placement.X,
Y: placement.Y,
Width: placement.Width,
Height: placement.Height,
}
}
func toAPIMockupModel(model design.MockupModel) types.MockupModel {
return types.MockupModel{
Version: int64(model.Version),
DepthMap: toAPIMockupModelMap(model.DepthMap),
DepthScaleMin: model.DepthScaleMin,
DepthScaleMax: model.DepthScaleMax,
ShadeMap: toAPIMockupModelMap(model.ShadeMap),
SegmentationMap: toAPIMockupModelMap(model.SegmentationMap),
XOffsetMap: toAPIMockupModelMap(model.XOffsetMap),
YOffsetMap: toAPIMockupModelMap(model.YOffsetMap),
CameraIntrinsics: types.MockupCameraIntrinsics{
Matrix: append([]float64(nil), model.CameraIntrinsics.Matrix...),
},
ColorCorrection: model.ColorCorrection,
ForegroundImage: model.ForegroundImage,
}
}
func toAPIMockupModelMap(modelMap design.MockupModelMap) types.MockupModelMap {
return types.MockupModelMap{
DataId: modelMap.DataID,
Width: int64(modelMap.Width),
Height: int64(modelMap.Height),
Url: modelMap.URL,
Data: append([]float64(nil), modelMap.Data...),
}
}
func toDomainMockupModel(model types.MockupModel) design.MockupModel {
return design.MockupModel{
Version: int(model.Version),
DepthMap: toDomainMockupModelMap(model.DepthMap),
DepthScaleMin: model.DepthScaleMin,
DepthScaleMax: model.DepthScaleMax,
ShadeMap: toDomainMockupModelMap(model.ShadeMap),
SegmentationMap: toDomainMockupModelMap(model.SegmentationMap),
XOffsetMap: toDomainMockupModelMap(model.XOffsetMap),
YOffsetMap: toDomainMockupModelMap(model.YOffsetMap),
CameraIntrinsics: design.MockupCameraIntrinsics{
Matrix: append([]float64(nil), model.CameraIntrinsics.Matrix...),
},
ColorCorrection: model.ColorCorrection,
ForegroundImage: model.ForegroundImage,
}
}
func toDomainMockupModelMap(modelMap types.MockupModelMap) design.MockupModelMap {
return design.MockupModelMap{
DataID: modelMap.DataId,
Width: int(modelMap.Width),
Height: int(modelMap.Height),
URL: modelMap.Url,
Data: append([]float64(nil), modelMap.Data...),
}
}
func toAPIMockupPoints(points []design.MockupPoint) []types.MockupPoint {
if len(points) == 0 {
return nil
}
resp := make([]types.MockupPoint, 0, len(points))
for _, point := range points {
resp = append(resp, types.MockupPoint{X: point.X, Y: point.Y})
}
return resp
}
func toDomainMockupPoints(points []types.MockupPoint) []design.MockupPoint {
if len(points) == 0 {
return nil
}
resp := make([]design.MockupPoint, 0, len(points))
for _, point := range points {
resp = append(resp, design.MockupPoint{X: point.X, Y: point.Y})
}
return resp
}
func toAPIConnections(items []design.Connection) []types.CanvasConnection {
resp := make([]types.CanvasConnection, 0, len(items))
for _, item := range items {
resp = append(resp, types.CanvasConnection{
Id: item.ID,
FromNodeId: item.FromNodeID,
ToNodeId: item.ToNodeID,
})
}
return resp
}
func toDomainConnections(items []types.CanvasConnection) []design.Connection {
resp := make([]design.Connection, 0, len(items))
for _, item := range items {
resp = append(resp, design.Connection{
ID: item.Id,
FromNodeID: item.FromNodeId,
ToNodeID: item.ToNodeId,
})
}
return resp
}
func toAPIMessages(items []design.Message) []types.AgentMessage {
resp := make([]types.AgentMessage, 0, len(items))
for _, item := range items {
resp = append(resp, toAPIMessage(item))
}
return resp
}
func isHiddenAgentMessage(item design.Message) bool {
return item.Type == "text_extraction_result" || item.Name == "text_extraction" || item.Name == "canvas_action" || item.ToolHint == "canvas_action"
}
func hiddenAgentThreadIDs(items []design.Message) map[string]bool {
ids := make(map[string]bool)
for _, item := range items {
if item.ThreadID == "" || !isHiddenAgentMessage(item) {
continue
}
ids[item.ThreadID] = true
}
return ids
}
func toAPIMessage(item design.Message) types.AgentMessage {
return types.AgentMessage{
Id: item.ID,
Role: item.Role,
Type: item.Type,
Title: item.Title,
Content: item.Content,
ThreadId: item.ThreadID,
ActionId: item.ActionID,
StepId: item.StepID,
ToolCallId: item.ToolCallID,
Name: item.Name,
ToolHint: item.ToolHint,
Status: item.Status,
CreatedAt: formatTime(item.CreatedAt),
}
}
func toAPIView(item design.Viewport) types.CanvasViewport {
return types.CanvasViewport{X: item.X, Y: item.Y, K: item.K}
}
func toDomainView(item types.CanvasViewport) design.Viewport {
return design.Viewport{X: item.X, Y: item.Y, K: item.K}
}
func toDomainAgentChat(req *types.AgentChatRequest) design.AgentChatRequest {
messages := make([]design.AgentChatMessage, 0, len(req.Messages))
for _, message := range req.Messages {
contents := make([]design.AgentContent, 0, len(message.Contents))
for _, content := range message.Contents {
contents = append(contents, design.AgentContent{
Type: content.Type,
Text: content.Text,
TextSource: content.TextSource,
ImageURL: content.ImageUrl,
ImageWidth: content.ImageWidth,
ImageHeight: content.ImageHeight,
})
}
messages = append(messages, design.AgentChatMessage{
Role: message.Role,
Contents: contents,
})
}
return design.AgentChatRequest{
Messages: messages,
ToolConfig: design.AgentToolConfig{
PreferToolCategories: map[string][]string{
"SEARCH": req.ToolConfig.PreferToolCategories.Search,
"IMAGE": req.ToolConfig.PreferToolCategories.Image,
},
},
ThreadID: req.ThreadId,
ThreadIDType: req.ThreadIdType,
EnableMultimodalInput: req.EnableMultimodalInput,
EnableWebSearch: req.EnableWebSearch || hasPreferredToolCategory(req.ToolConfig.PreferToolCategories.Search),
ImageModel: req.ImageModel,
ImageSize: req.ImageSize,
Mode: req.Mode,
}
}
func hasPreferredToolCategory(items []string) bool {
for _, item := range items {
if item != "" {
return true
}
}
return false
}
func toAPIAgentThread(thread design.AgentThread) *types.AgentThreadResponse {
return &types.AgentThreadResponse{
ThreadId: thread.ThreadID,
Status: thread.Status,
ThreadIdType: thread.ThreadIDType,
EventsUrl: "/api/projects/" + thread.Project.ID + "/events?threadId=" + url.QueryEscape(thread.ThreadID),
Project: toAPIProject(thread.Project),
}
}
func toAPIAgentThreadStatus(thread design.AgentThread) *types.AgentThreadStatusResponse {
return &types.AgentThreadStatusResponse{
ThreadId: thread.ThreadID,
Status: thread.Status,
ThreadIdType: thread.ThreadIDType,
}
}
func toAPIAgentThreadSummaries(items []design.AgentThreadSummary) []types.AgentThreadSummary {
resp := make([]types.AgentThreadSummary, 0, len(items))
for _, item := range items {
resp = append(resp, types.AgentThreadSummary{
AgentThreadId: item.AgentThreadID,
CoverImageUrl: item.CoverImageURL,
Text: item.Text,
ThreadIdType: item.ThreadIDType,
UpdateTimestamp: item.UpdateTimestamp,
})
}
return resp
}
func formatTime(value time.Time) string {
if value.IsZero() {
return ""
}
return value.UTC().Format(time.RFC3339)
}