feat(server): persist node semantic context and retain CAD agent context

Add a semantic_context column to canvas_nodes (schema, sqlc queries,
generated models, repository upserts) and thread SemanticContext through the
domain node, API type, and mappers so CAD-derived context survives canvas
saves.

Treat agent text parts with textSource "cad-context" like settings: keep
them for planning, memory, and image-generation prompts but exclude them
from the persisted user-visible message copy. Add coverage for the CAD
context visibility and mapper behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-16 16:40:47 +08:00
parent 57a9aba03e
commit 29b3bd222c
13 changed files with 249 additions and 137 deletions
+4
View File
@@ -419,6 +419,8 @@ Subsequent shared project requests send `X-Share-Id: <shareId>`. Middleware re-r
The harness builds short memory from the current turn, recent messages, and visible canvas state, and long memory from durable project decisions, prior artifacts, and previous research records with page-content excerpts. Image titles and assistant language are generated from the request context instead of fixed model labels. The harness builds short memory from the current turn, recent messages, and visible canvas state, and long memory from durable project decisions, prior artifacts, and previous research records with page-content excerpts. Image titles and assistant language are generated from the request context instead of fixed model labels.
CAD references may include a text content part with `textSource: "cad-context"`. It is retained in agent planning, memory, and image-generation prompts but omitted from persisted user-visible message copy. The context is bounded and treats CAD labels as reference data, never as executable instructions.
## Generate ## Generate
`POST /api/projects/:id/generate` `POST /api/projects/:id/generate`
@@ -537,6 +539,8 @@ Returns `{ "code": 0, "msg": "success", "data": { "status": "done" } }` or the c
Saves canvas layout changes with a Lovart-style lightweight payload. The frontend posts to `/api/canva/project/saveProject`; `/api/projects/:id/canvas` remains as a compatibility route. The `canvas` value is a `SHAKKERDATA://` gzip+base64 encoded snapshot containing viewport, nodes, and connections. Legacy uncompressed `{ "viewport": ..., "nodes": ..., "connections": ... }` payloads are still accepted. Saves canvas layout changes with a Lovart-style lightweight payload. The frontend posts to `/api/canva/project/saveProject`; `/api/projects/:id/canvas` remains as a compatibility route. The `canvas` value is a `SHAKKERDATA://` gzip+base64 encoded snapshot containing viewport, nodes, and connections. Legacy uncompressed `{ "viewport": ..., "nodes": ..., "connections": ... }` payloads are still accepted.
Canvas nodes may include optional `semanticContext`. CAD source nodes use `layerRole: "cad-vector"`; non-destructive crop siblings use `layerRole: "cad-crop"` and carry crop-specific context. PostgreSQL persists the field in `canvas_nodes.semantic_context`.
When a canvas image node disappears from the saved node list, its backing OSS asset is queued for asynchronous deletion after the canvas save succeeds. When a canvas image node disappears from the saved node list, its backing OSS asset is queued for asynchronous deletion after the canvas save succeeds.
## Image Asset Upload ## Image Asset Upload
+1
View File
@@ -242,6 +242,7 @@ type CanvasNode {
Status string `json:"status"` Status string `json:"status"`
ParentId string `json:"parentId,optional"` ParentId string `json:"parentId,optional"`
LayerRole string `json:"layerRole,optional"` LayerRole string `json:"layerRole,optional"`
SemanticContext string `json:"semanticContext,optional"`
FontSize float64 `json:"fontSize,optional"` FontSize float64 `json:"fontSize,optional"`
FontFamily string `json:"fontFamily,optional"` FontFamily string `json:"fontFamily,optional"`
FontWeight string `json:"fontWeight,optional"` FontWeight string `json:"fontWeight,optional"`
@@ -0,0 +1,43 @@
package application
import (
"strings"
"testing"
"img_infinite_canvas/internal/domain/design"
)
func TestCADContextIsModelInputButNotVisibleUserCopy(t *testing.T) {
messages := []design.AgentChatMessage{
{
Role: "user",
Contents: []design.AgentContent{
{Type: "text", Text: "Focus on the living room", TextSource: "input"},
{Type: "image", ImageURL: "/canvas-assets/floor-plan.webp", ImageName: "floor-plan crop.webp"},
{Type: "text", Text: "CAD semantic context: layer ROOMS, block ROOM_A", TextSource: "cad-context"},
},
},
}
modelPrompt := promptFromAgentMessages(messages)
visiblePrompt := visiblePromptFromAgentMessages(messages)
if !strings.Contains(modelPrompt, "layer ROOMS") {
t.Fatalf("expected model prompt to retain CAD semantics, got %q", modelPrompt)
}
if strings.Contains(visiblePrompt, "layer ROOMS") {
t.Fatalf("expected visible user copy to hide CAD semantics, got %q", visiblePrompt)
}
if !strings.Contains(visiblePrompt, "Focus on the living room") || !strings.Contains(visiblePrompt, "floor-plan crop.webp") {
t.Fatalf("expected visible prompt and reference capsule directive, got %q", visiblePrompt)
}
}
func TestCADContextAloneDoesNotSatisfyPromptValidation(t *testing.T) {
messages := []design.AgentChatMessage{{
Role: "user",
Contents: []design.AgentContent{{Type: "text", Text: "CAD semantic context", TextSource: "cad-context"}},
}}
if hasUserPromptTextInAgentMessages(messages) {
t.Fatal("expected hidden CAD context not to count as user prompt text")
}
}
+17 -3
View File
@@ -669,6 +669,10 @@ func (s *DesignService) AgentChat(ctx context.Context, projectID string, req des
if prompt == "" { if prompt == "" {
return design.AgentThread{}, fmt.Errorf("%w: prompt is required", design.ErrInvalidInput) return design.AgentThread{}, fmt.Errorf("%w: prompt is required", design.ErrInvalidInput)
} }
visiblePrompt := design.NormalizePrompt(visiblePromptFromAgentMessages(req.Messages))
if visiblePrompt == "" {
visiblePrompt = prompt
}
mode := strings.TrimSpace(req.Mode) mode := strings.TrimSpace(req.Mode)
if mode == "" { if mode == "" {
@@ -703,7 +707,7 @@ func (s *DesignService) AgentChat(ctx context.Context, projectID string, req des
Role: "user", Role: "user",
Type: "user", Type: "user",
Title: "新的设计需求", Title: "新的设计需求",
Content: prompt, Content: visiblePrompt,
ThreadID: threadID, ThreadID: threadID,
ActionID: actionID, ActionID: actionID,
StepID: newID(), StepID: newID(),
@@ -5153,6 +5157,14 @@ func isGeneratedImageContent(value string) bool {
} }
func promptFromAgentMessages(messages []design.AgentChatMessage) string { func promptFromAgentMessages(messages []design.AgentChatMessage) string {
return promptFromAgentMessagesWithVisibility(messages, false)
}
func visiblePromptFromAgentMessages(messages []design.AgentChatMessage) string {
return promptFromAgentMessagesWithVisibility(messages, true)
}
func promptFromAgentMessagesWithVisibility(messages []design.AgentChatMessage, visibleOnly bool) string {
var builder strings.Builder var builder strings.Builder
for _, message := range messages { for _, message := range messages {
if strings.TrimSpace(message.Role) != "" && strings.ToLower(strings.TrimSpace(message.Role)) != "user" { if strings.TrimSpace(message.Role) != "" && strings.ToLower(strings.TrimSpace(message.Role)) != "user" {
@@ -5161,7 +5173,8 @@ func promptFromAgentMessages(messages []design.AgentChatMessage) string {
for _, content := range message.Contents { for _, content := range message.Contents {
switch strings.ToLower(strings.TrimSpace(content.Type)) { switch strings.ToLower(strings.TrimSpace(content.Type)) {
case "text": case "text":
if strings.EqualFold(strings.TrimSpace(content.TextSource), "settings") { textSource := strings.TrimSpace(content.TextSource)
if strings.EqualFold(textSource, "settings") || (visibleOnly && strings.EqualFold(textSource, "cad-context")) {
continue continue
} }
if text := strings.TrimSpace(content.Text); text != "" { if text := strings.TrimSpace(content.Text); text != "" {
@@ -5207,7 +5220,8 @@ func hasUserPromptTextInAgentMessages(messages []design.AgentChatMessage) bool {
if strings.ToLower(strings.TrimSpace(content.Type)) != "text" { if strings.ToLower(strings.TrimSpace(content.Type)) != "text" {
continue continue
} }
if strings.EqualFold(strings.TrimSpace(content.TextSource), "settings") { textSource := strings.TrimSpace(content.TextSource)
if strings.EqualFold(textSource, "settings") || strings.EqualFold(textSource, "cad-context") {
continue continue
} }
text := stripPromptReferenceDirectives(stripImageGeneratorDirectiveLines(content.Text)) text := stripPromptReferenceDirectives(stripImageGeneratorDirectiveLines(content.Text))
+1
View File
@@ -134,6 +134,7 @@ type Node struct {
Status string Status string
ParentID string ParentID string
LayerRole string LayerRole string
SemanticContext string
FontSize float64 FontSize float64
FontFamily string FontFamily string
FontWeight string FontWeight string
@@ -26,6 +26,20 @@ type AgentMessage struct {
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
} }
type AuthDeviceSession struct {
ID string `json:"id"`
UserID pgtype.UUID `json:"user_id"`
DeviceType string `json:"device_type"`
System string `json:"system"`
Browser string `json:"browser"`
Client string `json:"client"`
UserAgent string `json:"user_agent"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
LastSeenAt pgtype.Timestamptz `json:"last_seen_at"`
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
RevokedAt pgtype.Timestamptz `json:"revoked_at"`
}
type AuthVerificationCode struct { type AuthVerificationCode struct {
ID string `json:"id"` ID string `json:"id"`
Region string `json:"region"` Region string `json:"region"`
@@ -58,38 +72,39 @@ type CanvasConnection struct {
} }
type CanvasNode struct { type CanvasNode struct {
ID string `json:"id"` ID string `json:"id"`
ProjectID string `json:"project_id"` ProjectID string `json:"project_id"`
UserID pgtype.UUID `json:"user_id"` UserID pgtype.UUID `json:"user_id"`
NodeType string `json:"node_type"` NodeType string `json:"node_type"`
Title string `json:"title"` Title string `json:"title"`
X float64 `json:"x"` X float64 `json:"x"`
Y float64 `json:"y"` Y float64 `json:"y"`
Width float64 `json:"width"` Width float64 `json:"width"`
Height float64 `json:"height"` Height float64 `json:"height"`
Content string `json:"content"` Content string `json:"content"`
Tone string `json:"tone"` Tone string `json:"tone"`
Status string `json:"status"` Status string `json:"status"`
SortOrder int32 `json:"sort_order"` SortOrder int32 `json:"sort_order"`
ParentID string `json:"parent_id"` ParentID string `json:"parent_id"`
LayerRole string `json:"layer_role"` LayerRole string `json:"layer_role"`
FontSize float64 `json:"font_size"` FontSize float64 `json:"font_size"`
FontFamily string `json:"font_family"` FontFamily string `json:"font_family"`
FontWeight string `json:"font_weight"` FontWeight string `json:"font_weight"`
Color string `json:"color"` Color string `json:"color"`
TextAlign string `json:"text_align"` TextAlign string `json:"text_align"`
LineHeight float64 `json:"line_height"` LineHeight float64 `json:"line_height"`
LetterSpacing float64 `json:"letter_spacing"` LetterSpacing float64 `json:"letter_spacing"`
TextDecoration string `json:"text_decoration"` TextDecoration string `json:"text_decoration"`
TextTransform string `json:"text_transform"` TextTransform string `json:"text_transform"`
Opacity float64 `json:"opacity"` Opacity float64 `json:"opacity"`
FillColor string `json:"fill_color"` FillColor string `json:"fill_color"`
StrokeColor string `json:"stroke_color"` StrokeColor string `json:"stroke_color"`
StrokeWidth float64 `json:"stroke_width"` StrokeWidth float64 `json:"stroke_width"`
StrokeStyle string `json:"stroke_style"` StrokeStyle string `json:"stroke_style"`
FlipX bool `json:"flip_x"` FlipX bool `json:"flip_x"`
FlipY bool `json:"flip_y"` FlipY bool `json:"flip_y"`
Rotation float64 `json:"rotation"` Rotation float64 `json:"rotation"`
SemanticContext string `json:"semantic_context"`
} }
type CanvasSnapshot struct { type CanvasSnapshot struct {
@@ -467,7 +467,7 @@ func (q *Queries) ListMessagesByProject(ctx context.Context, arg ListMessagesByP
} }
const listNodesByProject = `-- name: ListNodesByProject :many const listNodesByProject = `-- name: ListNodesByProject :many
SELECT id, project_id, user_id, node_type, title, x, y, width, height, content, tone, status, sort_order, parent_id, layer_role, font_size, font_family, font_weight, color, text_align, line_height, letter_spacing, text_decoration, text_transform, opacity, fill_color, stroke_color, stroke_width, stroke_style, flip_x, flip_y, rotation SELECT id, project_id, user_id, node_type, title, x, y, width, height, content, tone, status, sort_order, parent_id, layer_role, font_size, font_family, font_weight, color, text_align, line_height, letter_spacing, text_decoration, text_transform, opacity, fill_color, stroke_color, stroke_width, stroke_style, flip_x, flip_y, rotation, semantic_context
FROM canvas_nodes FROM canvas_nodes
WHERE project_id = $1 AND user_id = $2 WHERE project_id = $1 AND user_id = $2
ORDER BY sort_order ASC, id ASC ORDER BY sort_order ASC, id ASC
@@ -520,6 +520,7 @@ func (q *Queries) ListNodesByProject(ctx context.Context, arg ListNodesByProject
&i.FlipX, &i.FlipX,
&i.FlipY, &i.FlipY,
&i.Rotation, &i.Rotation,
&i.SemanticContext,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -745,8 +746,8 @@ func (q *Queries) UpsertBrandKit(ctx context.Context, arg UpsertBrandKitParams)
} }
const upsertCanvasNode = `-- name: UpsertCanvasNode :exec const upsertCanvasNode = `-- name: UpsertCanvasNode :exec
INSERT INTO canvas_nodes (id, project_id, user_id, node_type, title, x, y, width, height, content, tone, status, sort_order, parent_id, layer_role, font_size, font_family, font_weight, color, text_align, line_height, letter_spacing, text_decoration, text_transform, opacity, fill_color, stroke_color, stroke_width, stroke_style, flip_x, flip_y, rotation) INSERT INTO canvas_nodes (id, project_id, user_id, node_type, title, x, y, width, height, content, tone, status, sort_order, parent_id, layer_role, font_size, font_family, font_weight, color, text_align, line_height, letter_spacing, text_decoration, text_transform, opacity, fill_color, stroke_color, stroke_width, stroke_style, flip_x, flip_y, rotation, semantic_context)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33)
ON CONFLICT (id) DO UPDATE SET ON CONFLICT (id) DO UPDATE SET
project_id = EXCLUDED.project_id, project_id = EXCLUDED.project_id,
user_id = EXCLUDED.user_id, user_id = EXCLUDED.user_id,
@@ -778,42 +779,44 @@ ON CONFLICT (id) DO UPDATE SET
stroke_style = EXCLUDED.stroke_style, stroke_style = EXCLUDED.stroke_style,
flip_x = EXCLUDED.flip_x, flip_x = EXCLUDED.flip_x,
flip_y = EXCLUDED.flip_y, flip_y = EXCLUDED.flip_y,
rotation = EXCLUDED.rotation rotation = EXCLUDED.rotation,
semantic_context = EXCLUDED.semantic_context
` `
type UpsertCanvasNodeParams struct { type UpsertCanvasNodeParams struct {
ID string `json:"id"` ID string `json:"id"`
ProjectID string `json:"project_id"` ProjectID string `json:"project_id"`
UserID pgtype.UUID `json:"user_id"` UserID pgtype.UUID `json:"user_id"`
NodeType string `json:"node_type"` NodeType string `json:"node_type"`
Title string `json:"title"` Title string `json:"title"`
X float64 `json:"x"` X float64 `json:"x"`
Y float64 `json:"y"` Y float64 `json:"y"`
Width float64 `json:"width"` Width float64 `json:"width"`
Height float64 `json:"height"` Height float64 `json:"height"`
Content string `json:"content"` Content string `json:"content"`
Tone string `json:"tone"` Tone string `json:"tone"`
Status string `json:"status"` Status string `json:"status"`
SortOrder int32 `json:"sort_order"` SortOrder int32 `json:"sort_order"`
ParentID string `json:"parent_id"` ParentID string `json:"parent_id"`
LayerRole string `json:"layer_role"` LayerRole string `json:"layer_role"`
FontSize float64 `json:"font_size"` FontSize float64 `json:"font_size"`
FontFamily string `json:"font_family"` FontFamily string `json:"font_family"`
FontWeight string `json:"font_weight"` FontWeight string `json:"font_weight"`
Color string `json:"color"` Color string `json:"color"`
TextAlign string `json:"text_align"` TextAlign string `json:"text_align"`
LineHeight float64 `json:"line_height"` LineHeight float64 `json:"line_height"`
LetterSpacing float64 `json:"letter_spacing"` LetterSpacing float64 `json:"letter_spacing"`
TextDecoration string `json:"text_decoration"` TextDecoration string `json:"text_decoration"`
TextTransform string `json:"text_transform"` TextTransform string `json:"text_transform"`
Opacity float64 `json:"opacity"` Opacity float64 `json:"opacity"`
FillColor string `json:"fill_color"` FillColor string `json:"fill_color"`
StrokeColor string `json:"stroke_color"` StrokeColor string `json:"stroke_color"`
StrokeWidth float64 `json:"stroke_width"` StrokeWidth float64 `json:"stroke_width"`
StrokeStyle string `json:"stroke_style"` StrokeStyle string `json:"stroke_style"`
FlipX bool `json:"flip_x"` FlipX bool `json:"flip_x"`
FlipY bool `json:"flip_y"` FlipY bool `json:"flip_y"`
Rotation float64 `json:"rotation"` Rotation float64 `json:"rotation"`
SemanticContext string `json:"semantic_context"`
} }
func (q *Queries) UpsertCanvasNode(ctx context.Context, arg UpsertCanvasNodeParams) error { func (q *Queries) UpsertCanvasNode(ctx context.Context, arg UpsertCanvasNodeParams) error {
@@ -850,6 +853,7 @@ func (q *Queries) UpsertCanvasNode(ctx context.Context, arg UpsertCanvasNodePara
arg.FlipX, arg.FlipX,
arg.FlipY, arg.FlipY,
arg.Rotation, arg.Rotation,
arg.SemanticContext,
) )
return err return err
} }
@@ -189,38 +189,39 @@ func (r *ProjectRepository) Save(ctx context.Context, project design.Project) er
for index, node := range project.Nodes { for index, node := range project.Nodes {
if err := q.UpsertCanvasNode(ctx, sqlcdb.UpsertCanvasNodeParams{ if err := q.UpsertCanvasNode(ctx, sqlcdb.UpsertCanvasNodeParams{
ID: node.ID, ID: node.ID,
ProjectID: project.ID, ProjectID: project.ID,
UserID: toPGUUID(project.UserID), UserID: toPGUUID(project.UserID),
NodeType: string(node.Type), NodeType: string(node.Type),
Title: node.Title, Title: node.Title,
X: node.X, X: node.X,
Y: node.Y, Y: node.Y,
Width: node.Width, Width: node.Width,
Height: node.Height, Height: node.Height,
Content: node.Content, Content: node.Content,
Tone: node.Tone, Tone: node.Tone,
Status: node.Status, Status: node.Status,
SortOrder: int32(index), SortOrder: int32(index),
ParentID: node.ParentID, ParentID: node.ParentID,
LayerRole: node.LayerRole, LayerRole: node.LayerRole,
FontSize: node.FontSize, SemanticContext: node.SemanticContext,
FontFamily: node.FontFamily, FontSize: node.FontSize,
FontWeight: node.FontWeight, FontFamily: node.FontFamily,
Color: node.Color, FontWeight: node.FontWeight,
TextAlign: node.TextAlign, Color: node.Color,
LineHeight: node.LineHeight, TextAlign: node.TextAlign,
LetterSpacing: node.LetterSpacing, LineHeight: node.LineHeight,
TextDecoration: node.TextDecoration, LetterSpacing: node.LetterSpacing,
TextTransform: node.TextTransform, TextDecoration: node.TextDecoration,
Opacity: node.Opacity, TextTransform: node.TextTransform,
FillColor: node.FillColor, Opacity: node.Opacity,
StrokeColor: node.StrokeColor, FillColor: node.FillColor,
StrokeWidth: node.StrokeWidth, StrokeColor: node.StrokeColor,
StrokeStyle: node.StrokeStyle, StrokeWidth: node.StrokeWidth,
FlipX: node.FlipX, StrokeStyle: node.StrokeStyle,
FlipY: node.FlipY, FlipX: node.FlipX,
Rotation: node.Rotation, FlipY: node.FlipY,
Rotation: node.Rotation,
}); err != nil { }); err != nil {
return err return err
} }
@@ -509,35 +510,36 @@ func mapNodes(rows []sqlcdb.CanvasNode) []design.Node {
nodes := make([]design.Node, 0, len(rows)) nodes := make([]design.Node, 0, len(rows))
for _, row := range rows { for _, row := range rows {
nodes = append(nodes, design.Node{ nodes = append(nodes, design.Node{
ID: row.ID, ID: row.ID,
Type: design.NodeType(row.NodeType), Type: design.NodeType(row.NodeType),
Title: row.Title, Title: row.Title,
X: row.X, X: row.X,
Y: row.Y, Y: row.Y,
Width: row.Width, Width: row.Width,
Height: row.Height, Height: row.Height,
Content: row.Content, Content: row.Content,
Tone: row.Tone, Tone: row.Tone,
Status: row.Status, Status: row.Status,
ParentID: row.ParentID, ParentID: row.ParentID,
LayerRole: row.LayerRole, LayerRole: row.LayerRole,
FontSize: row.FontSize, SemanticContext: row.SemanticContext,
FontFamily: row.FontFamily, FontSize: row.FontSize,
FontWeight: row.FontWeight, FontFamily: row.FontFamily,
Color: row.Color, FontWeight: row.FontWeight,
TextAlign: row.TextAlign, Color: row.Color,
LineHeight: row.LineHeight, TextAlign: row.TextAlign,
LetterSpacing: row.LetterSpacing, LineHeight: row.LineHeight,
TextDecoration: row.TextDecoration, LetterSpacing: row.LetterSpacing,
TextTransform: row.TextTransform, TextDecoration: row.TextDecoration,
Opacity: row.Opacity, TextTransform: row.TextTransform,
FillColor: row.FillColor, Opacity: row.Opacity,
StrokeColor: row.StrokeColor, FillColor: row.FillColor,
StrokeWidth: row.StrokeWidth, StrokeColor: row.StrokeColor,
StrokeStyle: row.StrokeStyle, StrokeWidth: row.StrokeWidth,
FlipX: row.FlipX, StrokeStyle: row.StrokeStyle,
FlipY: row.FlipY, FlipX: row.FlipX,
Rotation: row.Rotation, FlipY: row.FlipY,
Rotation: row.Rotation,
}) })
} }
return nodes return nodes
@@ -114,7 +114,7 @@ SET thumbnail = $3,
WHERE id = $1 AND user_id = $2; WHERE id = $1 AND user_id = $2;
-- name: ListNodesByProject :many -- name: ListNodesByProject :many
SELECT id, project_id, user_id, node_type, title, x, y, width, height, content, tone, status, sort_order, parent_id, layer_role, font_size, font_family, font_weight, color, text_align, line_height, letter_spacing, text_decoration, text_transform, opacity, fill_color, stroke_color, stroke_width, stroke_style, flip_x, flip_y, rotation SELECT id, project_id, user_id, node_type, title, x, y, width, height, content, tone, status, sort_order, parent_id, layer_role, font_size, font_family, font_weight, color, text_align, line_height, letter_spacing, text_decoration, text_transform, opacity, fill_color, stroke_color, stroke_width, stroke_style, flip_x, flip_y, rotation, semantic_context
FROM canvas_nodes FROM canvas_nodes
WHERE project_id = $1 AND user_id = $2 WHERE project_id = $1 AND user_id = $2
ORDER BY sort_order ASC, id ASC; ORDER BY sort_order ASC, id ASC;
@@ -124,8 +124,8 @@ DELETE FROM canvas_nodes
WHERE project_id = $1 AND user_id = $2; WHERE project_id = $1 AND user_id = $2;
-- name: UpsertCanvasNode :exec -- name: UpsertCanvasNode :exec
INSERT INTO canvas_nodes (id, project_id, user_id, node_type, title, x, y, width, height, content, tone, status, sort_order, parent_id, layer_role, font_size, font_family, font_weight, color, text_align, line_height, letter_spacing, text_decoration, text_transform, opacity, fill_color, stroke_color, stroke_width, stroke_style, flip_x, flip_y, rotation) INSERT INTO canvas_nodes (id, project_id, user_id, node_type, title, x, y, width, height, content, tone, status, sort_order, parent_id, layer_role, font_size, font_family, font_weight, color, text_align, line_height, letter_spacing, text_decoration, text_transform, opacity, fill_color, stroke_color, stroke_width, stroke_style, flip_x, flip_y, rotation, semantic_context)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33)
ON CONFLICT (id) DO UPDATE SET ON CONFLICT (id) DO UPDATE SET
project_id = EXCLUDED.project_id, project_id = EXCLUDED.project_id,
user_id = EXCLUDED.user_id, user_id = EXCLUDED.user_id,
@@ -157,7 +157,8 @@ ON CONFLICT (id) DO UPDATE SET
stroke_style = EXCLUDED.stroke_style, stroke_style = EXCLUDED.stroke_style,
flip_x = EXCLUDED.flip_x, flip_x = EXCLUDED.flip_x,
flip_y = EXCLUDED.flip_y, flip_y = EXCLUDED.flip_y,
rotation = EXCLUDED.rotation; rotation = EXCLUDED.rotation,
semantic_context = EXCLUDED.semantic_context;
-- name: ListConnectionsByProject :many -- name: ListConnectionsByProject :many
SELECT id, project_id, user_id, from_node_id, to_node_id SELECT id, project_id, user_id, from_node_id, to_node_id
@@ -194,7 +194,8 @@ CREATE TABLE IF NOT EXISTS canvas_nodes (
stroke_style TEXT NOT NULL DEFAULT '', stroke_style TEXT NOT NULL DEFAULT '',
flip_x BOOLEAN NOT NULL DEFAULT FALSE, flip_x BOOLEAN NOT NULL DEFAULT FALSE,
flip_y BOOLEAN NOT NULL DEFAULT FALSE, flip_y BOOLEAN NOT NULL DEFAULT FALSE,
rotation DOUBLE PRECISION NOT NULL DEFAULT 0 rotation DOUBLE PRECISION NOT NULL DEFAULT 0,
semantic_context TEXT NOT NULL DEFAULT ''
); );
ALTER TABLE canvas_nodes ADD COLUMN IF NOT EXISTS user_id UUID; ALTER TABLE canvas_nodes ADD COLUMN IF NOT EXISTS user_id UUID;
@@ -225,6 +226,7 @@ ALTER TABLE canvas_nodes ADD COLUMN IF NOT EXISTS stroke_style TEXT NOT NULL DEF
ALTER TABLE canvas_nodes ADD COLUMN IF NOT EXISTS flip_x BOOLEAN NOT NULL DEFAULT FALSE; ALTER TABLE canvas_nodes ADD COLUMN IF NOT EXISTS flip_x BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE canvas_nodes ADD COLUMN IF NOT EXISTS flip_y BOOLEAN NOT NULL DEFAULT FALSE; ALTER TABLE canvas_nodes ADD COLUMN IF NOT EXISTS flip_y BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE canvas_nodes ADD COLUMN IF NOT EXISTS rotation DOUBLE PRECISION NOT NULL DEFAULT 0; ALTER TABLE canvas_nodes ADD COLUMN IF NOT EXISTS rotation DOUBLE PRECISION NOT NULL DEFAULT 0;
ALTER TABLE canvas_nodes ADD COLUMN IF NOT EXISTS semantic_context TEXT NOT NULL DEFAULT '';
CREATE INDEX IF NOT EXISTS canvas_nodes_project_id_idx ON canvas_nodes(project_id); CREATE INDEX IF NOT EXISTS canvas_nodes_project_id_idx ON canvas_nodes(project_id);
CREATE INDEX IF NOT EXISTS canvas_nodes_user_project_idx ON canvas_nodes(user_id, project_id); CREATE INDEX IF NOT EXISTS canvas_nodes_user_project_idx ON canvas_nodes(user_id, project_id);
@@ -0,0 +1,22 @@
package logic
import (
"testing"
"img_infinite_canvas/internal/domain/design"
)
func TestCanvasNodeSemanticContextRoundTrip(t *testing.T) {
nodes := []design.Node{{
ID: "cad-1",
Type: design.NodeTypeImage,
Title: "plan.svg",
LayerRole: "cad-vector",
SemanticContext: "CAD semantic context: layer ROOMS",
}}
roundTrip := toDomainNodes(toAPINodes(nodes))
if len(roundTrip) != 1 || roundTrip[0].SemanticContext != nodes[0].SemanticContext {
t.Fatalf("expected semantic context round trip, got %#v", roundTrip)
}
}
+2
View File
@@ -263,6 +263,7 @@ func toAPINodes(items []design.Node) []types.CanvasNode {
Status: item.Status, Status: item.Status,
ParentId: item.ParentID, ParentId: item.ParentID,
LayerRole: item.LayerRole, LayerRole: item.LayerRole,
SemanticContext: item.SemanticContext,
FontSize: item.FontSize, FontSize: item.FontSize,
FontFamily: item.FontFamily, FontFamily: item.FontFamily,
FontWeight: item.FontWeight, FontWeight: item.FontWeight,
@@ -305,6 +306,7 @@ func toDomainNodes(items []types.CanvasNode) []design.Node {
Status: item.Status, Status: item.Status,
ParentID: item.ParentId, ParentID: item.ParentId,
LayerRole: item.LayerRole, LayerRole: item.LayerRole,
SemanticContext: item.SemanticContext,
FontSize: item.FontSize, FontSize: item.FontSize,
FontFamily: item.FontFamily, FontFamily: item.FontFamily,
FontWeight: item.FontWeight, FontWeight: item.FontWeight,
+1
View File
@@ -287,6 +287,7 @@ type CanvasNode struct {
Status string `json:"status"` Status string `json:"status"`
ParentId string `json:"parentId,optional"` ParentId string `json:"parentId,optional"`
LayerRole string `json:"layerRole,optional"` LayerRole string `json:"layerRole,optional"`
SemanticContext string `json:"semanticContext,optional"`
FontSize float64 `json:"fontSize,optional"` FontSize float64 `json:"fontSize,optional"`
FontFamily string `json:"fontFamily,optional"` FontFamily string `json:"fontFamily,optional"`
FontWeight string `json:"fontWeight,optional"` FontWeight string `json:"fontWeight,optional"`