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,323 @@
package websearch
import (
"context"
"fmt"
"html"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"img_infinite_canvas/internal/domain/design"
)
type DuckDuckGoOptions struct {
TimeoutSeconds int
MaxResults int
}
type DuckDuckGoResearcher struct {
client *http.Client
maxResults int
}
func NewDuckDuckGoResearcher(opts DuckDuckGoOptions) *DuckDuckGoResearcher {
timeout := time.Duration(opts.TimeoutSeconds) * time.Second
if timeout <= 0 {
timeout = 10 * time.Second
}
maxResults := opts.MaxResults
if maxResults <= 0 {
maxResults = 5
}
return &DuckDuckGoResearcher{
client: &http.Client{Timeout: timeout},
maxResults: maxResults,
}
}
func (r *DuckDuckGoResearcher) Research(ctx context.Context, prompt string) (design.ResearchReport, error) {
query := BuildVisualSearchQuery(prompt)
report := design.ResearchReport{
Kind: "web_search",
Source: "duckduckgo",
Query: query,
}
endpoint := "https://html.duckduckgo.com/html/?q=" + url.QueryEscape(query)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return report, err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; MotevaDesignAgent/1.0)")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
resp, err := r.client.Do(req)
if err != nil {
return report, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return report, fmt.Errorf("web search failed: %s", resp.Status)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
if err != nil {
return report, err
}
report.Results = parseDuckDuckGoResults(string(body), r.maxResults)
if len(report.Results) == 0 {
return report, fmt.Errorf("web search returned no results")
}
return report, nil
}
func BuildVisualSearchQuery(prompt string) string {
prompt = cleanPromptForSearch(prompt)
if prompt == "" {
return "brand visual design website inspiration"
}
lower := strings.ToLower(prompt)
if isLogoReferencePrompt(prompt, lower) {
return buildLogoReferenceQuery(prompt, lower)
}
keywords := []string{"website design reference", "brand visual style", "ecommerce ui"}
if strings.Contains(lower, "hoodie") || strings.Contains(prompt, "服装") || strings.Contains(prompt, "针织") {
keywords = []string{"fashion ecommerce website design", "hoodie brand visual reference", "streetwear UI"}
}
if strings.Contains(prompt, "海报") || strings.Contains(lower, "poster") {
keywords = []string{"poster design reference", "campaign key visual", "layout inspiration"}
}
if strings.Contains(prompt, "包装") || strings.Contains(lower, "packaging") {
keywords = []string{"packaging design reference", "brand identity system", "visual guidelines"}
}
if strings.Contains(lower, "ui") || strings.Contains(prompt, "界面") {
keywords = []string{"UI design reference", "product interface inspiration", "case study"}
}
return strings.Join(append([]string{prompt}, keywords...), " ")
}
func isLogoReferencePrompt(prompt string, lower string) bool {
return strings.Contains(lower, "logo") ||
strings.Contains(prompt, "品牌标识") ||
strings.Contains(prompt, "标志") ||
strings.Contains(prompt, "商标")
}
func buildLogoReferenceQuery(prompt string, lower string) string {
subject := logoReferenceSubject(prompt, lower)
terms := []string{
"logo design case study",
"brand identity",
"visual identity",
"design process",
"moodboard inspiration",
"Behance Dribbble",
"-maker",
"-generator",
"-free",
"-online",
"-template",
"-工具",
"-生成器",
"-制作器",
"-模板",
"-免费",
}
if hasCJK(prompt) {
terms = append([]string{
"logo 设计稿",
"品牌视觉",
"设计思路",
"案例",
"灵感",
}, terms...)
}
if subject != "" {
terms = append([]string{subject}, terms...)
}
return strings.Join(terms, " ")
}
func logoReferenceSubject(prompt string, lower string) string {
if strings.Contains(lower, "design agent") ||
strings.Contains(lower, "design-agent") ||
strings.Contains(prompt, "设计 Agent") ||
strings.Contains(prompt, "设计Agent") ||
strings.Contains(prompt, "设计智能体") ||
strings.Contains(prompt, "设计助手") {
return "AI design agent"
}
if strings.Contains(lower, "ai") && (strings.Contains(lower, "design") || strings.Contains(prompt, "设计")) {
return "AI design product"
}
subject := prompt
replacer := strings.NewReplacer(
"制作一个", " ",
"制作一款", " ",
"设计一个", " ",
"设计一款", " ",
"做一个", " ",
"做一款", " ",
"生成一个", " ",
"生成一款", " ",
"创建一个", " ",
"创建一款", " ",
"logo", " ",
"Logo", " ",
"LOGO", " ",
"品牌标识", " ",
"标志", " ",
"商标", " ",
"让设计变简单", " ",
"帮你搞定一切设计需求", " ",
"一切设计需求", " ",
"create a", " ",
"make a", " ",
"design a", " ",
"generate a", " ",
)
subject = replacer.Replace(subject)
subject = strings.Trim(subject, " ,,。.!?;:-—_")
fields := strings.Fields(subject)
if len(fields) > 8 {
fields = fields[:8]
}
return strings.Join(fields, " ")
}
func hasCJK(value string) bool {
for _, r := range value {
if r >= '\u4e00' && r <= '\u9fff' {
return true
}
}
return false
}
func cleanPromptForSearch(prompt string) string {
lines := strings.Split(prompt, "\n")
cleaned := make([]string, 0, len(lines))
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
if strings.HasPrefix(line, "模型选择") || strings.HasPrefix(line, "Selected model:") {
continue
}
if strings.HasPrefix(line, "请先进行联网参考") || strings.HasPrefix(line, "Start with web references") {
continue
}
if strings.HasPrefix(line, "参考图") || strings.HasPrefix(line, "Reference image") {
continue
}
cleaned = append(cleaned, line)
}
value := strings.Join(cleaned, " ")
fields := strings.Fields(value)
if len(fields) > 40 {
fields = fields[:40]
}
return strings.Join(fields, " ")
}
var (
resultLinkRe = regexp.MustCompile(`(?is)<a[^>]*class="[^"]*\bresult__a\b[^"]*"[^>]*href="([^"]+)"[^>]*>(.*?)</a>`)
snippetRe = regexp.MustCompile(`(?is)<a[^>]*class="[^"]*\bresult__snippet\b[^"]*"[^>]*>(.*?)</a>`)
tagRe = regexp.MustCompile(`(?is)<[^>]+>`)
spaceRe = regexp.MustCompile(`\s+`)
)
func parseDuckDuckGoResults(body string, maxResults int) []design.ResearchResult {
matches := resultLinkRe.FindAllStringSubmatchIndex(body, -1)
results := make([]design.ResearchResult, 0, maxResults)
seen := map[string]bool{}
for index, match := range matches {
if len(results) >= maxResults {
break
}
rawURL := body[match[2]:match[3]]
titleHTML := body[match[4]:match[5]]
resolvedURL := resolveDuckDuckGoURL(rawURL)
if resolvedURL == "" {
continue
}
key := dedupeKey(resolvedURL)
if key == "" || seen[key] {
continue
}
nextStart := len(body)
if index+1 < len(matches) {
nextStart = matches[index+1][0]
}
segment := body[match[1]:nextStart]
snippet := ""
if snippetMatch := snippetRe.FindStringSubmatch(segment); len(snippetMatch) > 1 {
snippet = cleanHTML(snippetMatch[1])
}
title := cleanHTML(titleHTML)
if title == "" {
continue
}
seen[key] = true
results = append(results, design.ResearchResult{
Title: title,
URL: resolvedURL,
Snippet: snippet,
})
}
return results
}
func resolveDuckDuckGoURL(raw string) string {
raw = html.UnescapeString(strings.TrimSpace(raw))
if strings.HasPrefix(raw, "//") {
raw = "https:" + raw
}
parsed, err := url.Parse(raw)
if err != nil {
return ""
}
if strings.Contains(parsed.Host, "duckduckgo.com") {
if uddg := parsed.Query().Get("uddg"); uddg != "" {
raw = uddg
}
}
if !strings.HasPrefix(raw, "http://") && !strings.HasPrefix(raw, "https://") {
return ""
}
return raw
}
func cleanHTML(value string) string {
value = tagRe.ReplaceAllString(value, " ")
value = html.UnescapeString(value)
value = spaceRe.ReplaceAllString(value, " ")
return strings.TrimSpace(value)
}
func dedupeKey(raw string) string {
parsed, err := url.Parse(raw)
if err != nil {
return ""
}
host := strings.TrimPrefix(strings.ToLower(parsed.Host), "www.")
return host + strings.TrimRight(parsed.Path, "/")
}
@@ -0,0 +1,41 @@
package websearch
import (
"strings"
"testing"
)
func TestParseDuckDuckGoResults(t *testing.T) {
body := `
<a rel="nofollow" class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com%2Ffashion&amp;rut=abc">Fashion Website Design</a>
<a class="result__snippet" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com%2Ffashion&amp;rut=abc">A <b>fashion</b> ecommerce homepage reference.</a>
<a rel="nofollow" class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.org%2Fhoodie&amp;rut=def">Hoodie Brand System</a>
<a class="result__snippet" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.org%2Fhoodie&amp;rut=def">Brand and hoodie visual language.</a>`
results := parseDuckDuckGoResults(body, 5)
if len(results) != 2 {
t.Fatalf("expected 2 results, got %d", len(results))
}
if results[0].Title != "Fashion Website Design" {
t.Fatalf("unexpected title: %s", results[0].Title)
}
if results[0].URL != "https://example.com/fashion" {
t.Fatalf("unexpected url: %s", results[0].URL)
}
if results[0].Snippet != "A fashion ecommerce homepage reference." {
t.Fatalf("unexpected snippet: %s", results[0].Snippet)
}
}
func TestBuildVisualSearchQueryForLogoReferencesAvoidsMakerTools(t *testing.T) {
query := BuildVisualSearchQuery("Moteva 让设计变简单懂你的设计 Agent,帮我搞定一切设计需求,制作一个 logo")
for _, want := range []string{"AI design agent", "logo 设计稿", "设计思路", "brand identity", "-maker", "-generator", "-template"} {
if !strings.Contains(query, want) {
t.Fatalf("expected query to contain %q, got %q", want, query)
}
}
if strings.Contains(query, "制作一个 logo") {
t.Fatalf("expected query to avoid raw prompt wording, got %q", query)
}
}