diff --git a/README.md b/README.md index ef8b777..06b4cc6 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ It is meant to validate these product-facing APIs first: - `run` - `jobs` - `artifacts` +- `media` - basic `budget`, `project`, and `models` stubs ## Project Relationship @@ -21,6 +22,8 @@ See [docs/project-relationship.md](./docs/project-relationship.md). Persistence details for the current local backend are documented in [docs/persistence.md](./docs/persistence.md). +Stable media URL behavior and rollout notes are documented in +[docs/stable-media-url-v1.md](./docs/stable-media-url-v1.md). This note explains how `popiartcli`, `popiartServer`, and `PopiNewAPI` split responsibilities, and why routing, billing attribution, and provider access belong in the backend layers rather than the CLI. @@ -48,6 +51,8 @@ Use it from `popiartcli`: cd /Users/jiajia/popiartcli go run ./cmd/popiart --endpoint http://127.0.0.1:8080/v1 auth login --key go run ./cmd/popiart --endpoint http://127.0.0.1:8080/v1 skills list +go run ./cmd/popiart --endpoint http://127.0.0.1:8080/v1 media upload ./source.png +go run ./cmd/popiart --endpoint http://127.0.0.1:8080/v1 artifacts upload ./source.png --role source ``` Optional skillhub source: @@ -61,7 +66,12 @@ In local development, `/tmp/Popiart_skillhub` is used automatically when it exis ## Notes - Local persistence is intentionally thin: `sessions`, `job refs`, and project route overrides live in SQLite. -- `artifact` no longer has a local blob store. For sync image results, the server stores `result_refs_json` in the job row and derives artifact metadata on demand. +- The local backend now keeps a lightweight media store under `POPIART_DATA_DIR/media/` and exposes stable `media` URLs for uploaded files and persisted job outputs. +- `artifact` metadata is still derived from `result_refs_json`, but new artifacts bind to a local `media_id` and a stable `url`. +- New endpoints now include: + - `POST /v1/media/upload` + - `GET /v1/media/:id` + - `GET /v1/media/:id/content` - `job logs` are synthesized from job state transitions instead of being stored as a separate table. - The local development backend verifies your login key against the local `PopiNewAPI`. - This server is a development backend, not the final production architecture. diff --git a/docs/persistence.md b/docs/persistence.md index bd2ce5a..f940023 100644 --- a/docs/persistence.md +++ b/docs/persistence.md @@ -14,7 +14,6 @@ 所以 `popiartServer` 不再重复持有: -- 本地 artifact blob store - 本地 artifact metadata table - 本地 job_logs table @@ -23,6 +22,7 @@ - `sessions` - `jobs`(更准确说是 job refs) - `skill_routes` +- `media blobs + metadata` ## 当前关系 @@ -30,14 +30,16 @@ session -> jobs jobs -> result_refs_json project -> skill_routes +media -> local files + metadata json ``` 其中: - `session` 保存 PopiArt 登录态和对应的 `PopiNewAPI token` - `jobs` 保存 skill 语义、用户归属、上游引用和同步结果引用 -- `result_refs_json` 保存同步能力的返回引用,例如 `data_url` 或远端 `url` +- `result_refs_json` 保存同步能力的结果引用;新结果会优先 re-host 到本地 media store - `skill_routes` 保存项目级路由覆盖 +- `media` 保存稳定 URL 所需的本地文件与元数据 ## 存储位置 @@ -66,7 +68,10 @@ POPIART_SQLITE_PATH=./data/popiart.db 2. `JobRepository` 3. `RouteRepository` -没有单独的 blob storage 抽象。 +本地开发版没有对象存储依赖,但现在有一个轻量 media 存储层: + +- blob 文件保存在 `POPIART_DATA_DIR/media/blobs/` +- metadata JSON 保存在 `POPIART_DATA_DIR/media/meta/` ## SQLite Schema @@ -141,15 +146,24 @@ CREATE TABLE skill_routes ( ### 完成同步结果 job 1. 如果 `PopiNewAPI` 直接返回同步结果,例如 `b64_json` 或 `url` -2. `popiartServer` 只把它转换成 `result_refs_json` -3. `GET /jobs/:id/artifacts` 时再从 `result_refs_json` 派生 artifact 列表 +2. `popiartServer` 会先把结果 re-host 到本地 media store +3. 再把本地 `local_path + media_id + stable url` 写进 `result_refs_json` +4. `GET /jobs/:id/artifacts` 时再从 `result_refs_json` 派生 artifact 列表 ### 拉取 artifact 1. server 从 `artifact_id` 反推出 `job_id + result index` 2. 读取 `jobs.result_refs_json` -3. 如果是 `data_url`,直接解码并流式返回 -4. 如果是远端 `url`,server 代理下载并返回 +3. 如果是本地 `local_path`,直接读取本地文件并流式返回 +4. 如果是旧的 `data_url`,直接解码并流式返回 +5. 如果是旧的远端 `url`,server 代理下载并返回 + +### 读取 media + +1. `POST /v1/media/upload` 会把本地文件写入 `media/blobs/` +2. server 同时写一份 metadata JSON 到 `media/meta/` +3. `GET /v1/media/:id` 返回 media 元数据 +4. `GET /v1/media/:id/content` 返回可供模型或客户端直接 fetch 的稳定内容路径 ## 当前边界 @@ -157,7 +171,8 @@ CREATE TABLE skill_routes ( - `session` 会保留 - `job ref` 会保留 - `artifact` 通过 `result_refs_json` 继续可读 + - 新 `media` 文件和 metadata 会继续可读 - 已存在的 `pending/running` job 仍然不会自动恢复执行 - 视频类 `upstream_task` 路径只预留了字段,后续再接 `PopiNewAPI` task 查询 -- 对同步图像结果来说,`data_url` 仍然会落在 SQLite 的 `result_refs_json` - 这是当前 `PopiNewAPI` 没有统一 file id 的现实折中,但已经不再有本地 artifact 表和 blob store +- 旧数据里仍可能存在 `data_url` 或上游 `url` +- 新写入路径优先落本地 media store,从而给 artifact 补出稳定 `url` diff --git a/docs/project-relationship.md b/docs/project-relationship.md index af944ef..1d88414 100644 --- a/docs/project-relationship.md +++ b/docs/project-relationship.md @@ -9,7 +9,7 @@ | 项目 | 角色 | 应该负责 | 不应该负责 | |---|---|---|---| | `popiartcli` | 面向 coding agent 和创作者的统一 CLI 入口 | 登录、发现 skill、调用 skill、查看 jobs、拉取 artifacts、本地配置 | 不直接持有上游 provider key,不直接做模型路由,不直接做供应商计费 | -| `popiartServer` | PopiArt 产品后端 | 用户鉴权、项目权限、skill 注册表聚合、skill 执行、job 引用管理、artifact read-through、路由决策、计费归因 | 不把供应商细节暴露给 CLI,不把 skillhub 直接耦合到 CLI,不重复实现 `PopiNewAPI` 已有的模型网关能力 | +| `popiartServer` | PopiArt 产品后端 | 用户鉴权、项目权限、skill 注册表聚合、skill 执行、job 引用管理、artifact 与 media 管理、稳定 URL 生成、路由决策、计费归因 | 不把供应商细节暴露给 CLI,不把 skillhub 直接耦合到 CLI,不重复实现 `PopiNewAPI` 已有的模型网关能力 | | `PopiNewAPI` | 模型网关和通道管理层 | 管理上游渠道和 key、代理模型请求、记录原始用量、提供模型层能力 | 不承载 PopiArt 的 skill 业务语义,不负责 CLI 交互,不负责产品级项目上下文 | ## 标准调用链路 @@ -58,7 +58,8 @@ GitHub skillhub / skillhub.popi.art - 上游 provider key 管理:`PopiNewAPI` - 原始模型调用计量:`PopiNewAPI` - 面向 skill / project / user 的计费归因:`popiartServer` -- artifact 文件存储与 task 内容代理:优先复用 `PopiNewAPI`,`popiartServer` 只做 read-through +- artifact 与 media 的产品级持久化、稳定 URL 和生命周期:`popiartServer` +- provider 专属任务代理、task 内容代理与供应商差异适配:`PopiNewAPI` 一个重要原则是: @@ -96,6 +97,7 @@ CLI 只拿产品层 key;后端再用自己的方式调用 `PopiNewAPI`。 - 图生图 - 图生视频 +- 稳定媒体 URL 与 artifact/media 复用 - 更多供应商和项目级路由覆盖 ## 什么时候改哪个仓库 diff --git a/docs/stable-media-url-v1.md b/docs/stable-media-url-v1.md new file mode 100644 index 0000000..b5edc16 --- /dev/null +++ b/docs/stable-media-url-v1.md @@ -0,0 +1,129 @@ +# popiartServer Stable Media URL V1 + +这份文档只描述 `popiartServer` 这一层为了支持稳定媒体 URL 所做的职责扩展,不覆盖 `popiartcli` 的命令面,也不要求修改 `PopiNewAPI` 的现有通道实现。 + +## 背景 + +原有本地开发版 `popiartServer` 更偏向: + +- `artifact` read-through +- `result_refs_json` 存 `data_url` 或上游临时 `url` +- `GET /artifacts/:id/content` 由 server 当场去解码或代理下载 + +这种实现能跑通基本流程,但不适合多模态 skill 的复用: + +- `img2img` 经常还要重新拉流或转 base64 +- 上游签名 URL 可能过期 +- 前一个 job 的输出不能稳定地作为下一个 job 的 URL 输入 + +## V1 目标 + +`popiartServer` 在不依赖外部对象存储的前提下,先提供一套本地可运行的稳定媒体 URL 能力: + +- 本地上传文件可立即获得稳定 URL +- 新生成的 job 结果会被 re-host 到本地 media store +- 新 artifact 会携带 `media_id` 和 `url` +- `image2video` 的 `vidu*` 路由可以直接复用 artifact URL + +## 新接口 + +### `POST /v1/media/upload` + +上传一个本地文件,直接创建 media 记录并返回: + +- `id` +- `project_id` +- `filename` +- `content_type` +- `size_bytes` +- `created_at` +- `url` +- `visibility` +- `sha256` + +### `GET /v1/media/:id` + +读取 media 元数据。这个接口需要登录态,并要求 media 属于当前用户。 + +### `GET /v1/media/:id/content` + +读取稳定内容 URL。这个接口默认允许匿名 GET,以便模型提供商可以直接 fetch。 + +## Artifact 行为变化 + +`POST /v1/artifacts/upload` 现在不再只把内容塞成 `data_url`: + +1. server 先把上传文件写入本地 media store +2. 再创建本地 upload job +3. 最终 artifact 返回: + - `id` + - `media_id` + - `url` + - `visibility` + - `sha256` + - `storage_status` + +## Job 结果行为变化 + +对于新的 `text2image`、`img2img`、`image2video` 结果: + +1. 先从 `PopiNewAPI` 或上游临时结果读出真实内容 +2. re-host 到 `POPIART_DATA_DIR/media/` +3. 把 `local_path + media_id + stable url` 写回 `result_refs_json` + +这样新的 artifact 不再依赖上游临时 URL。 + +## 本地 media store + +V1 的本地开发版不引入 S3/R2/OSS,而是用本地文件系统: + +- blob 文件:`POPIART_DATA_DIR/media/blobs/` +- metadata JSON:`POPIART_DATA_DIR/media/meta/` + +如果 `POPIART_DATA_DIR` 没显式配置,但 `POPIART_SQLITE_PATH` 已配置,则 `DataDir` 会自动落到 SQLite 所在目录,避免测试把 media 文件写进源码目录。 + +## 当前已打通的 URL 复用 + +### Artifact / media + +- `media upload` -> stable URL +- `artifact upload` -> stable URL +- 新 `artifact` 的 `GET /artifacts/:id` -> `url` + +### Runtime output + +- `text2image` 输出会 re-host +- `img2img` 输出会 re-host +- `image2video` 输出会 re-host + +### URL-first dispatch + +当前已优先 URL 化的路径是: + +- `video.image2video` +- 当模型 ID 以 `vidu` 开头,且 reference 已有 stable URL 时,server 会优先用 JSON `images` 传给 `/v1/videos` + +这是为了先覆盖当前测试环境最常用的 `viduq2-pro-fast` 路由。 + +## 兼容策略 + +旧数据仍然兼容: + +- 如果 `result_ref.kind == data_url`,继续解码读取 +- 如果 `result_ref.kind == url`,继续代理下载 +- 如果 `result_ref.kind == local_path`,优先从本地 media store 读取 + +也就是说: + +- 历史 artifact 不会立即失效 +- 新 artifact 才开始享受稳定 URL + +## 后续阶段 + +后续如果要走生产化路线,可以保持接口不变,只把存储实现替换成对象存储: + +- 本地 `media/blobs/` -> S3 / R2 / OSS / COS +- metadata JSON -> 独立 media table 或对象存储元数据 +- `GET /v1/media/:id/content` -> CDN / 受控稳定 URL + +但这一步不是 V1 本地开发验证的前置条件。 diff --git a/internal/server/artifact_upload_test.go b/internal/server/artifact_upload_test.go index 7b69eba..e0ef795 100644 --- a/internal/server/artifact_upload_test.go +++ b/internal/server/artifact_upload_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "io" "mime/multipart" "net/http" "net/http/httptest" @@ -21,6 +22,9 @@ func TestArtifactUploadCreatesReadableArtifactForSourceArtifactID(t *testing.T) if err != nil { t.Fatalf("NewWithConfig: %v", err) } + srv := httptest.NewServer(server.Handler()) + defer srv.Close() + server.cfg.PublicBaseURL = srv.URL sessionToken, _, ok, err := server.store.createSession("sk-upload-user") if err != nil { @@ -80,9 +84,34 @@ func TestArtifactUploadCreatesReadableArtifactForSourceArtifactID(t *testing.T) if envelope.Data.ID == "" { t.Fatalf("expected artifact id, got %#v", envelope.Data) } + if envelope.Data.MediaID == "" { + t.Fatalf("expected media id, got %#v", envelope.Data) + } if envelope.Data.ContentType != "image/png" { t.Fatalf("expected image/png, got %q", envelope.Data.ContentType) } + if envelope.Data.URL == "" { + t.Fatalf("expected stable artifact url, got %#v", envelope.Data) + } + if envelope.Data.StorageStatus != "ready" { + t.Fatalf("expected storage status ready, got %#v", envelope.Data.StorageStatus) + } + + contentResp, err := http.Get(envelope.Data.URL) + if err != nil { + t.Fatalf("GET media content: %v", err) + } + defer contentResp.Body.Close() + if contentResp.StatusCode != http.StatusOK { + t.Fatalf("expected media content 200, got %d", contentResp.StatusCode) + } + streamed, err := io.ReadAll(contentResp.Body) + if err != nil { + t.Fatalf("read media content: %v", err) + } + if !bytes.Equal(streamed, imageBytes) { + t.Fatal("expected stable media url to serve uploaded bytes") + } editRef, err := server.resolveImageToImageReference(context.Background(), &job{ UserID: current.User.ID, diff --git a/internal/server/media.go b/internal/server/media.go new file mode 100644 index 0000000..fcbc801 --- /dev/null +++ b/internal/server/media.go @@ -0,0 +1,193 @@ +package server + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/url" + "os" + "path/filepath" + "strings" + "time" +) + +type mediaRecord struct { + media + UserID string `json:"user_id"` + LocalPath string `json:"local_path"` +} + +func mediaDir(cfg Config) string { + return filepath.Join(cfg.DataDir, "media") +} + +func mediaBlobDir(cfg Config) string { + return filepath.Join(mediaDir(cfg), "blobs") +} + +func mediaMetaDir(cfg Config) string { + return filepath.Join(mediaDir(cfg), "meta") +} + +func mediaMetaPath(cfg Config, mediaID string) string { + return filepath.Join(mediaMetaDir(cfg), mediaID+".json") +} + +func mediaBlobPath(cfg Config, mediaID, filename string) string { + ext := filepath.Ext(filename) + if ext == "" { + ext = extensionFromContentType("") + } + return filepath.Join(mediaBlobDir(cfg), mediaID+ext) +} + +func publicBaseURL(cfg Config) string { + base := strings.TrimSpace(cfg.PublicBaseURL) + if base == "" { + base = strings.TrimSpace(os.Getenv("POPIART_SERVER_ADDR")) + } + if base == "" { + base = "127.0.0.1:8080" + } + if !strings.HasPrefix(base, "http://") && !strings.HasPrefix(base, "https://") { + base = "http://" + base + } + base = strings.Replace(base, "0.0.0.0", "127.0.0.1", 1) + return strings.TrimRight(base, "/") +} + +func mediaContentURL(cfg Config, mediaID string) string { + return publicBaseURL(cfg) + "/v1/media/" + url.PathEscape(strings.TrimSpace(mediaID)) + "/content" +} + +func persistMediaContent(cfg Config, userID, projectID, artifactID, filename, contentType, visibility string, content []byte, mediaID string) (mediaRecord, error) { + if len(content) == 0 { + return mediaRecord{}, fmt.Errorf("media content is required") + } + filename = sanitizeFilename(strings.TrimSpace(filename)) + if filename == "" || filename == "artifact" { + filename = "artifact" + extensionFromContentType(contentType) + } + contentType = strings.TrimSpace(contentType) + if contentType == "" { + contentType = "application/octet-stream" + } + visibility = strings.TrimSpace(visibility) + if visibility == "" { + visibility = "unlisted" + } + mediaID = strings.TrimSpace(mediaID) + if mediaID == "" { + mediaID = "med_" + randomID() + } + if err := os.MkdirAll(mediaBlobDir(cfg), 0o755); err != nil { + return mediaRecord{}, err + } + if err := os.MkdirAll(mediaMetaDir(cfg), 0o755); err != nil { + return mediaRecord{}, err + } + + sum := sha256.Sum256(content) + blobPath := mediaBlobPath(cfg, mediaID, filename) + if err := os.WriteFile(blobPath, content, 0o644); err != nil { + return mediaRecord{}, err + } + + record := mediaRecord{ + media: media{ + ID: mediaID, + ArtifactID: strings.TrimSpace(artifactID), + ProjectID: strings.TrimSpace(projectID), + Filename: filename, + ContentType: contentType, + SizeBytes: int64(len(content)), + CreatedAt: time.Now().UTC().Format(time.RFC3339), + URL: mediaContentURL(cfg, mediaID), + Visibility: visibility, + SHA256: hex.EncodeToString(sum[:]), + }, + UserID: strings.TrimSpace(userID), + LocalPath: blobPath, + } + + metaBytes, err := json.Marshal(record) + if err != nil { + return mediaRecord{}, err + } + if err := os.WriteFile(mediaMetaPath(cfg, mediaID), metaBytes, 0o644); err != nil { + return mediaRecord{}, err + } + return record, nil +} + +func loadMediaRecord(cfg Config, mediaID string) (*mediaRecord, bool, error) { + mediaID = strings.TrimSpace(mediaID) + if mediaID == "" { + return nil, false, nil + } + data, err := os.ReadFile(mediaMetaPath(cfg, mediaID)) + if os.IsNotExist(err) { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + var record mediaRecord + if err := json.Unmarshal(data, &record); err != nil { + return nil, false, err + } + return &record, true, nil +} + +func (s *Server) persistResultRefs(ctx context.Context, record *job, refs []resultRef) ([]resultRef, error) { + if record == nil || len(refs) == 0 { + return refs, nil + } + items := make([]resultRef, 0, len(refs)) + for idx, ref := range refs { + contentType, _, reader, err := s.newapi.openResultRef(ctx, record.UpstreamKey, ref) + if err != nil { + return nil, err + } + content, readErr := io.ReadAll(reader) + reader.Close() + if readErr != nil { + return nil, readErr + } + filename := strings.TrimSpace(ref.Filename) + if filename == "" { + filename = inferFilenameFromRef(ref, buildArtifactID(record.JobID, idx)) + } + artifactID := buildArtifactID(record.JobID, idx) + mediaRecord, err := persistMediaContent( + s.cfg, + record.UserID, + record.ProjectID, + artifactID, + filename, + defaultString(strings.TrimSpace(contentType), strings.TrimSpace(ref.ContentType)), + defaultString(strings.TrimSpace(ref.Visibility), "unlisted"), + content, + "", + ) + if err != nil { + return nil, err + } + items = append(items, resultRef{ + Kind: "local_path", + URL: mediaRecord.URL, + LocalPath: mediaRecord.LocalPath, + MediaID: mediaRecord.ID, + Filename: mediaRecord.Filename, + ContentType: mediaRecord.ContentType, + SizeBytes: mediaRecord.SizeBytes, + Visibility: mediaRecord.Visibility, + SHA256: mediaRecord.SHA256, + StorageStatus: "ready", + }) + } + return items, nil +} diff --git a/internal/server/media_test.go b/internal/server/media_test.go new file mode 100644 index 0000000..7a2a48c --- /dev/null +++ b/internal/server/media_test.go @@ -0,0 +1,106 @@ +package server + +import ( + "bytes" + "encoding/json" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" +) + +func TestMediaUploadGetAndContent(t *testing.T) { + cfg := Config{ + SQLitePath: filepath.Join(t.TempDir(), "popiart.db"), + SkillhubDir: makeEmptySkillhub(t), + SessionSecret: "test-secret", + } + server, err := NewWithConfig(cfg) + if err != nil { + t.Fatalf("NewWithConfig: %v", err) + } + srv := httptest.NewServer(server.Handler()) + defer srv.Close() + server.cfg.PublicBaseURL = srv.URL + + sessionToken, _, ok, err := server.store.createSession("sk-media-user") + if err != nil { + t.Fatalf("createSession: %v", err) + } + if !ok { + t.Fatal("expected session creation to succeed") + } + + imageBytes := tinyPNG(t) + var body bytes.Buffer + writer := multipart.NewWriter(&body) + if err := writer.WriteField("project_id", "proj_media_demo"); err != nil { + t.Fatalf("write project_id field: %v", err) + } + if err := writer.WriteField("visibility", "public"); err != nil { + t.Fatalf("write visibility field: %v", err) + } + part, err := writer.CreateFormFile("file", "poster.png") + if err != nil { + t.Fatalf("create form file: %v", err) + } + if _, err := part.Write(imageBytes); err != nil { + t.Fatalf("write form file: %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("close multipart writer: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/v1/media/upload", &body) + req.Header.Set("Authorization", "Bearer "+sessionToken) + req.Header.Set("Content-Type", writer.FormDataContentType()) + rec := httptest.NewRecorder() + server.Handler().ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("expected status 201, got %d body=%s", rec.Code, rec.Body.String()) + } + + var envelope struct { + OK bool `json:"ok"` + Data media `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil { + t.Fatalf("decode upload response: %v body=%s", err, rec.Body.String()) + } + if !envelope.OK { + t.Fatalf("expected ok upload response, got %s", rec.Body.String()) + } + if envelope.Data.ID == "" { + t.Fatalf("expected media id, got %#v", envelope.Data) + } + if envelope.Data.URL == "" { + t.Fatalf("expected stable url, got %#v", envelope.Data) + } + + getReq := httptest.NewRequest(http.MethodGet, "/v1/media/"+envelope.Data.ID, nil) + getReq.Header.Set("Authorization", "Bearer "+sessionToken) + getRec := httptest.NewRecorder() + server.Handler().ServeHTTP(getRec, getReq) + if getRec.Code != http.StatusOK { + t.Fatalf("expected media get 200, got %d body=%s", getRec.Code, getRec.Body.String()) + } + + contentResp, err := http.Get(envelope.Data.URL) + if err != nil { + t.Fatalf("GET media content: %v", err) + } + defer contentResp.Body.Close() + if contentResp.StatusCode != http.StatusOK { + t.Fatalf("expected media content 200, got %d", contentResp.StatusCode) + } + content, err := io.ReadAll(contentResp.Body) + if err != nil { + t.Fatalf("read content body: %v", err) + } + if !bytes.Equal(content, imageBytes) { + t.Fatal("expected media content bytes to match uploaded file") + } +} diff --git a/internal/server/newapi.go b/internal/server/newapi.go index 9be4d52..25a5807 100644 --- a/internal/server/newapi.go +++ b/internal/server/newapi.go @@ -30,6 +30,7 @@ type Config struct { NewAPIToken string DefaultImageModel string DefaultVideoModel string + PublicBaseURL string DataDir string SQLitePath string SessionSecret string @@ -42,11 +43,16 @@ func ConfigFromEnv() Config { NewAPIToken: strings.TrimSpace(os.Getenv("POPIART_NEWAPI_TOKEN")), DefaultImageModel: strings.TrimSpace(os.Getenv("POPIART_DEFAULT_IMAGE_MODEL")), DefaultVideoModel: strings.TrimSpace(os.Getenv("POPIART_DEFAULT_VIDEO_MODEL")), + PublicBaseURL: strings.TrimSpace(os.Getenv("POPIART_PUBLIC_BASE_URL")), DataDir: strings.TrimSpace(os.Getenv("POPIART_DATA_DIR")), SQLitePath: strings.TrimSpace(os.Getenv("POPIART_SQLITE_PATH")), SessionSecret: strings.TrimSpace(os.Getenv("POPIART_SESSION_SECRET")), SkillhubDir: strings.TrimSpace(os.Getenv("POPIART_SKILLHUB_DIR")), } + return normalizeConfig(cfg) +} + +func normalizeConfig(cfg Config) Config { if cfg.NewAPIBaseURL == "" { cfg.NewAPIBaseURL = "http://127.0.0.1:3000" } @@ -57,7 +63,11 @@ func ConfigFromEnv() Config { cfg.DefaultVideoModel = "viduq2" } if cfg.DataDir == "" { - cfg.DataDir = "./data" + if strings.TrimSpace(cfg.SQLitePath) != "" { + cfg.DataDir = filepath.Dir(cfg.SQLitePath) + } else { + cfg.DataDir = "./data" + } } if cfg.SQLitePath == "" { cfg.SQLitePath = filepath.Join(cfg.DataDir, "popiart.db") @@ -131,6 +141,7 @@ type imageEditReference struct { Filename string ContentType string Content []byte + URL string } type openAIVideoResponse struct { @@ -587,7 +598,7 @@ func (c *newAPIClient) generateGeminiImageRefs(ctx context.Context, token, model "generationConfig": generationConfig, } if len(imageConfig) > 0 { - payload["imageConfig"] = imageConfig + generationConfig["imageConfig"] = imageConfig } body, err := json.Marshal(payload) @@ -629,6 +640,9 @@ func (c *newAPIClient) submitImageToVideoTask(ctx context.Context, token, modelI if prompt == "" { prompt = "Generate a short polished image-to-video clip from the provided reference image." } + if strings.HasPrefix(strings.ToLower(strings.TrimSpace(modelID)), "vidu") && strings.TrimSpace(ref.URL) != "" { + return c.submitImageToVideoTaskByURL(ctx, token, modelID, input, ref, prompt) + } if len(ref.Content) == 0 { return "", errors.New("reference image content is required") } @@ -713,6 +727,71 @@ func (c *newAPIClient) submitImageToVideoTask(ctx context.Context, token, modelI return taskID, nil } +func (c *newAPIClient) submitImageToVideoTaskByURL(ctx context.Context, token, modelID string, input map[string]any, ref imageEditReference, prompt string) (string, error) { + payload := map[string]any{ + "model": modelID, + "prompt": prompt, + "images": []string{strings.TrimSpace(ref.URL)}, + "duration": 5, + } + if duration := strings.TrimSpace(resolveVideoDurationSeconds(input)); duration != "" { + if parsed, err := strconv.Atoi(duration); err == nil && parsed > 0 { + payload["duration"] = parsed + } + } + if size := resolveVideoSize(modelID, input, ref); size != "" { + payload["size"] = size + } + if aspectRatio := resolveVideoAspectRatio(input, ref); aspectRatio != "" { + payload["metadata"] = map[string]any{ + "aspect_ratio": aspectRatio, + } + } + + body, err := json.Marshal(payload) + if err != nil { + return "", err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/videos", bytes.NewReader(body)) + if err != nil { + return "", err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + var decoded openAIVideoResponse + if err := json.Unmarshal(respBody, &decoded); err != nil { + return "", fmt.Errorf("decode PopiNewAPI video submit response: %w", err) + } + if resp.StatusCode >= 400 { + return "", decodeTaskAPIError(respBody, resp.StatusCode) + } + if decoded.Error != nil && strings.TrimSpace(decoded.Error.Message) != "" { + return "", errors.New(decoded.Error.Message) + } + + taskID := strings.TrimSpace(decoded.ID) + if taskID == "" { + taskID = strings.TrimSpace(decoded.TaskID) + } + if taskID == "" { + return "", errors.New("PopiNewAPI returned no task id") + } + return taskID, nil +} + func (c *newAPIClient) fetchVideoTask(ctx context.Context, token, taskID string) (*videoTaskResult, error) { if !c.enabled() { return nil, errors.New("PopiNewAPI base URL is not configured") @@ -997,6 +1076,12 @@ func resolveGeminiImageSize(input map[string]any) string { func (c *newAPIClient) openResultRef(ctx context.Context, token string, ref resultRef) (string, int64, io.ReadCloser, error) { switch ref.Kind { + case "local_path": + file, err := os.Open(ref.LocalPath) + if err != nil { + return "", 0, nil, err + } + return defaultString(strings.TrimSpace(ref.ContentType), "application/octet-stream"), ref.SizeBytes, file, nil case "data_url": contentType, content, err := decodeDataURL(ref.DataURL) if err != nil { @@ -1137,6 +1222,24 @@ func resolveVideoSize(modelID string, input map[string]any, ref imageEditReferen return size } +func resolveVideoAspectRatio(input map[string]any, ref imageEditReference) string { + if input != nil { + if aspectRatio := strings.TrimSpace(stringValue(input["aspect_ratio"])); aspectRatio != "" { + return aspectRatio + } + } + if len(ref.Content) > 0 { + cfg, _, err := image.DecodeConfig(bytes.NewReader(ref.Content)) + if err == nil && cfg.Width > 0 && cfg.Height > 0 { + if cfg.Width >= cfg.Height { + return "16:9" + } + return "9:16" + } + } + return "" +} + func resolveBaseVideoSize(input map[string]any, ref imageEditReference) string { if input != nil { if size := strings.TrimSpace(stringValue(input["size"])); size != "" { diff --git a/internal/server/server.go b/internal/server/server.go index 20dd7de..82173f1 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -9,6 +9,7 @@ import ( "io" "log" "net/http" + "os" "path/filepath" "strconv" "strings" @@ -31,6 +32,7 @@ func New() *Server { } func NewWithConfig(cfg Config) (*Server, error) { + cfg = normalizeConfig(cfg) store, err := newStore(cfg) if err != nil { return nil, err @@ -59,6 +61,8 @@ func (s *Server) routes() { s.mux.HandleFunc("/v1/skills/", s.handleSkill) s.mux.HandleFunc("/v1/jobs", s.handleJobs) s.mux.HandleFunc("/v1/jobs/", s.handleJob) + s.mux.HandleFunc("/v1/media/upload", s.handleMediaUpload) + s.mux.HandleFunc("/v1/media/", s.handleMedia) s.mux.HandleFunc("/v1/artifacts/upload", s.handleArtifactUpload) s.mux.HandleFunc("/v1/artifacts/", s.handleArtifact) s.mux.HandleFunc("/v1/budget", s.handleBudget) @@ -468,6 +472,7 @@ func (s *Server) handleArtifactUpload(w http.ResponseWriter, r *http.Request) { filename = "artifact" + extensionFromContentType(contentType) } filename = sanitizeFilename(filename) + visibility := strings.TrimSpace(r.FormValue("visibility")) input := map[string]any{ "filename": filename, @@ -499,12 +504,33 @@ func (s *Server) handleArtifactUpload(w http.ResponseWriter, r *http.Request) { return } + refMedia, err := persistMediaContent( + s.cfg, + current.User.ID, + projectID, + buildArtifactID(record.JobID, 0), + filename, + contentType, + visibility, + content, + "", + ) + if err != nil { + writeInternalError(w, "failed to persist upload media", err) + return + } + ref := resultRef{ - Kind: "data_url", - DataURL: buildDataURL(contentType, content), - Filename: filename, - ContentType: contentType, - SizeBytes: int64(len(content)), + Kind: "local_path", + URL: refMedia.URL, + LocalPath: refMedia.LocalPath, + MediaID: refMedia.ID, + Filename: refMedia.Filename, + ContentType: refMedia.ContentType, + SizeBytes: refMedia.SizeBytes, + Visibility: refMedia.Visibility, + SHA256: refMedia.SHA256, + StorageStatus: "ready", } if err := s.store.completeJobWithResults(record.JobID, []resultRef{ref}, nil); err != nil { writeInternalError(w, "failed to persist upload artifact", err) @@ -523,6 +549,140 @@ func (s *Server) handleArtifactUpload(w http.ResponseWriter, r *http.Request) { writeData(w, http.StatusCreated, item) } +func (s *Server) handleMediaUpload(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + methodNotAllowed(w) + return + } + current, ok := s.authenticateSession(w, r) + if !ok { + return + } + + if err := r.ParseMultipartForm(32 << 20); err != nil { + writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "failed to parse multipart form", map[string]any{ + "details": err.Error(), + }) + return + } + + file, header, err := r.FormFile("file") + if err != nil { + writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "file is required", nil) + return + } + defer file.Close() + + content, err := io.ReadAll(file) + if err != nil { + writeError(w, http.StatusBadRequest, "BAD_REQUEST", "failed to read upload file", map[string]any{ + "details": err.Error(), + }) + return + } + if len(content) == 0 { + writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "uploaded file is empty", nil) + return + } + + metadataJSON := strings.TrimSpace(r.FormValue("metadata_json")) + if metadataJSON != "" { + var metadata any + if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil { + writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "metadata_json must be valid JSON", map[string]any{ + "details": err.Error(), + }) + return + } + } + + filename := strings.TrimSpace(r.FormValue("filename")) + if filename == "" { + filename = strings.TrimSpace(header.Filename) + } + contentType := strings.TrimSpace(r.FormValue("content_type")) + if contentType == "" { + contentType = strings.TrimSpace(header.Header.Get("Content-Type")) + } + if contentType == "" || contentType == "application/octet-stream" { + contentType = http.DetectContentType(content) + } + if filename == "" { + filename = "media" + extensionFromContentType(contentType) + } + filename = sanitizeFilename(filename) + + record, err := persistMediaContent( + s.cfg, + current.User.ID, + strings.TrimSpace(r.FormValue("project_id")), + "", + filename, + contentType, + strings.TrimSpace(r.FormValue("visibility")), + content, + "", + ) + if err != nil { + writeInternalError(w, "failed to persist media", err) + return + } + writeData(w, http.StatusCreated, record.media) +} + +func (s *Server) handleMedia(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/v1/media/") + parts := strings.Split(strings.Trim(path, "/"), "/") + if len(parts) == 0 || parts[0] == "" { + notFound(w) + return + } + + record, exists, err := loadMediaRecord(s.cfg, parts[0]) + if err != nil { + writeInternalError(w, "failed to load media", err) + return + } + if !exists || record == nil { + notFound(w) + return + } + + if len(parts) == 2 && parts[1] == "content" && r.Method == http.MethodGet { + file, err := os.Open(record.LocalPath) + if err != nil { + writeInternalError(w, "failed to read media content", err) + return + } + defer file.Close() + + w.Header().Set("Content-Type", defaultString(record.ContentType, "application/octet-stream")) + if record.SizeBytes > 0 { + w.Header().Set("Content-Length", strconv.FormatInt(record.SizeBytes, 10)) + } + w.WriteHeader(http.StatusOK) + if _, err := io.Copy(w, file); err != nil { + log.Printf("popiartServer: streaming media %s failed: %v", record.ID, err) + } + return + } + + current, ok := s.authenticateSession(w, r) + if !ok { + return + } + if current.User.ID != record.UserID { + notFound(w) + return + } + if len(parts) == 1 && r.Method == http.MethodGet { + writeData(w, http.StatusOK, record.media) + return + } + + notFound(w) +} + func (s *Server) handleArtifact(w http.ResponseWriter, r *http.Request) { current, ok := s.authenticateSession(w, r) if !ok { @@ -958,6 +1118,17 @@ func (s *Server) executeTextToImageJob(record *job) { return } + refs, err = s.persistResultRefs(ctx, record, refs) + if err != nil { + if repoErr := s.store.failJob(record.JobID, "MEDIA_PERSIST_FAILED", "failed to persist generated media", map[string]any{ + "details": err.Error(), + "model_id": modelID, + }); repoErr != nil { + log.Printf("popiartServer: fail job %s failed: %v", record.JobID, repoErr) + } + return + } + if err := s.store.completeJobWithResults(record.JobID, refs, usage); err != nil { log.Printf("popiartServer: complete job %s failed: %v", record.JobID, err) if failErr := s.store.failJob(record.JobID, "RESULT_REF_PERSIST_FAILED", "failed to persist job result refs", map[string]any{ @@ -1010,6 +1181,18 @@ func (s *Server) executeImageToImageJob(record *job) { return } + refs, err = s.persistResultRefs(ctx, record, refs) + if err != nil { + if repoErr := s.store.failJob(record.JobID, "MEDIA_PERSIST_FAILED", "failed to persist generated media", map[string]any{ + "details": err.Error(), + "model_id": modelID, + "route_key": record.RouteKey, + }); repoErr != nil { + log.Printf("popiartServer: fail job %s failed: %v", record.JobID, repoErr) + } + return + } + if err := s.store.completeJobWithResults(record.JobID, refs, usage); err != nil { log.Printf("popiartServer: complete job %s failed: %v", record.JobID, err) if failErr := s.store.failJob(record.JobID, "RESULT_REF_PERSIST_FAILED", "failed to persist job result refs", map[string]any{ @@ -1097,6 +1280,21 @@ func (s *Server) executeImageToVideoJob(record *job) { usage["format"] = taskResult.Format } + persistCtx, cancelPersist := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancelPersist() + refs, err = s.persistResultRefs(persistCtx, record, refs) + if err != nil { + if repoErr := s.store.failJob(record.JobID, "MEDIA_PERSIST_FAILED", "failed to persist generated media", map[string]any{ + "details": err.Error(), + "model_id": modelID, + "route_key": record.RouteKey, + "newapi_task_id": upstreamTaskID, + }); repoErr != nil { + log.Printf("popiartServer: fail job %s failed: %v", record.JobID, repoErr) + } + return + } + if err := s.store.completeJobWithResults(record.JobID, refs, usage); err != nil { log.Printf("popiartServer: complete job %s failed: %v", record.JobID, err) if failErr := s.store.failJob(record.JobID, "RESULT_REF_PERSIST_FAILED", "failed to persist job result refs", map[string]any{ @@ -1168,6 +1366,7 @@ func (s *Server) resolveImageToImageReference(ctx context.Context, record *job, Filename: item.Filename, ContentType: defaultString(contentType, item.ContentType), Content: content, + URL: item.URL, }, nil } @@ -1237,6 +1436,7 @@ func (s *Server) downloadReferenceImage(ctx context.Context, rawURL string) (ima Filename: filenameFromURL(rawURL, "reference"+extensionFromContentType(contentType)), ContentType: contentType, Content: content, + URL: strings.TrimSpace(rawURL), }, nil } diff --git a/internal/server/store.go b/internal/server/store.go index a07bdcb..23e0837 100644 --- a/internal/server/store.go +++ b/internal/server/store.go @@ -360,15 +360,31 @@ func buildArtifacts(record *job) []artifact { filename = inferFilenameFromRef(ref, buildArtifactID(record.JobID, idx)) } contentType := defaultString(strings.TrimSpace(ref.ContentType), "application/octet-stream") + storageStatus := strings.TrimSpace(ref.StorageStatus) + if storageStatus == "" { + switch { + case ref.LocalPath != "": + storageStatus = "ready" + case ref.URL != "": + storageStatus = "upstream" + case ref.DataURL != "": + storageStatus = "embedded" + } + } items = append(items, artifact{ - ID: buildArtifactID(record.JobID, idx), - JobID: record.JobID, - Filename: filename, - ContentType: contentType, - SizeBytes: ref.SizeBytes, - CreatedAt: createdAt, - ExpiresAt: expiresAt, - Ref: ref, + ID: buildArtifactID(record.JobID, idx), + JobID: record.JobID, + MediaID: strings.TrimSpace(ref.MediaID), + Filename: filename, + ContentType: contentType, + SizeBytes: ref.SizeBytes, + CreatedAt: createdAt, + ExpiresAt: expiresAt, + URL: strings.TrimSpace(ref.URL), + Visibility: strings.TrimSpace(ref.Visibility), + SHA256: strings.TrimSpace(ref.SHA256), + StorageStatus: storageStatus, + Ref: ref, }) } return items diff --git a/internal/server/types.go b/internal/server/types.go index 1ec0c8e..8263faa 100644 --- a/internal/server/types.go +++ b/internal/server/types.go @@ -46,12 +46,17 @@ type jobError struct { } type resultRef struct { - Kind string `json:"kind"` - URL string `json:"url,omitempty"` - DataURL string `json:"data_url,omitempty"` - Filename string `json:"filename,omitempty"` - ContentType string `json:"content_type,omitempty"` - SizeBytes int64 `json:"size_bytes,omitempty"` + Kind string `json:"kind"` + URL string `json:"url,omitempty"` + DataURL string `json:"data_url,omitempty"` + LocalPath string `json:"local_path,omitempty"` + MediaID string `json:"media_id,omitempty"` + Filename string `json:"filename,omitempty"` + ContentType string `json:"content_type,omitempty"` + SizeBytes int64 `json:"size_bytes,omitempty"` + Visibility string `json:"visibility,omitempty"` + SHA256 string `json:"sha256,omitempty"` + StorageStatus string `json:"storage_status,omitempty"` } type job struct { @@ -85,14 +90,32 @@ type logEntry struct { } type artifact struct { - ID string `json:"id"` - JobID string `json:"job_id"` - Filename string `json:"filename"` - ContentType string `json:"content_type"` - SizeBytes int64 `json:"size_bytes"` - CreatedAt string `json:"created_at"` - ExpiresAt string `json:"expires_at"` - Ref resultRef `json:"-"` + ID string `json:"id"` + JobID string `json:"job_id"` + MediaID string `json:"media_id,omitempty"` + Filename string `json:"filename"` + ContentType string `json:"content_type"` + SizeBytes int64 `json:"size_bytes"` + CreatedAt string `json:"created_at"` + ExpiresAt string `json:"expires_at"` + URL string `json:"url,omitempty"` + Visibility string `json:"visibility,omitempty"` + SHA256 string `json:"sha256,omitempty"` + StorageStatus string `json:"storage_status,omitempty"` + Ref resultRef `json:"-"` +} + +type media struct { + ID string `json:"id"` + ArtifactID string `json:"artifact_id,omitempty"` + ProjectID string `json:"project_id,omitempty"` + Filename string `json:"filename"` + ContentType string `json:"content_type"` + SizeBytes int64 `json:"size_bytes"` + CreatedAt string `json:"created_at"` + URL string `json:"url"` + Visibility string `json:"visibility,omitempty"` + SHA256 string `json:"sha256,omitempty"` } type project struct { diff --git a/internal/server/video_test.go b/internal/server/video_test.go index d324416..1ebae01 100644 --- a/internal/server/video_test.go +++ b/internal/server/video_test.go @@ -3,6 +3,7 @@ package server import ( "context" "encoding/base64" + "encoding/json" "io" "net/http" "net/http/httptest" @@ -85,6 +86,58 @@ func TestSubmitImageToVideoTaskUsesMultipartInputReference(t *testing.T) { } } +func TestSubmitImageToVideoTaskUsesURLImagesForViduModels(t *testing.T) { + var gotBody map[string]any + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/videos" { + http.NotFound(w, r) + return + } + if got := r.Header.Get("Content-Type"); got != "application/json" { + t.Fatalf("expected application/json content type, got %q", got) + } + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Fatalf("decode request body: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"task_vidu_url_123","status":"queued"}`)) + })) + defer srv.Close() + + client := newNewAPIClient(Config{NewAPIBaseURL: srv.URL}) + taskID, err := client.submitImageToVideoTask(context.Background(), "sk-test", "viduq2-pro-fast", map[string]any{ + "prompt": "animate this still gently", + "duration_s": 5, + "aspect_ratio": "9:16", + }, imageEditReference{ + Filename: "reference.png", + ContentType: "image/png", + URL: "https://media.popi.test/m/demo/reference.png", + }) + if err != nil { + t.Fatalf("submitImageToVideoTask: %v", err) + } + + if taskID != "task_vidu_url_123" { + t.Fatalf("expected task id task_vidu_url_123, got %q", taskID) + } + if gotBody["model"] != "viduq2-pro-fast" { + t.Fatalf("unexpected model: %#v", gotBody["model"]) + } + images, ok := gotBody["images"].([]any) + if !ok || len(images) != 1 || images[0] != "https://media.popi.test/m/demo/reference.png" { + t.Fatalf("expected one image url, got %#v", gotBody["images"]) + } + if gotBody["duration"] != float64(5) { + t.Fatalf("unexpected duration: %#v", gotBody["duration"]) + } + metadata, ok := gotBody["metadata"].(map[string]any) + if !ok || metadata["aspect_ratio"] != "9:16" { + t.Fatalf("unexpected metadata: %#v", gotBody["metadata"]) + } +} + func TestFetchVideoTaskFallsBackToGenericTaskEnvelope(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { @@ -176,6 +229,7 @@ func TestExecuteImageToVideoJobCompletesAndArtifactCanBeRead(t *testing.T) { if err != nil { t.Fatalf("NewWithConfig: %v", err) } + server.cfg.PublicBaseURL = "http://127.0.0.1:8080" token, _, ok, err := server.store.createSession("sk-test-upstream") if err != nil { @@ -241,6 +295,15 @@ func TestExecuteImageToVideoJobCompletesAndArtifactCanBeRead(t *testing.T) { if item.ContentType != "video/mp4" { t.Fatalf("expected video/mp4 artifact, got %q", item.ContentType) } + if item.MediaID == "" { + t.Fatalf("expected media id on persisted artifact, got %#v", item) + } + if item.URL == "" { + t.Fatalf("expected stable url on persisted artifact, got %#v", item) + } + if item.StorageStatus != "ready" { + t.Fatalf("expected ready storage status, got %#v", item.StorageStatus) + } if !strings.HasSuffix(item.Filename, ".mp4") { t.Fatalf("expected .mp4 filename, got %q", item.Filename) } diff --git a/skillhub_assets/site.css b/skillhub_assets/site.css new file mode 100644 index 0000000..756d2b4 --- /dev/null +++ b/skillhub_assets/site.css @@ -0,0 +1,1268 @@ +:root { + --bg: #ffffff; + --bg-soft: #f6f7fb; + --surface: #ffffff; + --surface-soft: #f8f9ff; + --surface-accent: linear-gradient(180deg, #f7f5ff 0%, #ffffff 100%); + --line: rgba(15, 23, 42, 0.08); + --ink: #1a1a1a; + --muted: #64748b; + --primary: #7c3aed; + --primary-dark: #1a1a2e; + --primary-soft: rgba(124, 58, 237, 0.1); + --success: #10b981; + --warning: #f59e0b; + --shadow: 0 24px 60px rgba(15, 23, 42, 0.08); + --radius-xl: 32px; + --radius-lg: 24px; + --radius-md: 18px; + --radius-sm: 14px; + --container: 1200px; + --font-body: "Inter", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; + --font-mono: "SFMono-Regular", "JetBrains Mono", Menlo, Consolas, monospace; +} + +* { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; + scrollbar-gutter: stable; +} + +body { + margin: 0; + overflow-y: scroll; + color: var(--ink); + font-family: var(--font-body); + background: + radial-gradient(circle at top right, rgba(124, 58, 237, 0.08), transparent 22%), + radial-gradient(circle at top left, rgba(79, 70, 229, 0.06), transparent 24%), + var(--bg); +} + +a { + color: inherit; + text-decoration: none; +} + +button, +input, +textarea { + font: inherit; +} + +pre, +code { + font-family: var(--font-mono); +} + +.site-shell { + min-height: 100vh; +} + +.site-header, +.site-main, +.site-footer { + width: min(calc(100% - 32px), var(--container)); + margin: 0 auto; +} + +.site-header { + position: sticky; + top: 0; + z-index: 20; + display: grid; + grid-template-columns: 220px minmax(0, 1fr) 360px; + align-items: center; + gap: 20px; + margin-top: 20px; + padding: 16px 28px; + border: 1px solid var(--line); + border-radius: 999px; + background: #fff; + box-shadow: 0 1px 0 rgba(15, 23, 42, 0.03); +} + +.site-brand { + display: inline-flex; + align-items: center; + gap: 10px; + font-weight: 800; + font-size: 20px; + letter-spacing: -0.04em; + justify-self: start; +} + +.site-brand-dot { + width: 10px; + height: 10px; + border-radius: 999px; + background: linear-gradient(135deg, #5b45ff 0%, #8b5cf6 100%); + box-shadow: 0 0 0 6px rgba(124, 58, 237, 0.12); +} + +.site-brand span:last-child { + color: var(--primary); +} + +.site-nav { + display: flex; + align-items: center; + gap: 10px; + justify-self: center; +} + +.site-nav a { + padding: 12px 18px; + border-radius: 999px; + color: var(--muted); + font-size: 15px; + font-weight: 500; + transition: 160ms ease; +} + +.site-nav a:hover, +.site-nav a.is-active { + color: var(--ink); + background: rgba(248, 250, 252, 0.92); + box-shadow: inset 0 0 0 1px var(--line); +} + +.site-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 18px; + width: 100%; + justify-self: end; +} + +.site-actions-console { + padding-left: 28px; + border-left: 1px solid var(--line); +} + +.locale-link { + color: var(--muted); + font-size: 14px; + font-weight: 600; +} + +.locale-link:hover { + color: var(--ink); +} + +.button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 48px; + padding: 0 20px; + border: 0; + border-radius: 999px; + cursor: pointer; + font-weight: 600; + transition: transform 160ms ease, box-shadow 160ms ease, background 160ms ease; +} + +.button:hover { + transform: translateY(-1px); +} + +.button-primary { + color: #fff; + background: linear-gradient(90deg, var(--primary-dark) 0%, var(--primary) 100%); + box-shadow: 0 14px 28px rgba(124, 58, 237, 0.24); +} + +.button-secondary { + color: var(--ink); + background: #f5f6fa; + box-shadow: inset 0 0 0 1px var(--line); +} + +.button-ghost { + color: var(--muted); + background: transparent; + box-shadow: inset 0 0 0 1px var(--line); +} + +.button-header { + min-width: 92px; + padding: 0 18px; +} + +.site-header-console { + box-shadow: 0 1px 0 rgba(15, 23, 42, 0.03); +} + +.profile-chip { + display: inline-flex; + align-items: center; + gap: 12px; + color: #3c4257; + font-size: 15px; + font-weight: 600; +} + +.profile-avatar { + width: 44px; + height: 44px; + border-radius: 999px; + background: linear-gradient(135deg, #4f46e5 0%, #8b5cf6 100%); + box-shadow: + 0 0 0 3px #ffffff, + 0 0 0 4px rgba(124, 58, 237, 0.16); +} + +.site-main { + padding-top: 28px; + padding-bottom: 48px; +} + +.page-stack { + display: grid; + gap: 28px; +} + +.hero { + padding: 28px; + border-radius: 36px; + background: var(--surface-accent); + box-shadow: var(--shadow); + border: 1px solid var(--line); +} + +.hero-grid { + display: grid; + grid-template-columns: 1.2fr 0.8fr; + gap: 28px; + align-items: center; +} + +.hero-visual { + position: relative; + overflow: hidden; + min-height: 460px; + padding: 22px; + border-radius: 30px; + background: + radial-gradient(circle at top left, rgba(196, 181, 253, 0.38), transparent 28%), + radial-gradient(circle at bottom right, rgba(255, 216, 177, 0.36), transparent 30%), + linear-gradient(180deg, #f6f3ff 0%, #fff8f1 100%); +} + +.window { + display: grid; + gap: 14px; + height: 100%; +} + +.window-top { + display: flex; + align-items: center; + gap: 8px; +} + +.window-top span { + width: 10px; + height: 10px; + border-radius: 999px; + background: rgba(15, 23, 42, 0.16); +} + +.visual-grid { + display: grid; + grid-template-columns: 1.2fr 0.8fr; + gap: 14px; + flex: 1; +} + +.panel, +.terminal, +.mini-card, +.metric, +.scene-card, +.doc-card, +.toc-card, +.skill-card, +.pricing-card, +.faq-card, +.auth-card, +.preview-card, +.console-card, +.cta-banner { + background: var(--surface); + border: 1px solid var(--line); + box-shadow: var(--shadow); + border-radius: var(--radius-lg); +} + +.panel { + padding: 18px; +} + +.mini-stack { + display: grid; + gap: 14px; +} + +.mini-card { + padding: 16px; +} + +.mini-card h4, +.scene-card h3, +.doc-card h3, +.skill-card h3, +.pricing-card h3, +.auth-card h2, +.preview-card h3, +.console-card h3, +.faq-card h3, +.section-title h2 { + margin: 0; + letter-spacing: -0.03em; +} + +.mini-card p, +.scene-card p, +.doc-card p, +.skill-card p, +.pricing-card p, +.auth-card p, +.preview-card p, +.console-card p, +.faq-card p, +.section-title p, +.hero-copy p, +.footer-subtitle, +.toc-list li, +.meta-list li { + margin: 0; + color: var(--muted); + line-height: 1.7; +} + +.terminal { + padding: 18px; + color: #dbe4ff; + background: linear-gradient(180deg, #1a1a2e 0%, #101827 100%); +} + +.terminal code { + display: block; + white-space: pre-wrap; + font-size: 13px; + line-height: 1.8; +} + +.hero-copy { + padding: 12px 8px 12px 0; +} + +.eyebrow { + display: inline-flex; + align-items: center; + gap: 8px; + margin-bottom: 14px; + color: var(--primary); + font-size: 12px; + font-weight: 800; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.eyebrow::before { + content: ""; + width: 8px; + height: 8px; + border-radius: 999px; + background: currentColor; +} + +.eyebrow-soft { + color: #c4b5fd; +} + +.hero-copy h1 { + margin: 0; + font-size: clamp(3rem, 5vw, 5rem); + line-height: 0.98; + letter-spacing: -0.06em; +} + +.hero-copy h1 span, +.section-title h2 span, +.site-brand .accent { + color: var(--primary); +} + +.hero-copy p { + margin-top: 20px; + max-width: 480px; + font-size: 18px; +} + +.hero-actions { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-top: 26px; +} + +.hero-proof { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 18px; +} + +.pill { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + border-radius: 999px; + color: var(--muted); + background: rgba(255, 255, 255, 0.85); + box-shadow: inset 0 0 0 1px var(--line); + font-size: 13px; + font-weight: 500; +} + +.section { + display: grid; + gap: 22px; +} + +.section-heading { + max-width: 760px; +} + +.section-title { + display: grid; + gap: 10px; +} + +.section-title h2 { + font-size: clamp(2rem, 3vw, 3rem); +} + +.stat-row { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 16px; +} + +.metric { + padding: 20px 22px; +} + +.metric strong { + display: block; + font-size: 34px; + letter-spacing: -0.05em; +} + +.metric span { + display: block; + margin-top: 8px; + color: var(--muted); + font-size: 14px; +} + +.scene-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 16px; +} + +.scene-card, +.doc-card, +.toc-card, +.skill-card, +.pricing-card, +.faq-card, +.auth-card, +.preview-card, +.console-card, +.cta-banner { + padding: 22px; +} + +.scene-card { + min-height: 220px; + background: + radial-gradient(circle at top right, rgba(124, 58, 237, 0.08), transparent 26%), + linear-gradient(180deg, #ffffff 0%, #fbfbff 100%); +} + +.scene-card h3 { + margin-top: 14px; + font-size: 20px; +} + +.scene-link { + display: inline-flex; + align-items: center; + gap: 8px; + margin-top: 18px; + color: var(--primary); + font-size: 13px; + font-weight: 700; +} + +.step-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 16px; +} + +.step-card { + padding: 22px; + border-radius: var(--radius-lg); + background: #ffffff; + box-shadow: var(--shadow); + border: 1px solid var(--line); +} + +.step-number { + display: inline-flex; + align-items: center; + justify-content: center; + width: 50px; + height: 50px; + border-radius: 18px; + color: var(--primary); + background: var(--primary-soft); + font-size: 18px; + font-weight: 800; +} + +.step-card h3 { + margin: 18px 0 10px; + font-size: 20px; +} + +.step-card p { + margin: 0; + color: var(--muted); + line-height: 1.7; +} + +.docs-layout, +.skills-layout, +.pricing-layout, +.login-layout, +.console-layout { + display: grid; + gap: 20px; +} + +.docs-layout { + grid-template-columns: 300px minmax(0, 1fr); +} + +.toc-card { + position: sticky; + top: 96px; + align-self: start; +} + +.toc-list { + display: grid; + gap: 10px; + margin: 18px 0 0; + padding: 0; + list-style: none; +} + +.toc-list a { + color: var(--muted); +} + +.doc-stack, +.console-stack { + display: grid; + gap: 18px; +} + +.doc-card h3, +.console-card h3, +.preview-card h3 { + margin-bottom: 14px; + font-size: 24px; +} + +.doc-card .card-actions { + margin-top: 18px; +} + +.code-block { + margin-top: 16px; + overflow: hidden; + border-radius: 20px; + background: #1a1a2e; + border: 1px solid rgba(255, 255, 255, 0.06); +} + +.code-head { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + color: rgba(255, 255, 255, 0.7); + background: rgba(255, 255, 255, 0.04); + font-size: 12px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.copy-button { + min-height: auto; + padding: 8px 12px; + border-radius: 999px; + color: rgba(255, 255, 255, 0.8); + background: rgba(255, 255, 255, 0.08); + font-size: 12px; +} + +.code-block pre { + margin: 0; + padding: 18px 18px 20px; + overflow-x: auto; + color: #a5b4fc; + font-size: 13px; + line-height: 1.8; +} + +.doc-grid, +.skill-grid, +.pricing-grid, +.console-grid, +.preview-grid { + display: grid; + gap: 16px; +} + +.doc-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.skill-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.skill-card { + display: grid; + gap: 14px; +} + +.skill-card-top, +.pricing-card-top, +.console-card-top { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; +} + +.badge { + display: inline-flex; + align-items: center; + padding: 8px 10px; + border-radius: 999px; + font-size: 12px; + font-weight: 700; +} + +.badge-primary { + color: var(--primary); + background: var(--primary-soft); +} + +.badge-success { + color: var(--success); + background: rgba(16, 185, 129, 0.12); +} + +.badge-warning { + color: var(--warning); + background: rgba(245, 158, 11, 0.12); +} + +.meta-list { + display: grid; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; + font-size: 14px; +} + +.meta-list strong { + color: var(--ink); +} + +.meta-list-spaced { + margin-top: 16px; +} + +.meta-list-spaced-lg { + margin-top: 18px; +} + +.spotlight { + display: grid; + grid-template-columns: 1.1fr 0.9fr; + gap: 18px; +} + +.spotlight-card { + padding: 24px; + border-radius: var(--radius-lg); + background: var(--surface-accent); + border: 1px solid var(--line); + box-shadow: var(--shadow); +} + +.pricing-layout { + grid-template-columns: 1.1fr 0.9fr; +} + +.credits-layout { + display: grid; + grid-template-columns: 0.9fr 1.1fr; + gap: 20px; +} + +.pricing-card { + display: grid; + gap: 16px; +} + +.pricing-card-primary { + background: var(--surface-accent); +} + +.price-line { + display: flex; + align-items: flex-end; + gap: 8px; +} + +.price-line strong { + font-size: 44px; + letter-spacing: -0.06em; +} + +.price-line span { + color: var(--muted); +} + +.faq-list { + display: grid; + gap: 14px; +} + +.faq-card h3 { + margin-bottom: 10px; + font-size: 18px; +} + +.credits-pack, +.usage-board, +.gate-card { + padding: 26px; + border-radius: var(--radius-lg); + border: 1px solid var(--line); + box-shadow: var(--shadow); +} + +.credits-pack { + color: #fff; + background: linear-gradient(180deg, #18162f 0%, #35265f 100%); +} + +.credits-pack h3, +.usage-board h3, +.gate-card h1 { + margin: 0; + letter-spacing: -0.04em; +} + +.credits-pack p, +.usage-board p, +.gate-card p { + margin: 0; + line-height: 1.7; +} + +.credits-pack p { + margin-top: 14px; + color: rgba(255, 255, 255, 0.78); +} + +.credits-tier-list { + display: grid; + gap: 14px; + margin-top: 24px; +} + +.credits-tier { + display: flex; + justify-content: space-between; + gap: 16px; + padding: 16px 18px; + border-radius: 18px; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.12); +} + +.credits-tier strong, +.usage-item strong { + display: block; +} + +.credits-tier span, +.usage-item span { + display: block; + margin-top: 4px; + font-size: 13px; +} + +.credits-tier span { + color: rgba(255, 255, 255, 0.7); +} + +.credits-tier em, +.usage-item em { + font-style: normal; + font-weight: 700; + white-space: nowrap; +} + +.credits-tier em { + font-size: 22px; +} + +.usage-board { + background: var(--surface); +} + +.usage-board h3 { + font-size: 30px; +} + +.usage-board p { + margin-top: 14px; + color: var(--muted); +} + +.usage-columns { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 18px; + margin-top: 24px; +} + +.usage-group h4 { + margin: 0 0 14px; + font-size: 16px; +} + +.usage-list { + display: grid; + gap: 12px; +} + +.usage-item { + display: flex; + justify-content: space-between; + gap: 16px; + padding: 14px 16px; + border-radius: 16px; + background: var(--bg-soft); + border: 1px solid var(--line); +} + +.gate-shell { + display: grid; + place-items: center; + min-height: calc(100vh - 230px); +} + +.gate-card { + width: min(100%, 760px); + text-align: center; + background: linear-gradient(180deg, #f8f6ff 0%, #ffffff 100%); +} + +.gate-card h1 { + margin-top: 14px; + font-size: clamp(2.6rem, 5vw, 4.4rem); +} + +.gate-card p { + margin: 16px auto 0; + max-width: 520px; + font-size: 18px; + color: var(--muted); +} + +.gate-card .hero-actions { + justify-content: center; + margin-top: 28px; +} + +.login-layout { + grid-template-columns: 1fr 1fr; + align-items: start; +} + +.auth-card { + background: var(--surface-accent); +} + +.auth-form { + display: grid; + gap: 14px; + margin-top: 20px; +} + +.field { + display: grid; + gap: 8px; +} + +.field label { + font-size: 14px; + font-weight: 600; +} + +.field input, +.field textarea { + width: 100%; + padding: 14px 16px; + border: 1px solid var(--line); + border-radius: 16px; + background: #fff; + outline: none; +} + +.helper-text { + color: var(--muted); + font-size: 13px; +} + +.preview-grid, +.console-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.console-layout { + grid-template-columns: 1.1fr 0.9fr; +} + +.console-banner { + padding: 24px; + border-radius: var(--radius-lg); + background: + radial-gradient(circle at top left, rgba(124, 58, 237, 0.16), transparent 26%), + linear-gradient(180deg, #ffffff 0%, #f8f7ff 100%); + border: 1px solid var(--line); + box-shadow: var(--shadow); +} + +.console-banner h1 { + margin: 0; + font-size: clamp(2.2rem, 4vw, 3.6rem); + letter-spacing: -0.05em; +} + +.console-banner p { + margin: 16px 0 0; + color: var(--muted); + font-size: 17px; + line-height: 1.7; +} + +.console-kpis { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 16px; + margin-top: 24px; +} + +.console-kpi { + padding: 18px; + border-radius: 20px; + background: #fff; + box-shadow: inset 0 0 0 1px var(--line); +} + +.console-kpi strong { + display: block; + font-size: 28px; + letter-spacing: -0.05em; +} + +.console-kpi span { + display: block; + margin-top: 8px; + color: var(--muted); + font-size: 13px; +} + +.console-dashboard { + display: grid; + gap: 34px; + padding-top: 26px; +} + +.console-heading h1 { + margin: 0; + font-size: clamp(3.6rem, 8vw, 5rem); + line-height: 1; + letter-spacing: -0.06em; +} + +.console-heading p { + margin: 22px 0 0; + max-width: 720px; + color: #7184a6; + font-size: 19px; + line-height: 1.7; +} + +.console-stats-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 22px; +} + +.console-stat-card { + min-height: 220px; + padding: 34px 30px; + border: 1px solid #dce5f2; + border-radius: 28px; + background: #fbfcff; +} + +.console-stat-label, +.secret-label { + color: #8ea0bf; + font-size: 15px; + letter-spacing: 0.02em; +} + +.console-stat-card strong { + display: block; + margin-top: 34px; + font-size: 58px; + line-height: 1; + letter-spacing: -0.06em; +} + +.console-stat-card span { + display: block; + margin-top: 28px; + color: #8ea0bf; + font-size: 15px; +} + +.secret-panel { + padding: 18px 0; + border: 1px solid #dce5f2; + border-radius: 28px; + background: #fbfcff; +} + +.secret-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + padding: 22px 28px; +} + +.secret-copy { + min-width: 0; +} + +.secret-value { + margin-top: 12px; + color: #1b1f2a; + font-size: 19px; + line-height: 1.55; + word-break: break-all; +} + +.secret-divider { + height: 1px; + margin: 0 28px; + background: #dce5f2; +} + +.console-install-card { + overflow: hidden; + border-radius: 30px; + background: linear-gradient(180deg, #17182d 0%, #1d2140 100%); + border: 1px solid rgba(255, 255, 255, 0.06); + box-shadow: var(--shadow); +} + +.console-install-card .code-head { + padding: 16px 22px; +} + +.console-install-card pre { + margin: 0; + padding: 26px 28px 30px; + overflow-x: auto; + color: #c1ccff; + font-size: 15px; + line-height: 2; +} + +.cta-banner { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + padding: 18px 22px; +} + +.site-footer { + padding: 18px 0 36px; + color: var(--muted); + font-size: 14px; +} + +.footer-grid { + display: flex; + justify-content: space-between; + gap: 20px; + padding-top: 8px; + border-top: 1px solid var(--line); +} + +.footer-links { + display: flex; + gap: 18px; + flex-wrap: wrap; +} + +@media (max-width: 1100px) { + .hero-grid, + .docs-layout, + .skills-layout, + .pricing-layout, + .credits-layout, + .login-layout, + .console-layout, + .spotlight { + grid-template-columns: 1fr; + } + + .scene-grid, + .step-grid, + .skill-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .toc-card { + position: static; + } + + .console-kpis, + .console-stats-grid, + .stat-row, + .usage-columns, + .preview-grid, + .console-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 760px) { + .site-header { + grid-template-columns: 1fr; + } + + .site-header, + .site-main, + .site-footer { + width: min(calc(100% - 20px), var(--container)); + } + + .footer-grid, + .cta-banner { + flex-direction: column; + align-items: stretch; + } + + .site-header { + padding: 14px 16px; + border-radius: 28px; + } + + .site-nav, + .site-actions { + flex-wrap: wrap; + } + + .site-actions-console { + padding-left: 0; + border-left: 0; + } + + .hero, + .scene-card, + .doc-card, + .toc-card, + .skill-card, + .pricing-card, + .credits-pack, + .usage-board, + .faq-card, + .auth-card, + .preview-card, + .console-card, + .console-banner, + .gate-card, + .cta-banner { + padding: 18px; + } + + .hero-visual { + min-height: 320px; + } + + .hero-copy h1, + .console-banner h1 { + font-size: clamp(2.3rem, 10vw, 3.4rem); + } + + .scene-grid, + .step-grid, + .doc-grid, + .skill-grid, + .console-kpis, + .console-stats-grid, + .stat-row, + .usage-columns, + .preview-grid, + .console-grid { + grid-template-columns: 1fr; + } + + .secret-row { + flex-direction: column; + align-items: stretch; + } +} diff --git a/skillhub_assets/site.js b/skillhub_assets/site.js new file mode 100644 index 0000000..dbd10ff --- /dev/null +++ b/skillhub_assets/site.js @@ -0,0 +1,783 @@ +document.addEventListener("DOMContentLoaded", function () { + const sourceMap = captureSourceMap(); + let currentLocale = getInitialLocale(); + let heroRotationTimer = null; + + const copyMessages = { + zh: { copy: "复制", copied: "已复制", failed: "复制失败" }, + en: { copy: "Copy", copied: "Copied", failed: "Copy failed" } + }; + + const enTranslations = { + "common.nav.home": "Home", + "common.nav.docs": "Docs", + "common.nav.skills": "Skills", + "common.nav.console": "Console", + "common.nav.pricing": "Pricing", + "common.login": "Sign in", + "common.logout": "Sign out", + + "index.meta.title": "Popi.art SkillHub", + "index.meta.description": "Static bilingual prototype for Popi.art SkillHub, written from popiartcli and Popiart_skillhub.", + "index.hero.catalogCard": ` +
OFFICIAL SKILLHUB
+

Official skill catalog

+

+ The public registry comes from Popiart_skillhub. It currently exposes 59 skills across image, video, and audio. +

+ `, + "index.hero.baselineCard": ` +
RUNTIME BASELINE
+

3 official runtime baselines

+

text2image / img2img / image2video

+ `, + "index.hero.mcpCard": ` +
MCP
+

discoverable tool surface

+

list_skills / run_skill / get_job / pull_artifact

+ `, + "index.hero.showcaseCard": ` +
SHOWCASE
+

PopiStudio Alice

+

The character-consistency showcase skill is already visible in the public catalog.

+ `, + "index.hero.copy": ` +
POP I . ART
+

Creator skill entry for Coding Agents

+

+ popiartcli owns the unified CLI entrypoint, auth, skill discovery, run, jobs, artifacts, and MCP discoverability; Popiart_skillhub owns the public catalog and skill descriptions. +

+
+ Quick start + Browse skills +
+
+ 59 public skills + Image / Video / Audio + text2image · img2img +
+ `, + "index.surfaces.heading": ` +
PRODUCT SURFACES
+

Today’s product surface is grounded in CLI + SkillHub

+

The homepage is organized around real command surfaces, catalog facts, and execution boundaries instead of vague product copy.

+ `, + "index.surfaces.metricTotal": "public skills", + "index.surfaces.metricAudio": "audio skills", + "index.surfaces.metricImage": "image skills", + "index.surfaces.metricVideo": "video skills", + "index.scenarios.heading": ` +
SCENARIOS
+

High-value scenarios extracted from the catalog

+

These names stay close to the real skill ids, categories, and capabilities in Popiart_skillhub.

+ `, + "index.scenarios.card1": ` +
IMAGE
+

Text2Image

+

Built around popiskill-image-text2image-* for prompt-driven image generation, character sheets, three-view, and stylized artwork.

+ View image skills + `, + "index.scenarios.card2": ` +
IMAGE
+

Img2Img

+

Includes baseline image editing, localized retouching, and the PopiStudio Alice consistency showcase workflow.

+ View img2img + `, + "index.scenarios.card3": ` +
VIDEO
+

Image2Video

+

One of the official runtime baselines, ideal for turning still frames into short teasers and motion clips.

+ View video skills + `, + "index.scenarios.card4": ` +
VIDEO
+

Text2Video

+

The catalog already includes text2video definitions across kling, sora2pro, and studio-oriented variants.

+ View text2video + `, + "index.scenarios.card5": ` +
AUDIO
+

TTS / Voice

+

Covers doubao, ima-studio, multimodel, and voice-engineer style speech output workflows.

+ View audio skills + `, + "index.scenarios.card6": ` +
AUDIO
+

STT / Dubbing

+

The catalog already includes local speech recognition, video transcription, translation, and localization-related skills.

+ View STT & dubbing + `, + "index.scenarios.card7": ` +
MCP
+

Agent Discoverability

+

popiart mcp serve / print-config / doctor already forms a practical tool surface for agent integration.

+ Read MCP docs + `, + "index.scenarios.card8": ` +
BOOTSTRAP
+

Bootstrap & Seed Skills

+

The CLI generates discoverability assets and merges bundled seed skills into the query path.

+ View bootstrap + `, + "index.flow.heading": ` +
INTEGRATION FLOW
+

Real integration flow

+

No invented commands. The first-screen product story follows the commands already implemented in popiartcli.

+ `, + "index.flow.step1": ` +
01
+

Install the CLI

+

Supports Homebrew, install.sh, PowerShell, and source build. The main distribution path is the Go CLI.

+ `, + "index.flow.step2": ` +
02
+

Bootstrap the agent

+

Use bootstrap --discoverable to generate shell completion, MCP config snippets, and seed skill profile assets.

+ `, + "index.flow.step3": ` +
03
+

Auth & discover

+

Sign in with the product-layer key, then use skills list/get/schema to discover merged remote and local skills.

+ `, + "index.flow.step4": ` +
04
+

Run / Jobs / Artifacts

+

Tasks always return a job_id, long jobs are polled, and outputs are recovered through artifacts pull.

+ `, + "index.cta.text": ` +
NEXT STEP
+ Continue to docs, skills, and the console prototype + `, + "index.footer.title": "Popi.art SkillHub Static Prototype", + "index.footer.subtitle": "Content is rewritten from popiartcli and Popiart_skillhub, with layout references from open-claw.", + + "docs.meta.title": "Popi.art Docs", + "docs.meta.description": "Bilingual static documentation prototype for Popi.art CLI and SkillHub.", + "docs.heading": ` +
DOCUMENTATION
+

Documentation written from the real CLI surface

+

This page is sourced from the popiartcli README, current status notes, and command tree. It does not invent future commands.

+ `, + "docs.toc": ` +
ON THIS PAGE
+ + `, + "docs.install": ` +
INSTALL
+

Installation methods

+

The current popiartcli README explicitly lists Homebrew, curl/install.sh, PowerShell, and source build as supported install paths.

+
+
+ Homebrew + +
+
brew tap wtgoku-create/popi
+brew install wtgoku-create/popi/popiart
+popiart bootstrap --agent codex --discoverable
+
+
+
+ Source Build + +
+
git clone https://github.com/wtgoku-create/popiartcli.git
+cd popiartcli
+go install ./cmd/popiart
+popiart --help
+
+ `, + "docs.quickstart": ` +
QUICK START
+

Quick start

+

The smallest closed loop is: install the CLI, sign in, discover a skill, run a task, poll jobs, and pull artifacts.

+
+
+ Quick Start + +
+
popiart auth login --key pk-...
+popiart skills list --tag image
+popiart run popiskill-image-text2image-basic-v1 --input @params.json --wait
+popiart jobs get job_xxx
+popiart artifacts pull art_xxx
+
+ `, + "docs.coreCommands": ` +
COMMAND GROUP
+

Core commands

+ + `, + "docs.platformCommands": ` +
COMMAND GROUP
+

Platform commands

+ + `, + "docs.mcp": ` +
MCP
+

Implemented MCP discoverability

+

The repo already implements popiart mcp serve / print-config / doctor and exposes a practical agent-facing tool surface.

+
+
+ MCP + +
+
popiart mcp serve --describe
+popiart mcp print-config
+popiart mcp doctor
+popiart bootstrap --agent codex --discoverable
+
+ + `, + "docs.baseline": ` +
RUNTIME BASELINE
+

Current official runtime baseline

+

The current status notes for popiartcli explicitly treat only three skills as the official runtime baseline. They are the best candidates for homepage and console defaults.

+ + `, + "docs.footer.title": "Popi.art Docs Prototype", + "docs.footer.subtitle": "Documentation is rewritten from the popiartcli README, command tree, and current-status notes.", + + "login.meta.title": "Popi.art Login", + "login.meta.description": "Static bilingual login prototype for Popi.art.", + "login.form": ` +
SIGN IN
+

Sign in with the product-layer key

+

The real auth model in popiartcli is a PopiArt product-layer key, not a raw OpenAI, Gemini, Sora, or other provider key pasted into the client.

+
+
+ + +
+
+ + +
+
The real CLI command is popiart auth login --key pk-....
+
+ + Read CLI docs +
+
+ `, + "login.why": ` +
WHY THIS MODEL
+

Unified gateway boundary

+

popiartcli is the local entrypoint, popiartServer is the product backend, and PopiNewAPI is the model gateway. Provider keys do not enter the CLI.

+ `, + "login.after": ` +
AFTER LOGIN
+

What you can do after sign-in

+ + `, + "login.store": ` +
STORE
+

How local credentials are stored

+

Saved keys go into ~/.popiart/config.json, and can be overridden by POPIART_KEY or POPIART_TOKEN.

+ `, + "login.footer.title": "Popi.art Login Prototype", + "login.footer.subtitle": "The login model follows popiart auth login --key instead of inventing a separate account system.", + + "pricing.meta.title": "Popi.art Pricing", + "pricing.meta.description": "Static bilingual pricing prototype for Popi.art credits and usage.", + "pricing.heading": ` +
PRICING
+

Buy more Credits

+

The static pricing page follows the credits structure of open-claw, while capability names come from popiartcli and Popiart_skillhub.

+ `, + "pricing.packs": ` +
CREDIT PACKS
+

Choose a credit pack after login

+

This static prototype only demonstrates the information architecture. Real pricing, recharge, balance, and billing should come from popiartServer.

+
+
+
+ Starter Pack + 200 Credits / for quick starts and single demos +
+ ¥29 +
+
+
+ Creator Pack + 1000 Credits / for day-to-day run, jobs, and artifacts workflows +
+ ¥99 +
+
+
+ Studio Pack + 5000 Credits / for multi-project or higher-frequency agent usage +
+ ¥399 +
+
+
+ Buy after login + Read docs +
+ `, + "pricing.usage": ` +
CREDIT USAGE
+

Static usage examples by capability

+

Capability names and categories follow the current catalog. Credit numbers are placeholders for the static prototype and should later come from the real billing table.

+
+
+

Image tools

+
+
+
+ popiskill-image-text2image-basic-v1 + Generate images from text prompts +
+ 4 Credits/image +
+
+
+ popiskill-image-img2img-basic-v1 + Edit an existing image +
+ 2 Credits/image +
+
+
+ popiskill-image-img2img-edit-v1 + Local editing with multi-image references +
+ 3 Credits/image +
+
+
+ popiskill-image-text2image-character-sheet-v1 + Character sheets and multi-view generation +
+ 6 Credits/image +
+
+
+
+

Video & audio tools

+
+
+
+ popiskill-video-image2video-basic-v1 + Generate video from a starting image +
+ 20 Credits / 5s +
+
+
+ popiskill-video-text2video-studio-v1 + Generate video clips from text +
+ 25 Credits / 5s +
+
+
+ popiskill-video-avatar-talking-head-v1 + Talking-head and avatar-driven video +
+ 18 Credits / 5s +
+
+
+ popiskill-audio-tts-multimodel-v1 + Multi-model speech generation +
+ 3 Credits / run +
+
+
+
+ `, + "pricing.faqHeading": ` +
FAQ
+

Frequently asked questions

+ `, + "pricing.faq1": ` +

Can credit packs stack?

+

The static prototype assumes packs can stack into the same product-layer account. Real validity and stacking rules should come from the billing service.

+ `, + "pricing.faq2": ` +

Can all 59 skills in SkillHub run immediately?

+

No. The current CLI status notes explicitly treat only 3 skills as the official runtime baseline. Everything else still depends on server-side registration and routing.

+ `, + "pricing.faq3": ` +

Can usage continue after credits run out?

+

The static prototype currently assumes you need to purchase another credits pack. If monthly plans or bundled quotas are added later, the billing layer should define the rule.

+ `, + "pricing.faq4": ` +

Why use a product-layer key instead of a provider key?

+

popiartcli should not hold raw upstream provider keys. Those keys belong behind the gateway and product backend boundary.

+ `, + "pricing.footer.title": "Popi.art Pricing Prototype", + "pricing.footer.subtitle": "Pricing copy is organized around product-layer value and avoids exposing provider details directly.", + + "skills.meta.title": "Popi.art Skills", + "skills.meta.description": "Static bilingual skill catalog prototype for Popi.art SkillHub.", + "skills.heading": ` +
SKILL CATALOG
+

Turn Popiart_skillhub directly into a product catalog

+

The directory structure comes from index.json, and detail structure comes from each skills/*/SKILL.md instead of a separate hand-written layer.

+ `, + "skills.metrics.origin": "popiart origin skills", + "skills.metrics.upstream": "upstream reference skill", + "skills.metrics.showcase": "showcase skills", + "skills.metrics.baseline": "runtime baseline", + "skills.pills.naming": "Naming: popiskill----v", + "skills.pills.categories": "Categories: image / video / audio / meta", + "skills.pills.source": "Source: index.json + SKILL.md", + "skills.featuredHeading": ` +
FEATURED
+

Featured skills

+ `, + "skills.card1": ` +
+ IMAGE + BASELINE +
+

popiskill-image-text2image-basic-v1

+

One of the official runtime baselines and the most natural default capability to highlight on the homepage and in the console.

+
    +
  • Capability: text2image
  • +
  • Origin: popiart
  • +
  • Display: text-to-image
  • +
+ `, + "skills.card2": ` +
+ IMAGE + BASELINE +
+

popiskill-image-img2img-basic-v1

+

The baseline image-editing path and a key entrypoint from the static catalog into actual editable workflows.

+
    +
  • Capability: img2img
  • +
  • Origin: popiart
  • +
  • Display: image editing
  • +
+ `, + "skills.card3": ` +
+ VIDEO + BASELINE +
+

popiskill-video-image2video-basic-v1

+

The strongest public example skill on the current video path for a runnable product showcase.

+
    +
  • Capability: image2video
  • +
  • Origin: popiart
  • +
  • Display: image-to-video
  • +
+ `, + "skills.card4": ` +
+ IMAGE + SHOWCASE +
+

popiskill-image-img2img-popistudio-alice-showcase-v1

+

A fixed-reference Alice consistency showcase workflow, suitable for demos and proof frames.

+
    +
  • Capability: img2img
  • +
  • Showcase: true
  • +
  • Display: PopiStudio Alice
  • +
+ `, + "skills.card5": ` +
+ VIDEO + SHOWCASE +
+

popiskill-video-image2video-popistudio-alice-showcase-v1

+

Extends the Alice showcase from a single frame into a teaser-style motion clip, which makes it ideal for homepage marketing.

+
    +
  • Capability: image2video
  • +
  • Showcase: true
  • +
  • Display: Alice teaser
  • +
+ `, + "skills.card6": ` +
+ AUDIO + CATALOG +
+

popiskill-audio-tts-multimodel-v1

+

The presence of many audio skills makes it clear the site cannot be designed only around images and video.

+
    +
  • Capability: tts
  • +
  • Category: audio
  • +
  • Display: multi-model TTS
  • +
+ `, + "skills.detail": ` +
DETAIL EXAMPLE
+

What a skill detail page should look like

+

From SKILL.md you can reliably extract frontmatter descriptions, recommended usage, discouraged usage, required inputs, workflow, command patterns, payload templates, and artifact handling.

+
    +
  • Use when: demo frame / proof frame / character consistency
  • +
  • Do not use: text-only / batch / long-form video
  • +
  • Required input: scene_prompt
  • +
  • Optional: shot_type / camera / mood / aspect_ratio
  • +
+ `, + "skills.command": ` +
REAL COMMAND
+

Alice showcase command pattern

+
+
+ Skill Run + +
+
popiart run popiskill-image-img2img-popistudio-alice-showcase-v1 \
+  --input @params.json \
+  --wait
+
+ `, + "skills.footer.title": "Popi.art Skill Catalog Prototype", + "skills.footer.subtitle": "Skill-page fields are designed from index.json and SKILL.md, and can later connect directly to the remote registry.", + + "console.meta.title": "Popi.art Console", + "console.meta.description": "Static bilingual console prototype for Popi.art.", + "console.heading": ` +

Console

+

Manage product-layer keys, inspect credit usage, and install the CLI quickly.

+ `, + "console.stat1": ` +
Remaining credits
+ 0 + Total 0 credits + `, + "console.stat2": ` +
Used
+ 0 + Accumulated this month + `, + "console.stat3": ` +
Calls
+ 0 + CLI / API calls this month + `, + "console.apiHeading": "

API keys

", + "console.secrets": ` +
+
+
POPIART_KEY
+
pk_559196••••••••••••••••••••••••
+
+ +
+
+
+
+
POPIART_TOKEN
+
pk_559196••••••••••••••••••••••••
+
+ +
+ `, + "console.installHeading": "

Quick install command

", + "console.installCard": ` +
+ Quick Install + +
+
brew tap wtgoku-create/popi
+brew install wtgoku-create/popi/popiart
+export POPIART_KEY="pk_559196_demo"
+export POPIART_ENDPOINT="https://api.creatoragentos.io/v1"
+popiart bootstrap --agent codex --discoverable
+ `, + "console.footer.title": "Popi.art Console Prototype", + "console.footer.subtitle": "The console layout is heavily inspired by open-claw, while config fields and install commands are rewritten from popiartcli." + }; + + bindLocaleToggles(); + applyLocale(currentLocale); + + function captureSourceMap() { + const map = {}; + + document.querySelectorAll("[data-i18n]").forEach(function (node) { + map[node.getAttribute("data-i18n")] = node.textContent; + }); + + document.querySelectorAll("[data-i18n-html]").forEach(function (node) { + map[node.getAttribute("data-i18n-html")] = node.innerHTML.trim(); + }); + + document.querySelectorAll("[data-i18n-content]").forEach(function (node) { + map[node.getAttribute("data-i18n-content")] = node.getAttribute("content") || ""; + }); + + return map; + } + + function getInitialLocale() { + try { + const url = new URL(window.location.href); + const param = url.searchParams.get("lang"); + if (param === "en" || param === "zh") { + return param; + } + } catch (error) { + // Ignore URL parsing failures for unusual environments. + } + + try { + const stored = window.localStorage.getItem("popiart_locale"); + if (stored === "en" || stored === "zh") { + return stored; + } + } catch (error) { + // Ignore storage failures. + } + + return "zh"; + } + + function resolveTranslation(key, locale) { + if (locale === "en" && Object.prototype.hasOwnProperty.call(enTranslations, key)) { + return enTranslations[key]; + } + return sourceMap[key]; + } + + function applyLocale(locale) { + currentLocale = locale; + document.documentElement.lang = locale === "en" ? "en" : "zh-CN"; + + try { + window.localStorage.setItem("popiart_locale", locale); + } catch (error) { + // Ignore storage failures. + } + + try { + const url = new URL(window.location.href); + url.searchParams.set("lang", locale); + window.history.replaceState({}, "", url.toString()); + } catch (error) { + // Ignore history update failures. + } + + document.querySelectorAll("[data-i18n]").forEach(function (node) { + const value = resolveTranslation(node.getAttribute("data-i18n"), locale); + if (typeof value === "string") { + node.textContent = value; + } + }); + + document.querySelectorAll("[data-i18n-html]").forEach(function (node) { + const value = resolveTranslation(node.getAttribute("data-i18n-html"), locale); + if (typeof value === "string") { + node.innerHTML = value; + } + }); + + document.querySelectorAll("[data-i18n-content]").forEach(function (node) { + const value = resolveTranslation(node.getAttribute("data-i18n-content"), locale); + if (typeof value === "string") { + node.setAttribute("content", value); + } + }); + + document.querySelectorAll("[data-locale-toggle]").forEach(function (node) { + node.textContent = locale === "zh" ? "EN" : "中文"; + node.setAttribute("aria-label", locale === "zh" ? "Switch to English" : "切换到中文"); + }); + + bindCopyButtons(); + initRotatingHero(); + } + + function bindLocaleToggles() { + document.querySelectorAll("[data-locale-toggle]").forEach(function (node) { + node.onclick = function (event) { + event.preventDefault(); + applyLocale(currentLocale === "zh" ? "en" : "zh"); + }; + }); + } + + function bindCopyButtons() { + const messages = copyMessages[currentLocale] || copyMessages.zh; + + document.querySelectorAll("[data-copy-target]").forEach(function (button) { + button.onclick = async function () { + const targetId = button.getAttribute("data-copy-target"); + const target = targetId ? document.getElementById(targetId) : null; + if (!target) { + return; + } + + const original = button.textContent || messages.copy; + try { + await navigator.clipboard.writeText(target.textContent || ""); + button.textContent = messages.copied; + } catch (error) { + button.textContent = messages.failed; + } + + window.setTimeout(function () { + button.textContent = original; + }, 1400); + }; + }); + } + + function initRotatingHero() { + if (heroRotationTimer) { + window.clearInterval(heroRotationTimer); + heroRotationTimer = null; + } + + const rotating = document.querySelector("[data-hero-phrases]"); + if (!rotating) { + return; + } + + let phrases = []; + try { + phrases = JSON.parse(rotating.getAttribute("data-hero-phrases") || "[]"); + } catch (error) { + phrases = []; + } + + if (phrases.length > 1) { + let index = 0; + heroRotationTimer = window.setInterval(function () { + index = (index + 1) % phrases.length; + rotating.textContent = phrases[index]; + }, 2400); + } + } +}); diff --git a/web/LOCAL_DOMAIN.md b/web/LOCAL_DOMAIN.md new file mode 100644 index 0000000..a38aff7 --- /dev/null +++ b/web/LOCAL_DOMAIN.md @@ -0,0 +1,43 @@ +# Local Subdomain Preview + +Use a local subdomain-style URL instead of `127.0.0.1:3000`. + +## Recommended URL + +Modern browsers resolve `*.localhost` to the local machine automatically, so no `/etc/hosts` change is required. + +- `http://popiart.localhost:3100/zh` +- `http://popiart.localhost:3100/en` +- `http://popiart.localhost:3100/zh/console` +- `http://popiart.localhost:3100/zh/skills` + +## Run + +Development server: + +```bash +npm run dev:subdomain +``` + +Production preview: + +```bash +npm run build +npm run start:subdomain +``` + +## Why this works + +- The URL looks like a domain instead of `127.0.0.1`. +- It avoids port `3000` and uses `3100`. +- Session cookies stay same-origin because the app uses relative `/api/*` routes. + +## If you need a real custom domain later + +If you want a domain like `console.popi.art` locally, the next step is: + +1. Add a hosts mapping to `127.0.0.1`. +2. Run a reverse proxy on port `80` or `443`. +3. Point that domain to the Next.js app. + +That is a separate setup from the zero-config `*.localhost` approach. diff --git a/web/app/[locale]/console/page.tsx b/web/app/[locale]/console/page.tsx index 0fe7682..1dfbfe6 100644 --- a/web/app/[locale]/console/page.tsx +++ b/web/app/[locale]/console/page.tsx @@ -1,5 +1,34 @@ import Link from "next/link"; -import { getDictionary, type Locale } from "@/lib/site-content"; +import { CopyButton } from "@/components/copy-button"; +import { PopiartApiError, getBudgetSummary, getBudgetUsage, getPopiartEndpoint, getViewerSession } from "@/lib/popiart-api"; +import { getLiveCopy } from "@/lib/live-copy"; +import { type Locale } from "@/lib/site-content"; + +export const dynamic = "force-dynamic"; + +function formatNumber(locale: Locale, value: number) { + return new Intl.NumberFormat(locale === "zh" ? "zh-CN" : "en-US").format(value); +} + +function maskSecret(value?: string) { + if (!value) { + return "Unavailable"; + } + if (value.length <= 10) { + return value; + } + return `${value.slice(0, 8)} • • • • ${value.slice(-4)}`; +} + +function formatError(error: unknown) { + if (error instanceof PopiartApiError) { + return error.message; + } + if (error instanceof Error) { + return error.message; + } + return String(error); +} export default async function ConsolePage({ params, @@ -7,109 +36,167 @@ export default async function ConsolePage({ params: Promise<{ locale: string }>; }) { const { locale } = await params; - const dictionary = getDictionary(locale as Locale); + const typedLocale = locale as Locale; + const liveCopy = getLiveCopy(typedLocale); + const session = await getViewerSession(); + const isZh = typedLocale === "zh"; + + const pageTitle = isZh ? "控制台" : "Console"; + const pageSubtitle = isZh + ? "管理产品层密钥、查看用量、快速接入 CLI" + : "Manage product-layer keys, usage, and quick CLI onboarding."; + const remainingLabel = isZh ? "剩余 Credit" : "Remaining credits"; + const remainingHint = isZh ? "总计可用额度" : "Total available quota"; + const usedLabel = isZh ? "已使用" : "Used"; + const usedHint = isZh ? "本月累计消耗" : "Consumed this month"; + const callsLabel = isZh ? "调用次数" : "API calls"; + const callsHint = isZh ? "本月 CLI / API 调用" : "CLI / API calls this month"; + const keysTitle = isZh ? "API 密钥" : "API keys"; + const quickTitle = isZh ? "快速安装指令" : "Quick install commands"; + const quickBody = isZh + ? "把下面这段命令复制到终端,完成 CLI 安装、登录和验证。" + : "Copy these commands into your terminal to install the CLI, sign in, and verify the workflow."; + const sessionKeyLabel = "POPIART_SESSION_KEY"; + const endpointLabel = "POPIART_ENDPOINT"; + const copyLabel = isZh ? "复制" : "Copy"; + const copiedLabel = isZh ? "已复制" : "Copied"; + const loginCta = isZh ? "去登录" : "Sign in"; + const docsCta = isZh ? "查看文档" : "Open docs"; + + if (!session) { + return ( +
+
+
+

{pageTitle}

+

{pageSubtitle}

+
+
+ +
+
+

{liveCopy.console.unauthenticatedTitle}

+

{liveCopy.console.unauthenticatedBody}

+
+
+ + {loginCta} + + + {docsCta} + +
+
+
+ ); + } + + const [budgetResult, usageResult] = await Promise.allSettled([getBudgetSummary(), getBudgetUsage()]); + const budget = budgetResult.status === "fulfilled" ? budgetResult.value : null; + const usage = usageResult.status === "fulfilled" ? usageResult.value : null; + const loadErrors = [budgetResult, usageResult] + .filter((result) => result.status === "rejected") + .map((result) => formatError((result as PromiseRejectedResult).reason)); + + const metrics = [ + { + label: remainingLabel, + value: budget ? formatNumber(typedLocale, budget.remaining.tokens) : "--", + hint: remainingHint, + }, + { + label: usedLabel, + value: budget ? formatNumber(typedLocale, budget.used.tokens) : "--", + hint: usedHint, + }, + { + label: callsLabel, + value: usage ? formatNumber(typedLocale, usage.total.job_count) : "--", + hint: callsHint, + }, + ]; + + const quickStart = [ + "brew tap wtgoku-create/popi", + "brew install wtgoku-create/popi/popiart", + `export POPIART_ENDPOINT=${getPopiartEndpoint()}`, + "popiart auth login --key ", + "popiart skills list", + ].join("\n"); return (
-
-
-
-
{dictionary.console.tag}
-

{dictionary.console.title}

-

{dictionary.console.subtitle}

-
-
- - {dictionary.console.billingCta} - - - {dictionary.console.loginCta} - -
+
+
+

{pageTitle}

+

{pageSubtitle}

+ {loadErrors.length > 0 ? ( +
+ {liveCopy.console.loadErrorPrefix} + {loadErrors.join(" | ")} +
+ ) : null} +
-
- {dictionary.console.metrics.map((metric) => ( -
- {metric.value} - {metric.label} -
- ))} +
+ {metrics.map((metric) => ( +
+ {metric.label} + {metric.value} + {metric.hint} +
+ ))} +
+ +
+
+

{keysTitle}

+
+
+
+
+
+ {sessionKeyLabel} + {maskSecret(session.session_key)} +
+ {session.session_key ? ( + + ) : null} +
+
+
+
+
+ {endpointLabel} + {getPopiartEndpoint()} +
+ +
+
-
-
-
-
{dictionary.console.skillsTag}
-

{dictionary.console.skillsTitle}

-
-
- {dictionary.console.officialSkills.map((skill) => ( -
-
- {skill.name} - {skill.routeKey} -
-

{skill.status}

-
- ))} -
-
- -
-
-
{dictionary.console.keysTag}
-

{dictionary.console.keysTitle}

-
-
- {dictionary.console.apiKeys.map((key) => ( -
-
- {key.name} - {key.masked} -
-

{key.scope}

-
- ))} -
-
-
- -
-
-
-
{dictionary.console.usageTag}
-

{dictionary.console.usageTitle}

-
-
- {dictionary.console.usageRows.map((row) => ( -
-
- {row.name} - {row.count} -
-

{row.cost}

-
- ))} -
-
- -
-
-
{dictionary.console.planTag}
-

{dictionary.console.planTitle}

-
-

{dictionary.console.planDescription}

-
    - {dictionary.console.planBenefits.map((item) => ( -
  • {item}
  • - ))} -
- - {dictionary.console.planCta} - -
+
+
+

{quickTitle}

+

{quickBody}

+
+
+
+            {quickStart}
+          
+
); diff --git a/web/app/[locale]/login/page.tsx b/web/app/[locale]/login/page.tsx index c67e620..0d8e365 100644 --- a/web/app/[locale]/login/page.tsx +++ b/web/app/[locale]/login/page.tsx @@ -1,5 +1,8 @@ import Link from "next/link"; -import { getDictionary, type Locale } from "@/lib/site-content"; +import { LoginForm } from "@/components/login-form"; +import { getViewerSession } from "@/lib/popiart-api"; +import { getLiveCopy } from "@/lib/live-copy"; +import { type Locale } from "@/lib/site-content"; export default async function LoginPage({ params, @@ -7,39 +10,82 @@ export default async function LoginPage({ params: Promise<{ locale: string }>; }) { const { locale } = await params; - const dictionary = getDictionary(locale as Locale); + const typedLocale = locale as Locale; + const liveCopy = getLiveCopy(typedLocale); + const session = await getViewerSession(); + const isZh = typedLocale === "zh"; + + const title = isZh ? "使用 PopiNewAPI Key 登录" : "Sign in with your PopiNewAPI key"; + const subtitle = isZh + ? "页面登录后会换取产品层 session,控制台与 CLI 共用同一条认证链路。" + : "The web app exchanges your key for a product-layer session shared by the console and CLI."; + const cliTitle = isZh ? "CLI 登录指引" : "CLI sign-in guide"; + const cliBody = isZh + ? "如果你更习惯终端工作流,可以先安装 popiartcli,再用同一个 key 完成登录。" + : "If you prefer terminal-first workflows, install popiartcli and sign in with the same key."; + const installTitle = isZh ? "推荐命令" : "Recommended commands"; + const continueCta = isZh ? "进入控制台" : "Open console"; + + const cliCommands = [ + "brew tap wtgoku-create/popi", + "brew install wtgoku-create/popi/popiart", + "popiart auth login --key ", + "popiart skills list", + ].join("\n"); return (
-
-
-
{dictionary.login.tag}
-

{dictionary.login.title}

-

{dictionary.login.subtitle}

-
- - -
-

- {dictionary.login.termsPrefix}{" "} - {dictionary.login.termsLink}{" "} - {dictionary.login.and}{" "} - {dictionary.login.privacyLink} -

+
+
+
{isZh ? "SIGN IN" : "SIGN IN"}
+

{title}

+

{subtitle}

+ {session ? ( +
+
+ {session.user.name || session.user.email || session.user.id} + {liveCopy.login.alreadySignedIn} +
+ + {continueCta} + +
+ ) : ( + + )}
-
- {dictionary.login.benefits.map((benefit) => ( -
- {benefit.kicker} -

{benefit.title}

-

{benefit.description}

-
- ))} +
+
+
{isZh ? "CLI" : "CLI"}
+

{cliTitle}

+

{cliBody}

+
+
+ {installTitle} +

{liveCopy.login.helperBody}

+
+
+ {liveCopy.login.commandLabel} +
+              {cliCommands}
+            
+
+ + {isZh ? "查看文档" : "Read docs"} +
diff --git a/web/app/[locale]/page.tsx b/web/app/[locale]/page.tsx index 0f4a79d..f1f6049 100644 --- a/web/app/[locale]/page.tsx +++ b/web/app/[locale]/page.tsx @@ -7,17 +7,48 @@ export default async function HomePage({ params: Promise<{ locale: string }>; }) { const { locale } = await params; - const dictionary = getDictionary(locale as Locale); + const typedLocale = locale as Locale; + const dictionary = getDictionary(typedLocale); + const isZh = typedLocale === "zh"; + + const integrationTitle = isZh ? "popiartcli 接入原生流程" : "Native integration flow for popiartcli"; + const integrationSubtitle = isZh + ? "按照以下步骤,将 popiartcli 无缝接入你的 agent 工作流" + : "Follow these steps to plug popiartcli directly into your coding agent workflow."; + const stepOneTitle = isZh ? "复制安装指令到你的环境" : "Copy the install command into your environment"; + const stepOneHint = isZh + ? "先安装 CLI,再登录获取产品层 key" + : "Install the CLI first, then sign in to get the product-layer key."; + const stepTwoTitle = isZh + ? "执行 bootstrap,让 PopiArt 在你的 agent 环境中可发现" + : "Run bootstrap so PopiArt becomes discoverable in your agent environment."; + const stepTwoHint = isZh + ? "生成 completion、默认 discovery profile,并接入 Codex / OpenCode / OpenClaw" + : "Generate completion, default discovery profiles, and wire into Codex, OpenCode, or OpenClaw."; + const stepThreeTitle = isZh + ? "如需轮换或重置产品层 token,请前往控制台管理密钥" + : "Rotate or reset the product-layer token from the console when needed."; + const stepThreeHint = isZh + ? "使用 API key 与 session,而不是把 provider 密钥直接分发到本地环境" + : "Use API keys and sessions instead of distributing raw provider credentials to local machines."; + const stepFourTitle = isZh + ? "了解更多功能及服务使用须知,请查看开发者文档" + : "Read the developer docs for capabilities, runtime behavior, and usage guidance."; + const docsAction = isZh ? "查看开发者文档" : "Open developer docs"; + const installAction = isZh ? "登录" : "Sign in"; + const bootstrapAction = isZh ? "查看 Quick Start" : "Open Quick Start"; + const consoleAction = isZh ? "前往控制台" : "Open console"; + const sceneAction = isZh ? "了解更多" : "Learn more"; return (
-
+
{dictionary.home.tag}

{dictionary.home.title}

{dictionary.home.subtitle}

- + {dictionary.home.primaryCta} @@ -29,19 +60,21 @@ export default async function HomePage({ {dictionary.home.socialProof}
-
-
+
+ +
{dictionary.home.stageLabel}
{dictionary.home.stageValue}

{dictionary.home.stageDescription}

-
-
- {dictionary.home.heroPhrases.map((phrase) => ( - {phrase} - ))} -
-
@@ -52,43 +85,139 @@ export default async function HomePage({

{dictionary.home.scenesSubtitle}

- {dictionary.home.scenes.map((scene) => ( -
+ {dictionary.home.scenes.map((scene, index) => ( +
+
+ {scene.category} +
{scene.category}

{scene.name}

{scene.description}

+ + {sceneAction} +
))}
+
+
+
{dictionary.home.flowTag}
+

{integrationTitle}

+

{integrationSubtitle}

+
+
+
+
+
+
+
+                {dictionary.home.installMethods[0]?.command}
+              
+
+ {dictionary.home.installMethods.map((method) => ( + + {method.name} + + ))} +
+
+ {stepOneHint} + + {installAction} + +
+
+
+ +
+
+
+
+
+                {dictionary.home.bootstrapCommand}
+              
+
+ {stepTwoHint} + + {bootstrapAction} + + +
+
+
+ +
+
+
+
+
+ {stepThreeHint} + + {consoleAction} + + +
+
+
+ +
+
+
+
+ + {docsAction} + + +
+
+
+
+
-
{dictionary.home.flowTag}
-

{dictionary.home.flowTitle}

-

{dictionary.home.flowSubtitle}

+
{dictionary.pricing.tag}
+

{dictionary.pricing.title}

+

{dictionary.pricing.subtitle}

-
- {dictionary.home.flowSteps.map((step, index) => ( -
-
0{index + 1}
-

{step.title}

-

{step.description}

-
- ))} -
-
- -
-
-
{dictionary.home.trustTag}
-

{dictionary.home.trustTitle}

-
-
- {dictionary.home.trustStats.map((stat) => ( -
- {stat.value} - {stat.label} +
+ {dictionary.pricing.plans.map((plan) => ( +
+
+
+
{plan.badge}
+

{plan.name}

+

{plan.summary}

+
+
+ {plan.price} + {plan.cadence} +
+
+
    + {plan.features.map((feature) => ( +
  • {feature}
  • + ))} +
+ + {plan.cta} +
))}
diff --git a/web/app/[locale]/skills/page.tsx b/web/app/[locale]/skills/page.tsx new file mode 100644 index 0000000..34a929e --- /dev/null +++ b/web/app/[locale]/skills/page.tsx @@ -0,0 +1,157 @@ +import Link from "next/link"; +import { + PopiartApiError, + getSkillsCatalog, + getViewerSession, +} from "@/lib/popiart-api"; +import { getLiveCopy } from "@/lib/live-copy"; +import { type Locale } from "@/lib/site-content"; + +export const dynamic = "force-dynamic"; + +function formatError(error: unknown) { + if (error instanceof PopiartApiError) { + return error.message; + } + if (error instanceof Error) { + return error.message; + } + return String(error); +} + +export default async function SkillsPage({ + params, + searchParams, +}: { + params: Promise<{ locale: string }>; + searchParams: Promise<{ search?: string | string[] }>; +}) { + const { locale } = await params; + const rawSearch = (await searchParams).search; + const search = Array.isArray(rawSearch) ? rawSearch[0] : rawSearch; + const typedLocale = locale as Locale; + const liveCopy = getLiveCopy(typedLocale); + const session = await getViewerSession(); + + if (!session) { + return ( +
+
+
+
{liveCopy.skills.tag}
+

{liveCopy.skills.title}

+

{liveCopy.skills.subtitle}

+
+
+ +
+
+

{liveCopy.skills.unauthenticatedTitle}

+

{liveCopy.skills.unauthenticatedBody}

+
+
+ + {liveCopy.skills.loginCta} + + + {liveCopy.skills.docsCta} + +
+
+
+ ); + } + + let catalog = null; + let loadError: string | null = null; + + try { + catalog = await getSkillsCatalog(search?.trim()); + } catch (error) { + loadError = formatError(error); + } + + return ( +
+
+
+
{liveCopy.skills.tag}
+

{liveCopy.skills.title}

+

{liveCopy.skills.subtitle}

+
+
+ +
+
+ +
+ + +
+
+ + {loadError ? ( +
+ {liveCopy.skills.loadErrorPrefix} + {loadError} +
+ ) : null} + +
+ + {liveCopy.skills.resultsPrefix}: {catalog?.total ?? 0} + + {session.user.name || session.user.email || session.user.id} +
+
+ +
+ {catalog && catalog.items.length > 0 ? ( + catalog.items.map((skill) => ( +
+ {skill.version} +

{skill.name}

+

{skill.description}

+
+
+
{liveCopy.skills.routeKey}
+
{skill.route_key || skill.id}
+
+
+
{liveCopy.skills.modelType}
+
{skill.model_type}
+
+
+
{liveCopy.skills.latency}
+
{skill.estimated_duration_s}s
+
+
+
{liveCopy.skills.tags}
+
{skill.tags.join(", ") || "-"}
+
+
+
+ )) + ) : ( +
+
+

{liveCopy.skills.noResults}

+

{search ? `"${search}"` : liveCopy.skills.subtitle}

+
+
+ )} +
+
+ ); +} diff --git a/web/app/api/auth/login/route.ts b/web/app/api/auth/login/route.ts new file mode 100644 index 0000000..2ba0c15 --- /dev/null +++ b/web/app/api/auth/login/route.ts @@ -0,0 +1,77 @@ +import { NextResponse } from "next/server"; +import { + SESSION_COOKIE_NAME, + type LoginResponse, + popiartFetchEnvelope, +} from "@/lib/popiart-api"; + +export async function POST(request: Request) { + let body: { key?: string } = {}; + + try { + body = (await request.json()) as { key?: string }; + } catch { + body = {}; + } + + const key = body.key?.trim(); + if (!key) { + return NextResponse.json( + { + ok: false, + error: { + code: "VALIDATION_ERROR", + message: "key is required", + }, + }, + { status: 400 }, + ); + } + + try { + const { response, payload } = await popiartFetchEnvelope("/auth/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ key }), + }); + + const proxyResponse = NextResponse.json( + payload ?? { + ok: false, + error: { + code: "SERVER_ERROR", + message: "invalid response from popiartServer", + }, + }, + { status: response.status }, + ); + + if (response.ok && payload?.ok && payload.data?.token) { + proxyResponse.cookies.set({ + name: SESSION_COOKIE_NAME, + value: payload.data.token, + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + path: "/", + maxAge: 60 * 60 * 24 * 7, + }); + } + + return proxyResponse; + } catch (error) { + return NextResponse.json( + { + ok: false, + error: { + code: "SERVER_ERROR", + message: "failed to reach popiartServer", + details: error instanceof Error ? error.message : String(error), + }, + }, + { status: 502 }, + ); + } +} diff --git a/web/app/api/auth/logout/route.ts b/web/app/api/auth/logout/route.ts new file mode 100644 index 0000000..a257ebd --- /dev/null +++ b/web/app/api/auth/logout/route.ts @@ -0,0 +1,43 @@ +import { NextResponse } from "next/server"; +import { cookies } from "next/headers"; +import { + SESSION_COOKIE_NAME, + popiartFetchEnvelope, +} from "@/lib/popiart-api"; + +export async function POST() { + const cookieStore = await cookies(); + const token = cookieStore.get(SESSION_COOKIE_NAME)?.value; + + try { + if (token) { + await popiartFetchEnvelope<{ logged_out: boolean }>("/auth/logout", { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + }, + }); + } + } catch { + // Clear the local session even if the upstream logout call fails. + } + + const response = NextResponse.json({ + ok: true, + data: { + logged_out: true, + }, + }); + + response.cookies.set({ + name: SESSION_COOKIE_NAME, + value: "", + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + path: "/", + maxAge: 0, + }); + + return response; +} diff --git a/web/app/globals.css b/web/app/globals.css index 10922e8..e0de433 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -1,17 +1,20 @@ :root { - --bg: #f6f1e8; - --panel: rgba(255, 255, 255, 0.78); - --panel-strong: #fffaf3; - --ink: #1e1b16; - --muted: #665f57; - --line: rgba(30, 27, 22, 0.1); - --accent: #ea7c2b; - --accent-soft: #ffd9bf; - --shadow: 0 24px 64px rgba(36, 24, 12, 0.12); - --radius-xl: 32px; - --radius-lg: 24px; - --radius-md: 18px; - --container: 1200px; + --bg: #f5f7fb; + --panel: rgba(255, 255, 255, 0.92); + --panel-strong: #ffffff; + --ink: #181b24; + --muted: #6f7d98; + --line: rgba(113, 126, 153, 0.17); + --accent: #2c6bff; + --accent-soft: #edf3ff; + --accent-strong: #0f5eff; + --shadow: 0 14px 40px rgba(84, 96, 122, 0.08); + --shadow-soft: 0 8px 22px rgba(84, 96, 122, 0.06); + --shadow-hover: 0 18px 46px rgba(70, 83, 112, 0.12); + --radius-xl: 30px; + --radius-lg: 22px; + --radius-md: 16px; + --container: 1280px; --font-display: "Avenir Next", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; --font-body: "IBM Plex Sans", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; } @@ -22,6 +25,7 @@ html { scroll-behavior: smooth; + background: var(--bg); } body { @@ -29,10 +33,12 @@ body { color: var(--ink); font-family: var(--font-body); background: - radial-gradient(circle at top left, rgba(234, 124, 43, 0.12), transparent 32%), - radial-gradient(circle at top right, rgba(163, 214, 197, 0.18), transparent 24%), - linear-gradient(180deg, #fbf8f2 0%, #f4ede2 100%); + radial-gradient(circle at 12% 0%, rgba(44, 107, 255, 0.07), transparent 28%), + radial-gradient(circle at 100% 0%, rgba(44, 107, 255, 0.05), transparent 24%), + linear-gradient(180deg, #fafcff 0%, #f5f7fb 100%); min-height: 100vh; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; } a { @@ -40,11 +46,16 @@ a { text-decoration: none; } +::selection { + background: rgba(44, 107, 255, 0.14); +} + button, input, textarea, select { font: inherit; + -webkit-tap-highlight-color: transparent; } pre, @@ -59,7 +70,7 @@ code { .site-header, .site-footer, .main-content { - width: min(calc(100% - 32px), var(--container)); + width: min(calc(100% - 44px), var(--container)); margin: 0 auto; } @@ -67,23 +78,23 @@ code { position: sticky; top: 0; z-index: 20; - display: flex; + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; - justify-content: space-between; gap: 20px; - margin-top: 16px; - padding: 16px 20px; + margin-top: 24px; + padding: 18px 22px; border: 1px solid var(--line); border-radius: 999px; - backdrop-filter: blur(14px); - background: rgba(255, 249, 242, 0.78); - box-shadow: 0 10px 30px rgba(55, 34, 20, 0.06); + backdrop-filter: blur(18px) saturate(130%); + background: rgba(255, 255, 255, 0.82); + box-shadow: 0 8px 28px rgba(73, 82, 108, 0.06); } .brand-lockup { display: flex; align-items: center; - gap: 14px; + gap: 12px; } .brand-lockup strong, @@ -102,24 +113,26 @@ code { .brand-lockup span { display: block; color: var(--muted); - font-size: 13px; + font-size: 12px; + letter-spacing: 0.03em; } .brand-mark { display: grid; place-items: center; - width: 48px; - height: 48px; - border-radius: 16px; - background: linear-gradient(135deg, #1d1b18 0%, #614126 100%); + width: 18px; + height: 18px; + border-radius: 999px; + background: linear-gradient(135deg, #85a4ff 0%, #2c6bff 100%); color: white; - font-weight: 700; - letter-spacing: 0.08em; + font-size: 0; + box-shadow: 0 0 0 8px rgba(44, 107, 255, 0.1); } .main-nav, .header-actions, .locale-switch, +.auth-cluster, .hero-actions, .hero-proof, .dashboard-actions, @@ -131,76 +144,287 @@ code { } .main-nav { - gap: 18px; + gap: 10px; color: var(--muted); font-size: 15px; + justify-self: center; + flex-wrap: wrap; } .main-nav a:hover, .footer-links a:hover, -.locale-switch a:hover { +.locale-switch a:hover, +.locale-switch button:hover { color: var(--ink); } +.nav-link { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 46px; + padding: 0 20px; + white-space: nowrap; + border-radius: 999px; + border: 1px solid transparent; + color: var(--muted); + transition: + border-color 180ms ease, + background 180ms ease, + color 180ms ease, + box-shadow 180ms ease, + transform 180ms ease; +} + +.nav-link:hover { + border-color: rgba(113, 126, 153, 0.12); + background: rgba(255, 255, 255, 0.72); +} + +.nav-link-active { + border-color: var(--line); + background: rgba(255, 255, 255, 0.96); + color: var(--ink); + box-shadow: 0 6px 18px rgba(84, 94, 126, 0.07); +} + .header-actions { gap: 14px; + justify-self: end; + justify-content: flex-end; + min-width: 0; + flex-wrap: nowrap; } .locale-switch { - gap: 10px; - padding: 6px; - border-radius: 999px; - background: rgba(30, 27, 22, 0.05); + position: relative; + flex-shrink: 0; } -.locale-switch a { - padding: 6px 10px; - border-radius: 999px; +.locale-menu-button, +.locale-menu-item { color: var(--muted); - font-size: 13px; + font-size: 14px; + font-weight: 500; + white-space: nowrap; + border: 0; + background: transparent; + cursor: pointer; + transition: + background 180ms ease, + color 180ms ease, + box-shadow 180ms ease, + border-color 180ms ease, + transform 180ms ease; } -.locale-switch .locale-active { - background: white; +.locale-menu-button { + display: inline-flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-width: 104px; + height: 46px; + padding: 0 16px; + border: 1px solid rgba(113, 126, 153, 0.12); + border-radius: 999px; + background: rgba(255, 255, 255, 0.78); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.72); +} + +.locale-menu-button:hover, +.locale-menu-button-open { + background: rgba(255, 255, 255, 0.96); color: var(--ink); - box-shadow: 0 6px 16px rgba(30, 27, 22, 0.08); + border-color: rgba(113, 126, 153, 0.22); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.84), + 0 8px 18px rgba(84, 94, 126, 0.08); +} + +.locale-menu-chevron { + font-size: 12px; + color: var(--muted); + transition: transform 180ms ease, color 180ms ease; +} + +.locale-menu-button-open .locale-menu-chevron, +.locale-menu-button:hover .locale-menu-chevron { + color: var(--ink); +} + +.locale-menu-chevron-open { + transform: rotate(180deg); +} + +.locale-menu-list { + position: absolute; + right: 0; + top: calc(100% + 10px); + z-index: 30; + display: grid; + min-width: 164px; + padding: 8px; + border: 1px solid rgba(113, 126, 153, 0.14); + border-radius: 20px; + background: rgba(255, 255, 255, 0.98); + box-shadow: 0 20px 44px rgba(84, 96, 122, 0.14); + backdrop-filter: blur(18px); +} + +.locale-menu-item { + display: flex; + align-items: center; + width: 100%; + min-height: 46px; + padding: 0 14px; + border-radius: 14px; + text-align: left; +} + +.locale-menu-item:hover { + background: rgba(44, 107, 255, 0.06); + color: var(--ink); +} + +.locale-menu-item-active { + background: rgba(44, 107, 255, 0.08); + color: var(--ink); + font-weight: 600; +} + +.auth-cluster { + gap: 10px; + min-width: 0; + flex-wrap: nowrap; +} + +.session-pill { + display: inline-flex; + align-items: center; + gap: 10px; + min-width: 96px; + width: 96px; + max-width: 96px; + padding: 6px 12px 6px 6px; + border: 1px solid var(--line); + border-radius: 999px; + background: rgba(255, 255, 255, 0.72); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6); + transition: + border-color 160ms ease, + background 160ms ease, + box-shadow 160ms ease, + transform 160ms ease; +} + +.session-pill:hover { + background: rgba(255, 255, 255, 0.92); + border-color: rgba(113, 126, 153, 0.24); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.72), + 0 8px 18px rgba(84, 96, 122, 0.08); +} + +.session-pill:active { + transform: scale(0.985); +} + +.session-avatar { + display: inline-grid; + place-items: center; + width: 32px; + height: 32px; + min-width: 32px; + min-height: 32px; + flex: 0 0 32px; + aspect-ratio: 1 / 1; + border-radius: 999px; + background: linear-gradient(135deg, #85a4ff 0%, #2c6bff 100%); + color: white; + font-size: 12px; + font-weight: 700; +} + +.session-name, +.session-hint { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 14px; +} + +.session-name { + flex: 1 1 auto; + min-width: 0; +} + +.session-hint { + color: var(--muted); } .button { display: inline-flex; align-items: center; justify-content: center; - min-height: 46px; - padding: 0 18px; + min-height: 48px; + padding: 0 20px; + white-space: nowrap; border-radius: 999px; border: 1px solid transparent; font-weight: 600; - transition: transform 160ms ease, box-shadow 160ms ease, background 160ms ease; + transition: + transform 160ms ease, + box-shadow 160ms ease, + background 160ms ease, + border-color 160ms ease, + color 160ms ease; } .button:hover { transform: translateY(-1px); } +.button:active { + transform: translateY(0) scale(0.985); +} + .button-dark { - background: #1f1c18; + background: linear-gradient(180deg, #1b1c22 0%, #121318 100%); color: white; - box-shadow: 0 12px 24px rgba(31, 28, 24, 0.18); + box-shadow: 0 12px 24px rgba(23, 27, 36, 0.12); +} + +.button-dark:hover { + box-shadow: 0 16px 30px rgba(23, 27, 36, 0.16); } .button-light { background: rgba(255, 255, 255, 0.86); color: var(--ink); border-color: var(--line); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.78); +} + +.button-light:hover { + background: rgba(255, 255, 255, 0.96); + border-color: rgba(113, 126, 153, 0.22); } .button-small { min-height: 40px; - padding: 0 14px; + min-width: 72px; + padding: 0 16px; +} + +.button-auth-stable { + min-width: 112px; + width: 112px; + max-width: 112px; } .main-content { - padding: 28px 0 48px; + padding: 32px 0 56px; } .page-stack { @@ -240,10 +464,13 @@ code { } .hero-panel { - grid-template-columns: 1.2fr 0.8fr; - padding: 42px; - min-height: 520px; + grid-template-columns: 1.08fr 0.92fr; + padding: 48px; + min-height: 470px; overflow: hidden; + background: + radial-gradient(circle at 100% 0%, rgba(44, 107, 255, 0.08), transparent 32%), + linear-gradient(180deg, rgba(255, 255, 255, 0.94) 0%, rgba(255, 255, 255, 0.9) 100%); } .hero-copy, @@ -260,12 +487,37 @@ code { position: relative; } +.page-stack > * { + animation: rise-in 460ms cubic-bezier(0.2, 0.8, 0.2, 1) both; +} + +.page-stack > *:nth-child(2) { + animation-delay: 40ms; +} + +.page-stack > *:nth-child(3) { + animation-delay: 80ms; +} + +.page-stack > *:nth-child(4) { + animation-delay: 120ms; +} + .hero-copy h1, .section-heading h1 { margin: 0; - font-size: clamp(2.8rem, 5vw, 5rem); - line-height: 0.96; + font-size: clamp(3rem, 5vw, 5.3rem); + line-height: 0.95; max-width: 12ch; + letter-spacing: -0.04em; +} + +.section-heading h2 { + margin: 0; + font-size: clamp(2rem, 3vw, 2.85rem); + line-height: 1.04; + max-width: 14ch; + letter-spacing: -0.03em; } .hero-description, @@ -280,34 +532,41 @@ code { .table-row p, .login-panel p, .benefit-card p, -.stage-card p { +.stage-card p, +.install-note { color: var(--muted); line-height: 1.65; } +.hero-description { + max-width: 34rem; + font-size: 1.14rem; +} + .hero-actions { gap: 14px; - margin-top: 28px; + margin-top: 30px; flex-wrap: wrap; } .hero-proof { gap: 18px; - margin-top: 18px; + margin-top: 20px; flex-wrap: wrap; color: var(--muted); font-size: 14px; } .hero-stage { - align-content: end; + align-content: center; } .stage-card { - padding: 24px; + padding: 26px; border-radius: 28px; border: 1px solid var(--line); - background: rgba(255, 255, 255, 0.8); + background: rgba(255, 255, 255, 0.96); + box-shadow: var(--shadow-soft); } .stage-card + .stage-card { @@ -316,7 +575,7 @@ code { .stage-card-primary { background: - linear-gradient(155deg, rgba(255, 231, 212, 0.95) 0%, rgba(255, 255, 255, 0.88) 100%); + linear-gradient(155deg, rgba(237, 243, 255, 0.98) 0%, rgba(255, 255, 255, 0.98) 100%); } .stage-label, @@ -329,6 +588,360 @@ code { font-weight: 700; } +.hero-panel-reference { + min-height: 580px; + padding: 56px 54px; + grid-template-columns: minmax(0, 1.08fr) minmax(0, 0.92fr); + background: + radial-gradient(circle at 100% 0%, rgba(44, 107, 255, 0.06), transparent 30%), + linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(246, 249, 255, 0.96) 100%); +} + +.hero-panel-reference .hero-copy { + align-self: center; + padding-right: 12px; +} + +.hero-panel-reference .eyebrow { + margin-bottom: 22px; +} + +.hero-panel-reference .hero-description { + max-width: 38rem; + font-size: 1.18rem; + color: var(--muted); +} + +.hero-panel-reference .hero-actions { + margin-top: 36px; +} + +.hero-panel-reference .hero-proof { + margin-top: 24px; + gap: 22px; + font-size: 15px; +} + +.hero-stage-reference { + position: relative; + min-height: 440px; + align-self: stretch; +} + +.hero-pattern { + position: absolute; + inset: 4px; + overflow: hidden; + border: 1px solid rgba(113, 126, 153, 0.16); + border-radius: 34px; + background: + radial-gradient(circle at 0% 0%, rgba(44, 107, 255, 0.1), transparent 22%), + radial-gradient(circle at 100% 100%, rgba(44, 107, 255, 0.06), transparent 24%), + linear-gradient(180deg, #fcfdff 0%, #f2f7ff 100%); +} + +.hero-pattern::before { + content: ""; + position: absolute; + inset: 0; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.28) 0%, rgba(255, 255, 255, 0) 18%), + linear-gradient(90deg, rgba(255, 255, 255, 0.22) 0%, rgba(255, 255, 255, 0) 32%); + pointer-events: none; +} + +.hero-pattern-row { + display: flex; + gap: 18px; + white-space: nowrap; + padding: 20px 24px 0; + color: rgba(44, 107, 255, 0.18); + font-size: 13px; + font-weight: 700; + letter-spacing: 0.28em; + text-transform: uppercase; +} + +.hero-pattern-row:nth-child(2n) { + transform: translateX(-28px); +} + +.hero-pattern-row:nth-child(3n) { + transform: translateX(-12px); +} + +.stage-card-floating { + position: absolute; + right: 34px; + bottom: 34px; + width: min(380px, calc(100% - 68px)); + border-radius: 32px; + box-shadow: 0 16px 34px rgba(84, 96, 122, 0.09); +} + +.scene-card { + display: grid; + align-content: start; + padding: 0; + overflow: hidden; +} + +.scene-card > .card-kicker, +.scene-card > h3, +.scene-card > p, +.scene-card > .scene-link { + margin-left: 26px; + margin-right: 26px; +} + +.scene-card > .card-kicker { + margin-top: 18px; +} + +.scene-card > h3 { + margin-top: 10px; + margin-bottom: 8px; +} + +.scene-card > p { + margin-top: 0; + margin-bottom: 20px; +} + +.scene-link { + display: inline-flex; + align-items: center; + gap: 8px; + margin-bottom: 26px; + color: var(--accent); + font-weight: 700; +} + +.scene-link::after { + content: "→"; +} + +.scene-visual { + position: relative; + min-height: 212px; + border-bottom: 1px solid rgba(173, 185, 214, 0.24); + overflow: hidden; +} + +.scene-visual::before, +.scene-visual::after { + content: ""; + position: absolute; + border-radius: 999px; + opacity: 0.92; +} + +.scene-visual::before { + width: 74%; + height: 74%; + left: -10%; + top: -14%; + background: rgba(255, 255, 255, 0.72); + filter: blur(6px); +} + +.scene-visual::after { + width: 44%; + height: 44%; + right: 8%; + bottom: 10%; + background: rgba(255, 255, 255, 0.48); + filter: blur(2px); +} + +.scene-visual-1 { + background: linear-gradient(145deg, #edf3ff 0%, #eef5ff 50%, #fbfdff 100%); +} + +.scene-visual-2 { + background: linear-gradient(145deg, #eef7ff 0%, #ebf5ff 45%, #fbfeff 100%); +} + +.scene-visual-3 { + background: linear-gradient(145deg, #f3f7ff 0%, #eef4ff 45%, #fbfdff 100%); +} + +.scene-visual-4 { + background: linear-gradient(145deg, #edf8ff 0%, #eefcff 45%, #fbffff 100%); +} + +.scene-visual-5 { + background: linear-gradient(145deg, #eef4ff 0%, #f2f7ff 48%, #fbfdff 100%); +} + +.scene-visual-6 { + background: linear-gradient(145deg, #eef2ff 0%, #f4f8ff 52%, #ffffff 100%); +} + +.scene-visual .card-kicker { + position: absolute; + left: 22px; + bottom: 20px; + padding: 8px 12px; + border: 1px solid rgba(173, 185, 214, 0.24); + border-radius: 999px; + background: rgba(255, 255, 255, 0.72); + backdrop-filter: blur(10px); +} + +.integration-shell { + display: grid; + gap: 34px; + padding: 30px 0 8px; +} + +.integration-heading { + display: grid; + gap: 16px; + justify-items: center; + text-align: center; + padding: 22px 0 12px; +} + +.integration-heading .eyebrow { + font-size: 14px; + letter-spacing: 0.22em; +} + +.integration-heading h2 { + margin: 0; + max-width: 12ch; + font-family: var(--font-display); + font-size: clamp(2.7rem, 4.6vw, 4.4rem); + line-height: 0.98; + letter-spacing: -0.05em; +} + +.integration-heading p { + margin: 0; + max-width: 30ch; + color: var(--muted); + font-size: 1.18rem; + line-height: 1.62; +} + +.integration-steps { + display: grid; + gap: 22px; +} + +.integration-step-card { + padding: 44px 46px 40px; + border: 1px solid rgba(113, 126, 153, 0.18); + border-radius: 36px; + background: rgba(255, 255, 255, 0.98); + box-shadow: var(--shadow-soft); +} + +.integration-step-head { + display: flex; + align-items: center; + gap: 18px; + flex-wrap: wrap; +} + +.integration-step-dot { + width: 18px; + height: 18px; + flex: 0 0 auto; + border-radius: 999px; + background: linear-gradient(135deg, #83a7ff 0%, #2c6bff 100%); + box-shadow: 0 0 0 6px rgba(44, 107, 255, 0.08); +} + +.integration-step-label { + color: var(--accent); + font-size: 15px; + font-weight: 800; + letter-spacing: 0.18em; + text-transform: uppercase; +} + +.integration-step-head h3 { + margin: 0; + font-family: var(--font-display); + font-size: clamp(1.55rem, 2.6vw, 2.2rem); + line-height: 1.18; + letter-spacing: -0.03em; +} + +.integration-step-body { + display: grid; + gap: 22px; + margin-top: 28px; + padding-left: 36px; +} + +.integration-step-body pre { + margin: 0; + padding: 22px 24px; + overflow: auto; + border-radius: 26px; + background: #161727; + color: #f5f7ff; + border: 1px solid rgba(255, 255, 255, 0.06); + box-shadow: none; +} + +.integration-method-pills { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.integration-method-pills .pill { + min-height: 36px; + padding: 0 14px; + border: 1px solid rgba(44, 107, 255, 0.12); + background: rgba(44, 107, 255, 0.06); + color: #4f6fba; +} + +.integration-step-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + flex-wrap: wrap; +} + +.integration-step-actions span { + color: var(--muted); + font-size: 1.05rem; + line-height: 1.55; +} + +.integration-step-actions .button-dark { + min-width: 132px; + min-height: 72px; + padding: 0 30px; +} + +.integration-step-body-compact { + padding-top: 4px; +} + +.integration-docs-link { + min-height: 72px; + padding: 0 28px; + gap: 14px; + color: #2371ff; + font-size: 1.25rem; + font-weight: 700; + border-color: rgba(113, 126, 153, 0.2); + background: rgba(255, 255, 255, 0.94); +} + +.integration-docs-link span { + font-size: 1.4rem; +} + .stage-stat { margin-top: 12px; font-size: clamp(2rem, 4vw, 3.4rem); @@ -345,7 +958,8 @@ code { .stage-mini-grid span { padding: 14px 16px; border-radius: 16px; - background: rgba(30, 27, 22, 0.05); + background: rgba(44, 107, 255, 0.055); + border: 1px solid rgba(44, 107, 255, 0.08); font-size: 14px; } @@ -359,7 +973,7 @@ code { .feature-card, .step-card, .stat-card { - padding: 28px; + padding: 30px; } .hero-tight .section-heading h1 { @@ -369,12 +983,12 @@ code { .section-panel-accent { background: - linear-gradient(145deg, rgba(255, 235, 221, 0.8) 0%, rgba(255, 249, 242, 0.86) 100%); + linear-gradient(145deg, rgba(239, 244, 255, 0.88) 0%, rgba(255, 255, 255, 0.98) 100%); } .section-heading { display: grid; - gap: 8px; + gap: 10px; } .section-heading.compact h2 { @@ -390,6 +1004,10 @@ code { grid-template-columns: repeat(3, minmax(0, 1fr)); } +.install-grid { + align-items: stretch; +} + .feature-card h3, .faq-card h3, .step-card h3 { @@ -397,6 +1015,34 @@ code { font-size: 1.3rem; } +.feature-card, +.faq-card, +.step-card, +.pricing-card, +.docs-card, +.dashboard-card, +.stat-card, +.benefit-card { + background: rgba(255, 255, 255, 0.96); + transition: + transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), + box-shadow 220ms cubic-bezier(0.2, 0.8, 0.2, 1), + border-color 220ms cubic-bezier(0.2, 0.8, 0.2, 1); +} + +.feature-card:hover, +.faq-card:hover, +.step-card:hover, +.pricing-card:hover, +.docs-card:hover, +.dashboard-card:hover, +.stat-card:hover, +.benefit-card:hover { + transform: translateY(-3px); + border-color: rgba(113, 126, 153, 0.24); + box-shadow: var(--shadow-hover); +} + .steps-grid, .stats-grid, .faq-grid, @@ -414,7 +1060,7 @@ code { width: 52px; height: 52px; border-radius: 16px; - background: rgba(234, 124, 43, 0.15); + background: rgba(44, 107, 255, 0.1); color: var(--accent); font-weight: 700; } @@ -438,9 +1084,17 @@ code { align-content: start; } +.pricing-card h3 { + margin: 12px 0 8px; + font-family: var(--font-display); + font-size: 1.35rem; + line-height: 1.18; + letter-spacing: -0.02em; +} + .pricing-card-highlight { background: - linear-gradient(155deg, rgba(255, 225, 197, 0.88) 0%, rgba(255, 250, 243, 0.95) 100%); + linear-gradient(155deg, rgba(239, 244, 255, 0.96) 0%, rgba(255, 255, 255, 0.98) 100%); } .pricing-top { @@ -482,13 +1136,57 @@ code { } .docs-card pre, -.feature-card pre { +.feature-card pre, +.dashboard-card pre, +.code-card pre { margin: 14px 0; padding: 16px; overflow: auto; border-radius: 18px; - background: #171411; - color: #f7f3ec; + background: #151829; + color: #f4f7ff; + border: 1px solid rgba(255, 255, 255, 0.06); +} + +.install-card { + display: grid; + align-content: start; +} + +.install-card pre { + margin-top: 18px; +} + +.install-note { + margin: 0; + font-size: 14px; +} + +.bootstrap-callout { + display: grid; + grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.05fr); + gap: 24px; + margin-top: 26px; + padding: 26px 28px; + border: 1px solid var(--line); + border-radius: 28px; + background: + linear-gradient(145deg, rgba(239, 244, 255, 0.82) 0%, rgba(255, 255, 255, 0.94) 100%); + box-shadow: var(--shadow-soft); +} + +.bootstrap-callout h3 { + margin: 10px 0 10px; + font-size: 1.35rem; +} + +.bootstrap-callout p { + color: var(--muted); + line-height: 1.65; +} + +.bootstrap-callout pre { + margin: 0; } .docs-table, @@ -501,7 +1199,7 @@ code { .table-row { justify-content: space-between; gap: 20px; - padding: 14px 0; + padding: 16px 0; border-bottom: 1px solid var(--line); align-items: flex-start; } @@ -530,7 +1228,7 @@ code { .login-panel { background: - linear-gradient(165deg, rgba(255, 249, 240, 0.96) 0%, rgba(255, 255, 255, 0.9) 100%); + linear-gradient(165deg, rgba(239, 244, 255, 0.98) 0%, rgba(255, 255, 255, 0.96) 100%); } .login-actions { @@ -539,11 +1237,140 @@ code { margin-top: 28px; } +.login-form-stack { + display: grid; + gap: 16px; + margin-top: 26px; +} + +.auth-form, +.search-form { + display: grid; + gap: 10px; +} + +.field-label { + font-size: 14px; + font-weight: 600; +} + +.field-hint { + margin: 0; + color: var(--muted); + font-size: 14px; + line-height: 1.65; +} + +.text-input { + width: 100%; + min-height: 52px; + padding: 0 16px; + border: 1px solid var(--line); + border-radius: 18px; + background: rgba(255, 255, 255, 0.86); + color: var(--ink); + transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease; +} + +.text-input:focus { + outline: 0; + border-color: rgba(44, 107, 255, 0.32); + background: rgba(255, 255, 255, 0.96); + box-shadow: + 0 0 0 4px rgba(44, 107, 255, 0.08), + inset 0 1px 0 rgba(255, 255, 255, 0.8); +} + +.inline-note, +.code-card, +.session-summary, +.status-banner { + padding: 18px 20px; + border: 1px solid var(--line); + border-radius: 22px; + background: rgba(255, 255, 255, 0.94); +} + +.inline-note strong, +.status-banner strong { + display: block; + margin-bottom: 6px; +} + +.inline-note p, +.status-banner span { + color: var(--muted); + line-height: 1.65; +} + +.status-banner { + display: grid; + gap: 6px; +} + +.status-banner-error { + background: rgba(171, 60, 60, 0.06); + border-color: rgba(171, 60, 60, 0.12); +} + +.session-summary { + display: grid; + gap: 12px; +} + +.session-summary-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; +} + +.session-summary-row span { + color: var(--muted); + font-size: 14px; +} + +.code-card { + background: #151829; +} + +.code-card pre { + margin-bottom: 0; +} + +.search-row, +.pill-row { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.search-row .text-input { + flex: 1 1 320px; +} + +.pill { + display: inline-flex; + align-items: center; + min-height: 34px; + padding: 0 12px; + border-radius: 999px; + background: rgba(44, 107, 255, 0.06); + color: var(--muted); + font-size: 13px; +} + .benefit-card { display: grid; align-content: start; } +.login-benefits { + display: grid; + gap: 20px; +} + .dashboard-top { display: flex; justify-content: space-between; @@ -561,16 +1388,182 @@ code { align-items: start; } +.console-hero { + gap: 18px; +} + +.console-hero .section-heading h1 { + max-width: none; + font-size: clamp(2.8rem, 5vw, 4.4rem); +} + +.console-metrics-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 20px; +} + +.console-metric-card, +.console-surface-card, +.login-guide-panel { + border: 1px solid var(--line); + border-radius: 28px; + background: rgba(255, 255, 255, 0.96); + box-shadow: var(--shadow-soft); +} + +.console-metric-card { + display: grid; + gap: 14px; + padding: 30px; +} + +.console-metric-card span, +.console-key-copy span { + color: var(--muted); +} + +.console-metric-card strong { + font-family: var(--font-display); + font-size: clamp(2.5rem, 4vw, 3.4rem); + line-height: 1; + letter-spacing: -0.04em; +} + +.console-metric-card small { + color: var(--muted); + font-size: 14px; +} + +.console-surface-card { + display: grid; + gap: 24px; + padding: 30px; +} + +.console-key-list { + display: grid; + gap: 0; +} + +.console-key-row + .console-key-row { + border-top: 1px solid var(--line); +} + +.console-key-copy { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + padding: 22px 0; +} + +.console-key-copy strong { + display: block; + margin-bottom: 8px; + font-size: 13px; + letter-spacing: 0.08em; +} + +.console-key-copy span { + display: block; + font-size: 1.02rem; + line-height: 1.6; + word-break: break-all; +} + +.console-copy-button { + min-width: 92px; + width: 92px; + max-width: 92px; + flex: 0 0 92px; +} + +.console-code-block { + border-radius: 28px; + overflow: hidden; + background: #161728; +} + +.console-code-block pre { + margin: 0; + padding: 26px 28px; + overflow: auto; + color: #edf2ff; +} + +.login-shell-focused { + grid-template-columns: minmax(0, 1.02fr) minmax(0, 0.98fr); + align-items: stretch; +} + +.login-panel-plain { + background: rgba(255, 255, 255, 0.98); +} + +.login-panel-plain h1 { + max-width: 12ch; + margin: 8px 0 14px; + font-size: clamp(2.4rem, 4vw, 3.7rem); + line-height: 1.02; + letter-spacing: -0.04em; +} + +.login-guide-panel { + display: grid; + gap: 18px; + padding: 30px; +} + +.login-guide-code { + margin-top: 2px; +} + .billing-card { background: - linear-gradient(160deg, rgba(255, 230, 210, 0.85) 0%, rgba(255, 255, 255, 0.9) 100%); + linear-gradient(160deg, rgba(239, 244, 255, 0.98) 0%, rgba(255, 255, 255, 0.98) 100%); +} + +.empty-state-card { + align-content: start; +} + +.catalog-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 20px; +} + +.skill-card { + display: grid; + gap: 12px; +} + +.meta-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; + margin: 0; +} + +.meta-grid dt { + color: var(--muted); + font-size: 12px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.meta-grid dd { + margin: 6px 0 0; + font-size: 14px; + line-height: 1.55; } .site-footer { display: flex; justify-content: space-between; gap: 24px; - padding: 18px 8px 48px; + padding: 8px 8px 48px; color: var(--muted); } @@ -580,22 +1573,74 @@ code { flex-wrap: wrap; } +a:focus-visible, +button:focus-visible, +input:focus-visible, +select:focus-visible, +textarea:focus-visible { + outline: 3px solid rgba(44, 107, 255, 0.18); + outline-offset: 3px; +} + +@keyframes rise-in { + from { + opacity: 0; + transform: translateY(16px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } + + *, + *::before, + *::after { + animation: none !important; + transition: none !important; + } +} + @media (max-width: 1100px) { .hero-panel, .card-grid-three, + .bootstrap-callout, .pricing-grid, .docs-layout, .dashboard-grid, + .catalog-grid, .faq-grid, .steps-grid, .stats-grid, - .login-shell { + .login-shell, + .login-shell-focused, + .console-metrics-grid { grid-template-columns: 1fr; } .hero-panel { min-height: auto; } + + .hero-panel-reference { + padding: 42px; + } + + .hero-stage-reference { + min-height: 360px; + } + + .stage-card-floating { + right: 24px; + bottom: 24px; + width: min(360px, calc(100% - 48px)); + } } @media (max-width: 840px) { @@ -605,19 +1650,70 @@ code { width: min(calc(100% - 20px), var(--container)); } - .site-header, + .site-header { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + grid-template-areas: + "brand actions" + "nav nav"; + gap: 14px; + padding: 16px 18px 18px; + border-radius: 34px; + } + + .brand-lockup { + grid-area: brand; + } + + .main-nav { + grid-area: nav; + flex-wrap: nowrap; + justify-self: stretch; + overflow-x: auto; + padding-bottom: 4px; + -ms-overflow-style: none; + scrollbar-width: none; + } + + .main-nav::-webkit-scrollbar { + display: none; + } + + .nav-link { + flex: 0 0 auto; + min-height: 42px; + padding: 0 16px; + font-size: 14px; + } + + .header-actions { + grid-area: actions; + justify-self: end; + align-items: center; + gap: 10px; + } + .dashboard-top, - .site-footer { + .site-footer, + .pricing-top, + .docs-row, + .table-row, + .session-summary-row, + .console-key-copy { flex-direction: column; align-items: stretch; } - .main-nav { - flex-wrap: wrap; + .auth-cluster { + gap: 8px; } - .header-actions { - justify-content: space-between; + .session-hint { + display: none; + } + + .session-name { + max-width: none; } .hero-panel, @@ -639,7 +1735,402 @@ code { font-size: clamp(2.2rem, 10vw, 3.2rem); } + .section-heading h2 { + font-size: clamp(1.75rem, 8vw, 2.35rem); + max-width: none; + } + + .hero-panel { + padding: 28px 22px; + } + + .hero-panel-reference { + padding: 28px 22px 24px; + min-height: auto; + } + + .hero-panel-reference .hero-description { + font-size: 1rem; + } + + .hero-stage-reference { + min-height: 300px; + } + + .hero-pattern { + position: relative; + min-height: 240px; + } + + .hero-pattern-row { + padding: 16px 18px 0; + gap: 12px; + font-size: 11px; + letter-spacing: 0.2em; + } + + .stage-card-floating { + position: relative; + right: auto; + bottom: auto; + width: auto; + margin: -56px 18px 18px; + } + + .hero-actions > * { + width: 100%; + } + + .hero-proof { + display: grid; + gap: 10px; + } + + .search-row > * { + width: 100%; + } + .card-grid { grid-template-columns: 1fr; } + + .meta-grid { + grid-template-columns: 1fr; + } + + .scene-card > .card-kicker, + .scene-card > h3, + .scene-card > p, + .scene-card > .scene-link { + margin-left: 22px; + margin-right: 22px; + } + + .scene-visual { + min-height: 180px; + } + + .session-pill { + min-width: 92px; + width: 92px; + max-width: 92px; + } + + .integration-shell { + gap: 24px; + padding-top: 10px; + } + + .integration-heading { + justify-items: start; + text-align: left; + padding: 12px 2px 0; + } + + .integration-heading h2, + .integration-heading p { + max-width: none; + } + + .integration-step-card { + padding: 28px 24px 24px; + border-radius: 28px; + } + + .integration-step-head { + gap: 14px; + } + + .integration-step-body, + .integration-step-body-compact { + padding-left: 0; + } + + .integration-step-actions { + align-items: stretch; + } + + .integration-step-actions > * { + width: 100%; + } + + .integration-step-actions .button-dark, + .integration-docs-link { + min-height: 58px; + width: 100%; + justify-content: center; + } + + .console-surface-card, + .console-metric-card, + .login-guide-panel { + padding: 24px; + border-radius: 24px; + } + + .console-copy-button { + min-width: 100%; + width: 100%; + max-width: none; + flex: 1 1 auto; + } + + .locale-menu-button { + min-width: 92px; + height: 42px; + padding: 0 14px; + } + + .locale-menu-list { + min-width: 148px; + } +} + +@media (max-width: 640px) { + .site-header, + .site-footer, + .main-content { + width: min(calc(100% - 16px), var(--container)); + } + + .main-content { + padding: 22px 0 44px; + } + + .page-stack { + gap: 18px; + } + + .brand-lockup strong { + font-size: 1.1rem; + } + + .brand-lockup span { + display: none; + } + + .button { + min-height: 46px; + } + + .button-small { + min-height: 38px; + padding: 0 12px; + } + + .button-auth-stable { + min-width: 96px; + width: 96px; + max-width: 96px; + } + + .locale-menu-button { + min-width: 84px; + height: 40px; + padding: 0 12px; + font-size: 13px; + } + + .locale-menu-list { + min-width: 136px; + padding: 6px; + border-radius: 18px; + } + + .locale-menu-item { + min-height: 42px; + padding: 0 12px; + font-size: 13px; + } + + .hero-panel, + .section-panel, + .pricing-card, + .docs-card, + .dashboard-card, + .login-panel, + .benefit-card, + .faq-card, + .feature-card, + .step-card, + .stat-card { + padding: 20px; + border-radius: 24px; + } + + .hero-copy h1, + .section-heading h1 { + max-width: none; + font-size: clamp(2rem, 11vw, 2.8rem); + } + + .hero-description, + .section-heading p, + .pricing-summary, + .billing-copy, + .legal-copy, + .feature-card p, + .faq-card p, + .step-card p, + .docs-row p, + .table-row p, + .login-panel p, + .benefit-card p, + .stage-card p { + font-size: 14px; + } + + .hero-stage { + gap: 12px; + } + + .stage-card { + padding: 20px; + border-radius: 22px; + } + + .hero-panel-reference { + padding: 20px; + } + + .hero-pattern { + min-height: 208px; + border-radius: 24px; + } + + .hero-pattern-row { + padding: 14px 14px 0; + } + + .stage-card-floating { + margin: -42px 14px 14px; + border-radius: 24px; + } + + .stage-card + .stage-card { + margin-top: 0; + } + + .stage-stat { + font-size: clamp(1.8rem, 10vw, 2.5rem); + } + + .stage-mini-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + } + + .stage-mini-grid span { + padding: 12px; + font-size: 13px; + } + + .scene-card > .card-kicker, + .scene-card > h3, + .scene-card > p, + .scene-card > .scene-link { + margin-left: 20px; + margin-right: 20px; + } + + .scene-visual { + min-height: 164px; + } + + .session-pill { + min-width: 88px; + width: 88px; + max-width: 88px; + } + + .integration-heading h2 { + font-size: clamp(2.1rem, 12vw, 2.9rem); + } + + .integration-heading p, + .integration-step-actions span { + font-size: 14px; + } + + .integration-step-card { + padding: 22px 20px 20px; + border-radius: 24px; + } + + .integration-step-head h3 { + font-size: 1.3rem; + } + + .integration-step-body { + margin-top: 20px; + gap: 16px; + } + + .integration-step-body pre { + padding: 18px; + border-radius: 18px; + } + + .integration-method-pills .pill { + min-height: 34px; + padding: 0 12px; + font-size: 12px; + } + + .console-metric-card strong { + font-size: 2.3rem; + } + + .console-code-block { + border-radius: 20px; + } + + .console-code-block pre { + padding: 20px; + } + + .section-heading.compact h2, + .dashboard-card h2, + .pricing-card h2 { + font-size: 1.35rem; + } + + .feature-card h3, + .faq-card h3, + .step-card h3 { + font-size: 1.15rem; + } + + .step-number { + width: 44px; + height: 44px; + border-radius: 14px; + } + + .stat-card strong, + .price-line strong { + font-size: 1.9rem; + } + + .docs-card pre, + .feature-card pre, + .dashboard-card pre, + .code-card pre { + padding: 14px; + border-radius: 16px; + font-size: 12px; + } + + .inline-note, + .code-card, + .session-summary, + .status-banner { + padding: 16px; + border-radius: 18px; + } + + .site-footer { + gap: 14px; + padding-bottom: 32px; + } } diff --git a/web/app/icon.svg b/web/app/icon.svg new file mode 100644 index 0000000..605120c --- /dev/null +++ b/web/app/icon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/web/components/copy-button.tsx b/web/components/copy-button.tsx new file mode 100644 index 0000000..a0c208a --- /dev/null +++ b/web/components/copy-button.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +export function CopyButton({ + value, + copyLabel, + copiedLabel, + className = "", +}: { + value: string; + copyLabel: string; + copiedLabel: string; + className?: string; +}) { + const [copied, setCopied] = useState(false); + const timeoutRef = useRef(null); + + useEffect(() => { + return () => { + if (timeoutRef.current) { + window.clearTimeout(timeoutRef.current); + } + }; + }, []); + + async function handleCopy() { + try { + await navigator.clipboard.writeText(value); + setCopied(true); + if (timeoutRef.current) { + window.clearTimeout(timeoutRef.current); + } + timeoutRef.current = window.setTimeout(() => { + setCopied(false); + }, 1400); + } catch { + setCopied(false); + } + } + + return ( + + ); +} diff --git a/web/components/header-controls.tsx b/web/components/header-controls.tsx new file mode 100644 index 0000000..21fc009 --- /dev/null +++ b/web/components/header-controls.tsx @@ -0,0 +1,150 @@ +"use client"; + +import Link from "next/link"; +import { usePathname, useRouter } from "next/navigation"; +import { useEffect, useRef, useState, useTransition } from "react"; +import type { PopiartUser } from "@/lib/popiart-api"; +import type { Locale } from "@/lib/site-content"; + +function replaceLocale(pathname: string, locale: Locale) { + const parts = pathname.split("/"); + if (parts[1] === "zh" || parts[1] === "en") { + parts[1] = locale; + return parts.join("/") || `/${locale}`; + } + return `/${locale}${pathname.startsWith("/") ? pathname : `/${pathname}`}`; +} + +function userLabel(user: PopiartUser) { + return user.name || user.email || user.id; +} + +function initials(user: PopiartUser) { + const source = user.name || user.email || user.id || "PA"; + return source.slice(0, 2).toUpperCase(); +} + +export function HeaderControls({ + locale, + user, + labels, + loginHref, +}: { + locale: Locale; + user: PopiartUser | null; + labels: { + zh: string; + en: string; + logout: string; + loggingOut: string; + loginHint: string; + login: string; + }; + loginHref: string; +}) { + const pathname = usePathname(); + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + const [menuOpen, setMenuOpen] = useState(false); + const menuRef = useRef(null); + + useEffect(() => { + function handlePointerDown(event: MouseEvent) { + if (!menuRef.current?.contains(event.target as Node)) { + setMenuOpen(false); + } + } + + function handleEscape(event: KeyboardEvent) { + if (event.key === "Escape") { + setMenuOpen(false); + } + } + + window.addEventListener("mousedown", handlePointerDown); + window.addEventListener("keydown", handleEscape); + + return () => { + window.removeEventListener("mousedown", handlePointerDown); + window.removeEventListener("keydown", handleEscape); + }; + }, []); + + function switchLocale(target: Locale) { + setMenuOpen(false); + router.push(replaceLocale(pathname, target)); + } + + function logout() { + startTransition(async () => { + await fetch("/api/auth/logout", { + method: "POST", + }); + router.refresh(); + }); + } + + return ( +
+
+ + {menuOpen ? ( +
+ + +
+ ) : null} +
+ + {user ? ( +
+ + {initials(user)} + {userLabel(user)} + + +
+ ) : ( +
+ + {labels.login} + +
+ )} +
+ ); +} diff --git a/web/components/login-form.tsx b/web/components/login-form.tsx new file mode 100644 index 0000000..48fd431 --- /dev/null +++ b/web/components/login-form.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState, useTransition } from "react"; +import type { Locale } from "@/lib/site-content"; + +type ApiResponse = { + ok: boolean; + error?: { + message?: string; + }; +}; + +export function LoginForm({ + locale, + labels, +}: { + locale: Locale; + labels: { + fieldLabel: string; + fieldHint: string; + submit: string; + submitting: string; + helperTitle: string; + helperBody: string; + commandLabel: string; + invalidKey: string; + }; +}) { + const router = useRouter(); + const [key, setKey] = useState(""); + const [error, setError] = useState(null); + const [isPending, startTransition] = useTransition(); + + function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + const trimmed = key.trim(); + + if (!trimmed) { + setError(labels.invalidKey); + return; + } + + startTransition(async () => { + setError(null); + + let payload: ApiResponse | null = null; + + try { + const response = await fetch("/api/auth/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ key: trimmed }), + }); + payload = (await response.json()) as ApiResponse; + + if (!response.ok || !payload.ok) { + setError(payload.error?.message ?? labels.invalidKey); + return; + } + + router.push(`/${locale}/console`); + router.refresh(); + } catch (requestError) { + setError(requestError instanceof Error ? requestError.message : labels.invalidKey); + } + }); + } + + return ( +
+
+ + setKey(event.target.value)} + placeholder="pk_live_..." + spellCheck={false} + type="password" + value={key} + /> +

{labels.fieldHint}

+ {error ?
{error}
: null} + +
+
+ ); +} diff --git a/web/components/main-nav.tsx b/web/components/main-nav.tsx new file mode 100644 index 0000000..63dafeb --- /dev/null +++ b/web/components/main-nav.tsx @@ -0,0 +1,57 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import type { Locale } from "@/lib/site-content"; + +function localize(locale: Locale, path: string) { + if (!path || path === "/") { + return `/${locale}`; + } + return `/${locale}${path}`; +} + +function isActive(pathname: string, href: string) { + if (href === "/") { + return pathname === href; + } + return pathname === href || pathname.startsWith(`${href}/`); +} + +export function MainNav({ + locale, + labels, +}: { + locale: Locale; + labels: { + home: string; + docs: string; + skills: string; + console: string; + pricing: string; + }; +}) { + const pathname = usePathname(); + const items = [ + { href: localize(locale, "/"), label: labels.home }, + { href: localize(locale, "/docs"), label: labels.docs }, + { href: localize(locale, "/skills"), label: labels.skills }, + { href: localize(locale, "/console"), label: labels.console }, + { href: localize(locale, "/pricing"), label: labels.pricing }, + ]; + + return ( + + ); +} diff --git a/web/components/site-chrome.tsx b/web/components/site-chrome.tsx index a41bcd6..efa7fbd 100644 --- a/web/components/site-chrome.tsx +++ b/web/components/site-chrome.tsx @@ -1,4 +1,8 @@ import Link from "next/link"; +import { HeaderControls } from "@/components/header-controls"; +import { MainNav } from "@/components/main-nav"; +import { getViewerSession } from "@/lib/popiart-api"; +import { getLiveCopy } from "@/lib/live-copy"; import type { AppDictionary, Locale } from "@/lib/site-content"; function localize(locale: Locale, path: string) { @@ -8,7 +12,7 @@ function localize(locale: Locale, path: string) { return `/${locale}${path}`; } -export function SiteChrome({ +export async function SiteChrome({ children, dictionary, locale, @@ -17,6 +21,9 @@ export function SiteChrome({ dictionary: AppDictionary; locale: Locale; }) { + const liveCopy = getLiveCopy(locale); + const session = await getViewerSession(); + return (
@@ -30,32 +37,30 @@ export function SiteChrome({
- + -
-
- - 中文 - - - EN - -
- - {dictionary.nav.login} - -
+
{children}
@@ -67,6 +72,7 @@ export function SiteChrome({
{dictionary.nav.docs} + {liveCopy.nav.skills} {dictionary.nav.pricing} {dictionary.nav.console}
diff --git a/web/lib/live-copy.ts b/web/lib/live-copy.ts new file mode 100644 index 0000000..26de066 --- /dev/null +++ b/web/lib/live-copy.ts @@ -0,0 +1,188 @@ +import type { Locale } from "@/lib/site-content"; + +type LiveCopy = { + nav: { + skills: string; + }; + header: { + zh: string; + en: string; + logout: string; + loggingOut: string; + loginHint: string; + }; + login: { + fieldLabel: string; + fieldHint: string; + submit: string; + submitting: string; + helperTitle: string; + helperBody: string; + commandLabel: string; + alreadySignedIn: string; + continueCta: string; + invalidKey: string; + }; + console: { + unauthenticatedTitle: string; + unauthenticatedBody: string; + loginCta: string; + docsCta: string; + signedInAs: string; + liveTitle: string; + liveSubtitle: string; + quickstartTitle: string; + quickstartBody: string; + catalogCount: string; + noSkills: string; + noUsage: string; + loadErrorPrefix: string; + }; + skills: { + tag: string; + title: string; + subtitle: string; + searchLabel: string; + searchPlaceholder: string; + searchAction: string; + resultsPrefix: string; + unauthenticatedTitle: string; + unauthenticatedBody: string; + loginCta: string; + docsCta: string; + noResults: string; + routeKey: string; + modelType: string; + latency: string; + tags: string; + loadErrorPrefix: string; + }; +}; + +const copy: Record = { + zh: { + nav: { + skills: "技能页", + }, + header: { + zh: "中文", + en: "EN", + logout: "登出", + loggingOut: "退出中...", + loginHint: "未登录", + }, + login: { + fieldLabel: "PopiNewAPI Key", + fieldHint: "使用和 `popiart auth login --key ...` 相同的上游 key 登录,页面会换取产品层 session。", + submit: "登录控制台", + submitting: "登录中...", + helperTitle: "与 CLI 使用同一条认证链路", + helperBody: "页面登录后,控制台会通过 popiartServer 会话去读取 skills、budget 和 usage,而不是直接在浏览器里保存 provider key。", + commandLabel: "CLI 等价命令", + alreadySignedIn: "当前已登录,可直接进入控制台或继续查看技能目录。", + continueCta: "进入控制台", + invalidKey: "请输入可用的 PopiNewAPI Key。", + }, + console: { + unauthenticatedTitle: "登录后查看真实控制台数据", + unauthenticatedBody: "这页现在直接读取 popiartServer 的 auth、skills、budget 和 usage 接口。未登录时不会伪造密钥和余额。", + loginCta: "去登录", + docsCta: "看接入文档", + signedInAs: "当前账号", + liveTitle: "已接入 popiartServer 实时数据", + liveSubtitle: "页面逻辑沿用 `popiart auth login`、`skills list` 和 `budget` 的产品层协议。", + quickstartTitle: "快速安装指令", + quickstartBody: "CLI 和 Web 共用同一个产品层 endpoint,下面这段命令可直接对应到当前控制台。", + catalogCount: "技能目录", + noSkills: "当前账号下没有可见技能。", + noUsage: "当前周期还没有技能用量。", + loadErrorPrefix: "控制台数据加载失败", + }, + skills: { + tag: "SKILL CATALOG", + title: "官方技能目录", + subtitle: "当前列表直接读取 `/v1/skills`,与 `popiart skills list` 使用同一份产品层数据。", + searchLabel: "搜索技能", + searchPlaceholder: "搜索技能名、描述或标签", + searchAction: "查询", + resultsPrefix: "共找到", + unauthenticatedTitle: "登录后查看真实技能目录", + unauthenticatedBody: "技能页会直接走产品层会话鉴权,因此未登录时不展示假数据。", + loginCta: "登录查看", + docsCta: "阅读文档", + noResults: "没有匹配的技能。", + routeKey: "路由键", + modelType: "模型类型", + latency: "预计耗时", + tags: "标签", + loadErrorPrefix: "技能目录加载失败", + }, + }, + en: { + nav: { + skills: "Skills", + }, + header: { + zh: "中文", + en: "EN", + logout: "Logout", + loggingOut: "Logging out...", + loginHint: "Signed out", + }, + login: { + fieldLabel: "PopiNewAPI Key", + fieldHint: + "Use the same upstream key as `popiart auth login --key ...`. The web app exchanges it for a product-layer session.", + submit: "Sign in to console", + submitting: "Signing in...", + helperTitle: "The same auth path as the CLI", + helperBody: + "After sign-in, the console reads skills, budget, and usage through popiartServer instead of storing provider credentials in the browser.", + commandLabel: "Equivalent CLI command", + alreadySignedIn: "You are already signed in. Jump back to the console or continue browsing the catalog.", + continueCta: "Open console", + invalidKey: "Enter a valid PopiNewAPI key.", + }, + console: { + unauthenticatedTitle: "Sign in to view live console data", + unauthenticatedBody: + "This page now reads popiartServer auth, skills, budget, and usage endpoints directly. It no longer fakes keys or credits for signed-out users.", + loginCta: "Sign in", + docsCta: "Read docs", + signedInAs: "Signed in as", + liveTitle: "Connected to live popiartServer data", + liveSubtitle: + "The page follows the same product-layer protocol used by `popiart auth login`, `skills list`, and `budget`.", + quickstartTitle: "Quick install commands", + quickstartBody: "The CLI and the web console share the same product endpoint and auth flow.", + catalogCount: "Catalog", + noSkills: "No skills are visible for this account yet.", + noUsage: "No skill usage has been recorded for the current period.", + loadErrorPrefix: "Failed to load console data", + }, + skills: { + tag: "SKILL CATALOG", + title: "Official skill catalog", + subtitle: "This list is fetched from `/v1/skills`, the same surface used by `popiart skills list`.", + searchLabel: "Search skills", + searchPlaceholder: "Search by skill name, description, or tag", + searchAction: "Search", + resultsPrefix: "Results", + unauthenticatedTitle: "Sign in to view the live catalog", + unauthenticatedBody: + "The catalog page uses product-layer session auth, so it does not render fake skill data for signed-out users.", + loginCta: "Sign in", + docsCta: "Read docs", + noResults: "No skills matched this query.", + routeKey: "Route key", + modelType: "Model type", + latency: "Estimated duration", + tags: "Tags", + loadErrorPrefix: "Failed to load the skill catalog", + }, + }, +}; + +export function getLiveCopy(locale: Locale) { + return copy[locale] ?? copy.zh; +} diff --git a/web/lib/popiart-api.ts b/web/lib/popiart-api.ts new file mode 100644 index 0000000..1a605fd --- /dev/null +++ b/web/lib/popiart-api.ts @@ -0,0 +1,223 @@ +import { cookies } from "next/headers"; + +export const SESSION_COOKIE_NAME = "popiart_session"; + +type ApiEnvelope = { + ok: boolean; + data?: T; + error?: { + code?: string; + message?: string; + details?: unknown; + }; +}; + +export type PopiartUser = { + id: string; + email: string; + name: string; + scopes?: string[]; +}; + +export type LoginResponse = { + key: string; + token: string; + user: PopiartUser; +}; + +export type AuthSessionView = { + user: PopiartUser; + session_key?: string; + upstream_key_masked?: string; +}; + +export type Skill = { + id: string; + name: string; + description: string; + tags: string[]; + version: string; + model_type: string; + route_key?: string; + estimated_duration_s: number; +}; + +export type SkillListResponse = { + items: Skill[]; + total: number; + limit: number; + offset: number; +}; + +export type BudgetSummary = { + period: { + start: string; + end: string; + }; + used: { + tokens: number; + cost_usd: number; + }; + limit: { + monthly_tokens: number; + monthly_cost_usd: number; + }; + remaining: { + tokens: number; + cost_usd: number; + }; +}; + +export type BudgetUsageRow = { + dimension: string; + tokens_used: number; + cost_usd: number; + job_count: number; +}; + +export type BudgetUsage = { + rows: BudgetUsageRow[]; + total: { + tokens_used: number; + cost_usd: number; + job_count: number; + }; +}; + +export class PopiartApiError extends Error { + status: number; + code?: string; + details?: unknown; + + constructor(status: number, message: string, code?: string, details?: unknown) { + super(message); + this.name = "PopiartApiError"; + this.status = status; + this.code = code; + this.details = details; + } +} + +function stripTrailingSlash(value: string) { + return value.replace(/\/+$/, ""); +} + +export function getPopiartEndpoint() { + const configured = + process.env.POPIART_ENDPOINT ?? + process.env.POPIART_SERVER_URL ?? + "http://127.0.0.1:8080/v1"; + return stripTrailingSlash(configured); +} + +export function buildPopiartUrl(pathname: string) { + const path = pathname.startsWith("/") ? pathname : `/${pathname}`; + return `${getPopiartEndpoint()}${path}`; +} + +async function readEnvelope(response: Response) { + try { + return (await response.json()) as ApiEnvelope; + } catch { + return null; + } +} + +export async function popiartFetchEnvelope(pathname: string, init: RequestInit = {}) { + const response = await fetch(buildPopiartUrl(pathname), { + ...init, + cache: "no-store", + }); + const payload = await readEnvelope(response); + return { response, payload }; +} + +export async function popiartRequest( + pathname: string, + options: { + method?: string; + body?: BodyInit | object; + headers?: HeadersInit; + token?: string; + } = {}, +) { + const headers = new Headers(options.headers); + let body: BodyInit | undefined; + + if (options.token) { + headers.set("Authorization", `Bearer ${options.token}`); + } + + if (options.body instanceof FormData || typeof options.body === "string") { + body = options.body; + } else if (options.body !== undefined) { + headers.set("Content-Type", "application/json"); + body = JSON.stringify(options.body); + } + + const { response, payload } = await popiartFetchEnvelope(pathname, { + method: options.method ?? (body ? "POST" : "GET"), + headers, + body, + }); + + if (!response.ok || !payload?.ok) { + throw new PopiartApiError( + response.status, + payload?.error?.message ?? `Request failed with status ${response.status}`, + payload?.error?.code, + payload?.error?.details, + ); + } + + return payload.data as T; +} + +export async function getSessionToken() { + const cookieStore = await cookies(); + return cookieStore.get(SESSION_COOKIE_NAME)?.value ?? null; +} + +export async function getViewerSession() { + const token = await getSessionToken(); + if (!token) { + return null; + } + try { + return await popiartRequest("/auth/me", { token }); + } catch (error) { + if (error instanceof PopiartApiError && error.status === 401) { + return null; + } + throw error; + } +} + +export async function getSkillsCatalog(search?: string) { + const token = await getSessionToken(); + if (!token) { + return null; + } + const query = new URLSearchParams(); + query.set("limit", "100"); + if (search) { + query.set("search", search); + } + return popiartRequest(`/skills?${query.toString()}`, { token }); +} + +export async function getBudgetSummary() { + const token = await getSessionToken(); + if (!token) { + return null; + } + return popiartRequest("/budget", { token }); +} + +export async function getBudgetUsage() { + const token = await getSessionToken(); + if (!token) { + return null; + } + return popiartRequest("/budget/usage", { token }); +} diff --git a/web/lib/site-content.ts b/web/lib/site-content.ts index 9cf3d95..235995e 100644 --- a/web/lib/site-content.ts +++ b/web/lib/site-content.ts @@ -18,6 +18,14 @@ type Step = { description: string; }; +type InstallMethod = { + badge: string; + name: string; + description: string; + command: string; + note: string; +}; + type Plan = { badge: string; name: string; @@ -116,6 +124,13 @@ export type AppDictionary = { flowTitle: string; flowSubtitle: string; flowSteps: Step[]; + installTag: string; + installTitle: string; + installSubtitle: string; + installMethods: InstallMethod[]; + bootstrapTitle: string; + bootstrapDescription: string; + bootstrapCommand: string; trustTag: string; trustTitle: string; trustStats: Stat[]; @@ -194,23 +209,23 @@ const zh: AppDictionary = { }, home: { tag: "OFFICIAL SKILLS", - title: "把官方 PopiArt Skills 接进你的创作与自动化流程", + title: "安装 popiartcli,把官方 PopiArt Skills 接进你的 Coding Agent", subtitle: - "覆盖图片生成、编辑、视频与批量工作流,让团队用统一账号、统一计费、统一控制台管理所有 AI 能力。", - primaryCta: "开始使用", + "通过统一 CLI 发现、查看、调用官方 skills;当任务需要图像、视频与多模态模型时,再由产品层统一处理授权、路由和计费。", + primaryCta: "安装 CLI", secondaryCta: "查看文档", - freeTrial: "无需信用卡,注册即送 50 Credits", - socialProof: "已为团队工作流准备好账号、订阅与用量追踪", - stageLabel: "本月概览", - stageValue: "12,480 Credits", - stageDescription: "管理官方技能、订阅计划、API key 与项目级用量。", + freeTrial: "支持 Homebrew、curl | sh、Windows PowerShell 与源码构建", + socialProof: "安装后可直接 bootstrap 到 Codex、OpenCode 与 OpenClaw", + stageLabel: "CLI 命令面", + stageValue: "auth / skills / run", + stageDescription: "继续用 jobs、artifacts、budget、project、models、bootstrap 完成完整工作流。", heroPhrases: [ - "图片生成", - "图像编辑", - "图生视频", - "动作迁移", - "批量处理", - "官方计费", + "auth login", + "skills list", + "run --wait", + "jobs wait", + "artifacts pull", + "budget status", ], scenesTag: "SCENARIOS", scenesTitle: "覆盖高频 AI 创作场景", @@ -247,30 +262,62 @@ const zh: AppDictionary = { description: "把技能能力、额度、项目和账单统一到一个控制台管理。", }, ], - flowTag: "INTEGRATION", - flowTitle: "三步接入官方控制台", - flowSubtitle: "先搭出官网结构,再逐步接回 popiartServer 的登录、用量和计费接口。", + flowTag: "QUICK START", + flowTitle: "三步开始使用 popiartcli", + flowSubtitle: "直接沿用仓库 README 的真实安装与首次使用路径,而不是另造一套平台流程。", flowSteps: [ { - title: "注册并登录 PopiArt 账号", - description: "先拿到产品层账号与 API key,而不是把供应商密钥直接暴露给客户端。", + title: "安装 popiartcli", + description: "优先使用 Homebrew、官方 install.sh 或 PowerShell 脚本,把 CLI 先装到本地环境。", }, { - title: "在文档页完成 CLI 或 API 接入", - description: "根据官方 quick start 配置 endpoint、token 和默认输出目录。", + title: "执行 bootstrap 接入 agent 生态", + description: "生成 completion、默认 discovery profile,并可直接暴露给 Codex / OpenCode 使用。", }, { - title: "在控制台查看技能、用量和账单", - description: "按项目查看技能调用量、订阅状态和充值记录。", + title: "登录并调用官方 skill", + description: "执行 auth login、skills list、run、jobs wait、artifacts pull,进入完整工作流。", }, ], - trustTag: "CONTROL", - trustTitle: "围绕产品层能力组织,而不是裸模型调用", + installTag: "INSTALL", + installTitle: "首页直接开始安装 popiartcli", + installSubtitle: "推荐先安装 CLI,再按需执行 bootstrap,让 PopiArt 在你的 agent 环境里直接可发现。", + installMethods: [ + { + badge: "RECOMMENDED", + name: "Homebrew", + description: "适合 macOS / Linux,也是后续升级最稳定的方式。", + command: "brew tap wtgoku-create/popi\nbrew install wtgoku-create/popi/popiart", + note: "升级:brew upgrade wtgoku-create/popi/popiart", + }, + { + badge: "CLI ONLY", + name: "curl | sh", + description: "从 GitHub Releases 安装 Go CLI 二进制,默认只安装 CLI 本体。", + command: + "curl -fsSL https://raw.githubusercontent.com/wtgoku-create/popiartcli/main/install.sh | sh -s -- --cli-only", + note: "升级:popiart update", + }, + { + badge: "WINDOWS", + name: "PowerShell", + description: "Windows 环境使用官方 install.ps1,支持指定版本安装。", + command: + "irm https://raw.githubusercontent.com/wtgoku-create/popiartcli/main/install.ps1 | iex", + note: "也可以通过 VERSION 环境变量安装指定版本。", + }, + ], + bootstrapTitle: "安装后建议立即执行 bootstrap", + bootstrapDescription: "生成 shell completion、默认 skill discovery profile,并把 PopiArt 直接暴露给 agent。", + bootstrapCommand: + "popiart bootstrap --agent codex --completion zsh\npopiart bootstrap --agent codex --discoverable\npopiart skills list", + trustTag: "CLI DESIGN", + trustTitle: "围绕 agent 工作流设计,而不是零散脚本拼装", trustStats: [ - { label: "官方技能目录", value: "24+" }, - { label: "项目级路由覆盖", value: "Per Project" }, - { label: "统一登录与会话", value: "1 Account" }, - { label: "订阅与充值", value: "Built In" }, + { label: "默认 JSON 输出", value: "ok / data" }, + { label: "长任务轮询", value: "jobs wait" }, + { label: "工件恢复下载", value: "artifacts pull" }, + { label: "生态引导", value: "bootstrap" }, ], }, pricing: { @@ -329,23 +376,24 @@ const zh: AppDictionary = { tag: "DOCUMENTATION", title: "PopiArt 开发者文档", intro: - "先把官网、登录和控制台骨架搭起来,后续直接把文档页接到 popiartcli 与 popiartServer 的真实协议。", + "首页与文档都直接围绕 popiartcli README 的真实安装链路、bootstrap 和命令面来组织。", quickStartTitle: "快速开始", quickSteps: [ { - title: "1. 设置服务地址", - description: "将 Web 控制台和 CLI 指向统一的产品层后端。", - code: "export POPIART_ENDPOINT=http://127.0.0.1:8080/v1", + title: "1. 安装 popiartcli", + description: "优先使用 Homebrew,或者用官方 install.sh / install.ps1 安装 CLI。", + code: "brew tap wtgoku-create/popi\nbrew install wtgoku-create/popi/popiart", }, { - title: "2. 登录并写入 token", - description: "先拿到产品层 session,后端再桥接到上游模型网关。", - code: "popiart auth login --key ", + title: "2. 执行 bootstrap", + description: "把 PopiArt 直接接进 Codex / OpenCode,并生成 completion 与默认发现配置。", + code: "popiart bootstrap --agent codex --discoverable", }, { - title: "3. 调用官方 skill", - description: "直接通过技能名或 route key 发起任务。", - code: "popiart skills list\npopiart run --skill popiskill-image-text2image-basic-v1", + title: "3. 登录并调用官方 skill", + description: "完成 auth login 后,先列技能,再执行 run / jobs wait / artifacts pull。", + code: + "popiart auth login --key \npopiart skills list\npopiart run popiskill-image-text2image-basic-v1 --input @params.json --wait", }, ], envTitle: "环境变量", @@ -486,23 +534,23 @@ const en: AppDictionary = { }, home: { tag: "OFFICIAL SKILLS", - title: "Bring official PopiArt skills into your creative and automation workflows", + title: "Install popiartcli and bring official PopiArt skills into your coding agent", subtitle: - "One account, one console, and one billing layer for image generation, editing, video, and team-ready AI operations.", - primaryCta: "Get started", + "Use one CLI to discover, inspect, and run official skills, while product-layer auth, routing, and billing stay behind the PopiArt backend.", + primaryCta: "Install CLI", secondaryCta: "Read docs", - freeTrial: "No credit card required. 50 free credits on signup.", - socialProof: "Built for account management, subscriptions, and usage visibility.", - stageLabel: "This month", - stageValue: "12,480 Credits", - stageDescription: "Manage official skills, subscriptions, API keys, and per-project usage from one place.", + freeTrial: "Supports Homebrew, curl | sh, Windows PowerShell, and source builds", + socialProof: "Bootstrap directly into Codex, OpenCode, and OpenClaw after install", + stageLabel: "CLI surface", + stageValue: "auth / skills / run", + stageDescription: "Then move into jobs, artifacts, budget, project, models, and bootstrap for the full workflow.", heroPhrases: [ - "Text to image", - "Image edit", - "Image to video", - "Motion transfer", - "Batch ops", - "Official billing", + "auth login", + "skills list", + "run --wait", + "jobs wait", + "artifacts pull", + "budget status", ], scenesTag: "SCENARIOS", scenesTitle: "Designed around high-frequency AI production flows", @@ -540,31 +588,63 @@ const en: AppDictionary = { description: "Bring skills, quotas, projects, and billing together in one product surface.", }, ], - flowTag: "INTEGRATION", - flowTitle: "Three steps to launch the official console", + flowTag: "QUICK START", + flowTitle: "Three steps to start with popiartcli", flowSubtitle: - "Start with the web experience, then connect auth, usage, and billing to popiartServer.", + "This mirrors the real install and first-run path from the popiartcli README instead of inventing a separate web-only flow.", flowSteps: [ { - title: "Create and sign into a PopiArt account", - description: "Use a product-level account instead of pushing provider credentials into the client.", + title: "Install popiartcli", + description: "Use Homebrew, the official install.sh, or PowerShell to get the CLI onto your local machine.", }, { - title: "Complete CLI or API setup from the docs", - description: "Point your tooling to the shared endpoint and configure the official token flow.", + title: "Run bootstrap for your agent environment", + description: "Generate completion, default discovery profiles, and make PopiArt directly discoverable to Codex or OpenCode.", }, { - title: "Track skills, usage, and billing in the console", - description: "Review usage by project, skill, and billing period from a single dashboard.", + title: "Sign in and run official skills", + description: "Use auth login, skills list, run, jobs wait, and artifacts pull to complete the first real workflow.", }, ], - trustTag: "CONTROL", - trustTitle: "Organized around product semantics instead of raw model calls", + installTag: "INSTALL", + installTitle: "Install popiartcli directly from the homepage", + installSubtitle: "Install the CLI first, then optionally run bootstrap so PopiArt becomes discoverable inside your agent environment.", + installMethods: [ + { + badge: "RECOMMENDED", + name: "Homebrew", + description: "Best for macOS and Linux, and the cleanest upgrade path later.", + command: "brew tap wtgoku-create/popi\nbrew install wtgoku-create/popi/popiart", + note: "Upgrade with: brew upgrade wtgoku-create/popi/popiart", + }, + { + badge: "CLI ONLY", + name: "curl | sh", + description: "Download the released Go CLI binary directly from GitHub Releases.", + command: + "curl -fsSL https://raw.githubusercontent.com/wtgoku-create/popiartcli/main/install.sh | sh -s -- --cli-only", + note: "Upgrade with: popiart update", + }, + { + badge: "WINDOWS", + name: "PowerShell", + description: "Use the official install.ps1 flow on Windows, with optional version pinning.", + command: + "irm https://raw.githubusercontent.com/wtgoku-create/popiartcli/main/install.ps1 | iex", + note: "You can also pin a VERSION before running the installer.", + }, + ], + bootstrapTitle: "Run bootstrap right after installation", + bootstrapDescription: "Generate shell completion, a default discovery profile, and make PopiArt directly available to your coding agent.", + bootstrapCommand: + "popiart bootstrap --agent codex --completion zsh\npopiart bootstrap --agent codex --discoverable\npopiart skills list", + trustTag: "CLI DESIGN", + trustTitle: "Designed around agent workflows instead of scattered scripts", trustStats: [ - { label: "Official skills", value: "24+" }, - { label: "Project routing", value: "Per project" }, - { label: "Unified auth", value: "1 account" }, - { label: "Billing flows", value: "Built in" }, + { label: "JSON-first output", value: "ok / data" }, + { label: "Long-task polling", value: "jobs wait" }, + { label: "Artifact recovery", value: "artifacts pull" }, + { label: "Agent bootstrap", value: "bootstrap" }, ], }, pricing: { @@ -623,23 +703,24 @@ const en: AppDictionary = { tag: "DOCUMENTATION", title: "PopiArt developer docs", intro: - "This first pass recreates the information architecture of the reference site while aligning it with popiartcli and popiartServer.", + "The homepage and docs now follow the real install chain, bootstrap flow, and command surface from popiartcli.", quickStartTitle: "Quick start", quickSteps: [ { - title: "1. Configure the endpoint", - description: "Point both the console and CLI at the same product-layer backend.", - code: "export POPIART_ENDPOINT=http://127.0.0.1:8080/v1", + title: "1. Install popiartcli", + description: "Use Homebrew first when possible, or fall back to install.sh / install.ps1.", + code: "brew tap wtgoku-create/popi\nbrew install wtgoku-create/popi/popiart", }, { - title: "2. Sign in with a product token", - description: "Keep provider credentials behind the backend and work through the product session layer.", - code: "popiart auth login --key ", + title: "2. Bootstrap your agent environment", + description: "Generate completion, default discovery profiles, and make PopiArt directly discoverable.", + code: "popiart bootstrap --agent codex --discoverable", }, { - title: "3. Run official skills", - description: "Invoke skills by skill id or move to model-level calls when you need direct debugging.", - code: "popiart skills list\npopiart run --skill popiskill-image-text2image-basic-v1", + title: "3. Sign in and run official skills", + description: "Authenticate, inspect the catalog, and run the first skill through the real job workflow.", + code: + "popiart auth login --key \npopiart skills list\npopiart run popiskill-image-text2image-basic-v1 --input @params.json --wait", }, ], envTitle: "Environment variables", diff --git a/web/package.json b/web/package.json index cc21180..982fe33 100644 --- a/web/package.json +++ b/web/package.json @@ -4,8 +4,10 @@ "private": true, "scripts": { "dev": "next dev", + "dev:subdomain": "next dev --hostname 0.0.0.0 --port 3100", "build": "next build", - "start": "next start" + "start": "next start", + "start:subdomain": "next start --hostname 0.0.0.0 --port 3100" }, "dependencies": { "next": "15.5.9",