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>
This commit is contained in:
2026-07-07 23:15:37 +08:00
commit 44406b72db
349 changed files with 73265 additions and 0 deletions
@@ -0,0 +1,245 @@
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY,
email TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
INSERT INTO users (id, email, name, status)
VALUES ('00000000-0000-0000-0000-000000000001', 'local@example.test', 'Local User', 'active')
ON CONFLICT (id) DO NOTHING;
ALTER TABLE users ALTER COLUMN id TYPE UUID USING NULLIF(id::text, '')::uuid;
ALTER TABLE users ADD COLUMN IF NOT EXISTS region TEXT NOT NULL DEFAULT 'china';
ALTER TABLE users ADD COLUMN IF NOT EXISTS phone_country_code TEXT NOT NULL DEFAULT '';
ALTER TABLE users ADD COLUMN IF NOT EXISTS phone TEXT NOT NULL DEFAULT '';
ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_url TEXT NOT NULL DEFAULT '';
ALTER TABLE users ADD COLUMN IF NOT EXISTS password_hash TEXT NOT NULL DEFAULT '';
ALTER TABLE users ADD COLUMN IF NOT EXISTS wechat_open_id TEXT NOT NULL DEFAULT '';
ALTER TABLE users ADD COLUMN IF NOT EXISTS wechat_union_id TEXT NOT NULL DEFAULT '';
ALTER TABLE users ADD COLUMN IF NOT EXISTS google_subject TEXT NOT NULL DEFAULT '';
ALTER TABLE users ADD COLUMN IF NOT EXISTS phone_verified_at TIMESTAMPTZ;
ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMPTZ;
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMPTZ;
CREATE UNIQUE INDEX IF NOT EXISTS users_phone_unique_idx ON users(phone_country_code, phone) WHERE phone <> '';
CREATE UNIQUE INDEX IF NOT EXISTS users_email_unique_idx ON users(LOWER(email)) WHERE email <> '';
CREATE UNIQUE INDEX IF NOT EXISTS users_google_subject_unique_idx ON users(google_subject) WHERE google_subject <> '';
CREATE UNIQUE INDEX IF NOT EXISTS users_wechat_union_id_unique_idx ON users(wechat_union_id) WHERE wechat_union_id <> '';
CREATE UNIQUE INDEX IF NOT EXISTS users_wechat_open_id_unique_idx ON users(wechat_open_id) WHERE wechat_open_id <> '' AND wechat_union_id = '';
CREATE TABLE IF NOT EXISTS auth_verification_codes (
id TEXT PRIMARY KEY,
region TEXT NOT NULL,
channel TEXT NOT NULL,
target TEXT NOT NULL,
purpose TEXT NOT NULL,
code_hash TEXT NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
expires_at TIMESTAMPTZ NOT NULL,
consumed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS auth_codes_target_idx ON auth_verification_codes(region, channel, target, purpose, created_at DESC);
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
user_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001'::uuid,
title TEXT NOT NULL,
brief TEXT NOT NULL,
status TEXT NOT NULL,
thumbnail TEXT NOT NULL,
canvas TEXT NOT NULL DEFAULT '',
version TEXT NOT NULL DEFAULT '',
snapshot_id TEXT NOT NULL DEFAULT '',
last_thread_id TEXT NOT NULL DEFAULT '',
viewport_x DOUBLE PRECISION NOT NULL DEFAULT 0,
viewport_y DOUBLE PRECISION NOT NULL DEFAULT 0,
viewport_k DOUBLE PRECISION NOT NULL DEFAULT 1,
updated_at TIMESTAMPTZ NOT NULL
);
ALTER TABLE projects ADD COLUMN IF NOT EXISTS user_id UUID;
ALTER TABLE projects ALTER COLUMN user_id DROP DEFAULT;
ALTER TABLE projects ALTER COLUMN user_id TYPE UUID USING NULLIF(user_id::text, '')::uuid;
UPDATE projects SET user_id = '00000000-0000-0000-0000-000000000001'::uuid WHERE user_id IS NULL;
ALTER TABLE projects ALTER COLUMN user_id SET DEFAULT '00000000-0000-0000-0000-000000000001'::uuid;
ALTER TABLE projects ALTER COLUMN user_id SET NOT NULL;
ALTER TABLE projects ADD COLUMN IF NOT EXISTS canvas TEXT NOT NULL DEFAULT '';
ALTER TABLE projects ADD COLUMN IF NOT EXISTS version TEXT NOT NULL DEFAULT '';
ALTER TABLE projects ADD COLUMN IF NOT EXISTS snapshot_id TEXT NOT NULL DEFAULT '';
ALTER TABLE projects ADD COLUMN IF NOT EXISTS last_thread_id TEXT NOT NULL DEFAULT '';
CREATE INDEX IF NOT EXISTS projects_user_updated_idx ON projects(user_id, updated_at DESC);
CREATE TABLE IF NOT EXISTS canvas_nodes (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
user_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001'::uuid,
node_type TEXT NOT NULL,
title TEXT NOT NULL,
x DOUBLE PRECISION NOT NULL,
y DOUBLE PRECISION NOT NULL,
width DOUBLE PRECISION NOT NULL,
height DOUBLE PRECISION NOT NULL,
content TEXT NOT NULL,
tone TEXT NOT NULL,
status TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
parent_id TEXT NOT NULL DEFAULT '',
layer_role TEXT NOT NULL DEFAULT '',
font_size DOUBLE PRECISION NOT NULL DEFAULT 0,
font_family TEXT NOT NULL DEFAULT '',
font_weight TEXT NOT NULL DEFAULT '',
color TEXT NOT NULL DEFAULT '',
text_align TEXT NOT NULL DEFAULT '',
line_height DOUBLE PRECISION NOT NULL DEFAULT 0,
letter_spacing DOUBLE PRECISION NOT NULL DEFAULT 0,
text_decoration TEXT NOT NULL DEFAULT '',
text_transform TEXT NOT NULL DEFAULT '',
opacity DOUBLE PRECISION NOT NULL DEFAULT 0,
fill_color TEXT NOT NULL DEFAULT '',
stroke_color TEXT NOT NULL DEFAULT '',
stroke_width DOUBLE PRECISION NOT NULL DEFAULT 0,
stroke_style TEXT NOT NULL DEFAULT '',
flip_x BOOLEAN NOT NULL DEFAULT FALSE,
flip_y BOOLEAN NOT NULL DEFAULT FALSE,
rotation DOUBLE PRECISION NOT NULL DEFAULT 0
);
ALTER TABLE canvas_nodes ADD COLUMN IF NOT EXISTS user_id UUID;
ALTER TABLE canvas_nodes ALTER COLUMN user_id DROP DEFAULT;
ALTER TABLE canvas_nodes ALTER COLUMN user_id TYPE UUID USING NULLIF(user_id::text, '')::uuid;
UPDATE canvas_nodes
SET user_id = COALESCE((SELECT p.user_id FROM projects p WHERE p.id = canvas_nodes.project_id), '00000000-0000-0000-0000-000000000001'::uuid)
WHERE user_id IS NULL;
ALTER TABLE canvas_nodes ALTER COLUMN user_id SET DEFAULT '00000000-0000-0000-0000-000000000001'::uuid;
ALTER TABLE canvas_nodes ALTER COLUMN user_id SET NOT NULL;
ALTER TABLE canvas_nodes ADD COLUMN IF NOT EXISTS sort_order INTEGER NOT NULL DEFAULT 0;
ALTER TABLE canvas_nodes ADD COLUMN IF NOT EXISTS parent_id TEXT NOT NULL DEFAULT '';
ALTER TABLE canvas_nodes ADD COLUMN IF NOT EXISTS layer_role TEXT NOT NULL DEFAULT '';
ALTER TABLE canvas_nodes ADD COLUMN IF NOT EXISTS font_size DOUBLE PRECISION NOT NULL DEFAULT 0;
ALTER TABLE canvas_nodes ADD COLUMN IF NOT EXISTS font_family TEXT NOT NULL DEFAULT '';
ALTER TABLE canvas_nodes ADD COLUMN IF NOT EXISTS font_weight TEXT NOT NULL DEFAULT '';
ALTER TABLE canvas_nodes ADD COLUMN IF NOT EXISTS color TEXT NOT NULL DEFAULT '';
ALTER TABLE canvas_nodes ADD COLUMN IF NOT EXISTS text_align TEXT NOT NULL DEFAULT '';
ALTER TABLE canvas_nodes ADD COLUMN IF NOT EXISTS line_height DOUBLE PRECISION NOT NULL DEFAULT 0;
ALTER TABLE canvas_nodes ADD COLUMN IF NOT EXISTS letter_spacing DOUBLE PRECISION NOT NULL DEFAULT 0;
ALTER TABLE canvas_nodes ADD COLUMN IF NOT EXISTS text_decoration TEXT NOT NULL DEFAULT '';
ALTER TABLE canvas_nodes ADD COLUMN IF NOT EXISTS text_transform TEXT NOT NULL DEFAULT '';
ALTER TABLE canvas_nodes ADD COLUMN IF NOT EXISTS opacity DOUBLE PRECISION NOT NULL DEFAULT 0;
ALTER TABLE canvas_nodes ADD COLUMN IF NOT EXISTS fill_color TEXT NOT NULL DEFAULT '';
ALTER TABLE canvas_nodes ADD COLUMN IF NOT EXISTS stroke_color TEXT NOT NULL DEFAULT '';
ALTER TABLE canvas_nodes ADD COLUMN IF NOT EXISTS stroke_width DOUBLE PRECISION NOT NULL DEFAULT 0;
ALTER TABLE canvas_nodes ADD COLUMN IF NOT EXISTS stroke_style TEXT NOT NULL DEFAULT '';
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 rotation DOUBLE PRECISION NOT NULL DEFAULT 0;
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 TABLE IF NOT EXISTS mockup_blobs (
data_id TEXT PRIMARY KEY,
width INTEGER NOT NULL,
height INTEGER NOT NULL,
data BYTEA NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS canvas_connections (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
user_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001'::uuid,
from_node_id TEXT NOT NULL,
to_node_id TEXT NOT NULL
);
ALTER TABLE canvas_connections ADD COLUMN IF NOT EXISTS user_id UUID;
ALTER TABLE canvas_connections ALTER COLUMN user_id DROP DEFAULT;
ALTER TABLE canvas_connections ALTER COLUMN user_id TYPE UUID USING NULLIF(user_id::text, '')::uuid;
UPDATE canvas_connections
SET user_id = COALESCE((SELECT p.user_id FROM projects p WHERE p.id = canvas_connections.project_id), '00000000-0000-0000-0000-000000000001'::uuid)
WHERE user_id IS NULL;
ALTER TABLE canvas_connections ALTER COLUMN user_id SET DEFAULT '00000000-0000-0000-0000-000000000001'::uuid;
ALTER TABLE canvas_connections ALTER COLUMN user_id SET NOT NULL;
CREATE INDEX IF NOT EXISTS canvas_connections_project_id_idx ON canvas_connections(project_id);
CREATE INDEX IF NOT EXISTS canvas_connections_user_project_idx ON canvas_connections(user_id, project_id);
CREATE TABLE IF NOT EXISTS canvas_snapshots (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
user_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001'::uuid,
version TEXT NOT NULL,
canvas TEXT NOT NULL,
viewport_json JSONB NOT NULL,
nodes_json JSONB NOT NULL,
connections_json JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL
);
ALTER TABLE canvas_snapshots ALTER COLUMN user_id DROP DEFAULT;
ALTER TABLE canvas_snapshots ALTER COLUMN user_id TYPE UUID USING NULLIF(user_id::text, '')::uuid;
UPDATE canvas_snapshots SET user_id = '00000000-0000-0000-0000-000000000001'::uuid WHERE user_id IS NULL;
ALTER TABLE canvas_snapshots ALTER COLUMN user_id SET DEFAULT '00000000-0000-0000-0000-000000000001'::uuid;
ALTER TABLE canvas_snapshots ALTER COLUMN user_id SET NOT NULL;
CREATE INDEX IF NOT EXISTS canvas_snapshots_project_version_idx ON canvas_snapshots(user_id, project_id, created_at DESC);
CREATE TABLE IF NOT EXISTS agent_messages (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
user_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001'::uuid,
role TEXT NOT NULL,
message_type TEXT NOT NULL DEFAULT '',
title TEXT NOT NULL,
content TEXT NOT NULL,
thread_id TEXT NOT NULL DEFAULT '',
action_id TEXT NOT NULL DEFAULT '',
step_id TEXT NOT NULL DEFAULT '',
tool_call_id TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL DEFAULT '',
tool_hint TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL
);
ALTER TABLE agent_messages ADD COLUMN IF NOT EXISTS user_id UUID;
ALTER TABLE agent_messages ALTER COLUMN user_id DROP DEFAULT;
ALTER TABLE agent_messages ALTER COLUMN user_id TYPE UUID USING NULLIF(user_id::text, '')::uuid;
UPDATE agent_messages
SET user_id = COALESCE((SELECT p.user_id FROM projects p WHERE p.id = agent_messages.project_id), '00000000-0000-0000-0000-000000000001'::uuid)
WHERE user_id IS NULL;
ALTER TABLE agent_messages ALTER COLUMN user_id SET DEFAULT '00000000-0000-0000-0000-000000000001'::uuid;
ALTER TABLE agent_messages ALTER COLUMN user_id SET NOT NULL;
ALTER TABLE agent_messages ADD COLUMN IF NOT EXISTS message_type TEXT NOT NULL DEFAULT '';
ALTER TABLE agent_messages ADD COLUMN IF NOT EXISTS thread_id TEXT NOT NULL DEFAULT '';
ALTER TABLE agent_messages ADD COLUMN IF NOT EXISTS action_id TEXT NOT NULL DEFAULT '';
ALTER TABLE agent_messages ADD COLUMN IF NOT EXISTS step_id TEXT NOT NULL DEFAULT '';
ALTER TABLE agent_messages ADD COLUMN IF NOT EXISTS tool_call_id TEXT NOT NULL DEFAULT '';
ALTER TABLE agent_messages ADD COLUMN IF NOT EXISTS name TEXT NOT NULL DEFAULT '';
ALTER TABLE agent_messages ADD COLUMN IF NOT EXISTS tool_hint TEXT NOT NULL DEFAULT '';
ALTER TABLE agent_messages ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT '';
CREATE INDEX IF NOT EXISTS agent_messages_project_id_idx ON agent_messages(project_id);
CREATE INDEX IF NOT EXISTS agent_messages_user_project_idx ON agent_messages(user_id, project_id);
CREATE TABLE IF NOT EXISTS outbox_events (
id TEXT PRIMARY KEY,
user_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001'::uuid,
aggregate_type TEXT NOT NULL,
aggregate_id TEXT NOT NULL,
event_type TEXT NOT NULL,
thread_id TEXT NOT NULL DEFAULT '',
payload_json JSONB NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
published_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
ALTER TABLE outbox_events ALTER COLUMN user_id DROP DEFAULT;
ALTER TABLE outbox_events ALTER COLUMN user_id TYPE UUID USING NULLIF(user_id::text, '')::uuid;
UPDATE outbox_events SET user_id = '00000000-0000-0000-0000-000000000001'::uuid WHERE user_id IS NULL;
ALTER TABLE outbox_events ALTER COLUMN user_id SET DEFAULT '00000000-0000-0000-0000-000000000001'::uuid;
ALTER TABLE outbox_events ALTER COLUMN user_id SET NOT NULL;
CREATE INDEX IF NOT EXISTS outbox_events_status_created_idx ON outbox_events(status, created_at);
CREATE INDEX IF NOT EXISTS outbox_events_project_idx ON outbox_events(aggregate_type, aggregate_id, created_at);