Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# @talesofai/topic-sdk

## Unreleased

### Minor Changes

- 新增 `ossImage(url, { width, dpr?, quality?, webp? })` / `ossImageSrcSet(url, width)`:按渲染宽度 + 设备像素比拼接 OSS `x-oss-process`(resize/format/quality),与 talesofai 其余前端(weapp/event/bff 的 `getImageLink`)同一套图片处理约定。非 http(s) URL(如 `data:` 内联图)或已带 `x-oss-process` 的 URL 原样返回,不强行拼参数破坏。scaffold `App.tsx` 示例与 skill 文档已同步改用它包 `coverUrl`/`avatarUrl`,不再原图直出。
- `skill/`、`skill-internal-publish/` 两处 SKILL.md 顶部新增强制自我更新步骤:每次执行前先确认本地不是过期副本(git 仓库则 `git pull`,纯拷贝则重新从源仓库拉取覆盖,且明确排除"正在给 topic-sdk 本身开发"的场景),不允许跳过。(`topic-embed-review` 仓库单独做了同款改动,见该仓库自己的 commit,不在本仓库范围内。)

## 0.1.0-dev.0

### Minor Changes
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ const page = await sdk.topic.listStories(hashtag, { pageIndex: 0, pageSize: 20,
- **自指路由** `/topic` `/tag` `/activity`:参数可省,SDK 从当前页 `?hashtag=`/`?activity_uuid=` 自动填(可覆盖)。
- **per-item 路由** `/oc` `/user` `/collection/interaction`:必须传 `uuid`(来自被点卡片),漏传**构建期类型 + 运行期都会报错打回**。
- **可空字段**:`detail.startTime/endTime/title`、`StoryCard.aspect`、`*.author.uuid`、`Leaderboard.startTime/endTime` 等均可能为 `null`,渲染前判空(详见 cheatsheet)。
- **图片按尺寸取**:`coverUrl`/`avatarUrl` 都是 OSS 直出图,渲染前用 `ossImage(url, { width })` 包一层(按卡片渲染宽度 + 设备像素比拼 OSS resize/format 参数),别把原图尺寸直出——详见 cheatsheet「图片」一节。

完整契约、AllowedRoute 白名单与参数表、错误模型、三态降级见配套 skill 的 `references/api-cheatsheet.md`。

Expand Down
51 changes: 49 additions & 2 deletions dist/index.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ __export(src_exports, {
PageCursor: () => PageCursor,
TopicApiError: () => TopicApiError,
UnsupportedError: () => UnsupportedError,
createTopicSDK: () => createTopicSDK
createTopicSDK: () => createTopicSDK,
ossImage: () => ossImage,
ossImageSrcSet: () => ossImageSrcSet
});
module.exports = __toCommonJS(src_exports);

Expand Down Expand Up @@ -743,6 +745,49 @@ var SDKUiImpl = class {
}
};

// src/image.ts
var DEFAULT_QUALITY = 80;
var MAX_DPR = 3;
function resolveDpr(explicit) {
if (typeof explicit === "number" && explicit > 0)
return Math.min(explicit, MAX_DPR);
const detected = typeof window !== "undefined" && typeof window.devicePixelRatio === "number" ? window.devicePixelRatio : 1;
return Math.min(Math.max(detected, 1), MAX_DPR);
}
function buildOssProcess(pixelWidth, quality, webp) {
const styles = ["image", "auto-orient,1"];
if (pixelWidth && pixelWidth > 0)
styles.push(`resize,m_lfit,w_${Math.round(pixelWidth)}`);
if (webp)
styles.push("format,webp");
styles.push(`quality,q_${quality}`);
return styles.join("/");
}
function ossImage(source, options) {
if (!source)
return null;
let url;
try {
url = new URL(source);
} catch {
return source;
}
if (url.protocol !== "http:" && url.protocol !== "https:")
return source;
if (url.searchParams.has("x-oss-process"))
return source;
const { width, quality = DEFAULT_QUALITY, webp = true, dpr } = options ?? {};
const pixelWidth = width && width > 0 ? width * resolveDpr(dpr) : void 0;
url.searchParams.set("x-oss-process", buildOssProcess(pixelWidth, quality, webp));
return url.toString();
}
function ossImageSrcSet(source, width, options) {
if (!source)
return null;
const scales = [1, 2, 3];
return scales.map((scale) => `${ossImage(source, { ...options, width, dpr: scale })} ${scale}x`).join(", ");
}

// src/index.ts
var SDK_VERSION = "0.1.0";
function installLinkInterceptor(nav) {
Expand Down Expand Up @@ -880,6 +925,8 @@ async function createTopicSDK(options = {}) {
PageCursor,
TopicApiError,
UnsupportedError,
createTopicSDK
createTopicSDK,
ossImage,
ossImageSrcSet
});
//# sourceMappingURL=index.cjs.map
2 changes: 1 addition & 1 deletion dist/index.cjs.map

Large diffs are not rendered by default.

32 changes: 31 additions & 1 deletion dist/index.d.cts
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,36 @@ declare class UnsupportedError extends Error {
constructor(capability: Capability | string, context: ClientContext);
}

/**
* OSS 图片处理参数化:拼接 `x-oss-process`(resize/format/quality)。
* 与 talesofai 其余前端(weapp/event/bff 的 getImageLink)同一套 OSS 图片处理约定,
* 移植到这里是为了让内嵌页也别再把原图尺寸直出——不同卡片位置渲染宽度不同,
* 高分屏(devicePixelRatio > 1)不按比例多请求像素会糊,全量按比例请求又会浪费流量。
*/
interface OssImageOptions {
/** 目标 CSS 展示宽度(px)。省略则不做 resize,只处理 format/quality。 */
width?: number;
/** 设备像素比倍率;省略则取 `window.devicePixelRatio`(非浏览器环境兜底 1),并 clamp 到 [1, MAX_DPR]。 */
dpr?: number;
/** 输出质量 1-100,默认 80(与其余前端一致)。 */
quality?: number;
/** 是否转 webp,默认 true。 */
webp?: boolean;
}
/**
* 按目标展示宽度 + 设备像素比拼接 OSS 图片处理参数。
* 卡片封面/头像等按实际渲染宽度传 `width`(如缩略图 200、大图 750),SDK 按屏幕 dpr 换算成实取像素宽。
* 非 http(s)(如 `data:` 内联图)解析失败时原样返回,不强行拼参数——`data:` URL 拼 `?x-oss-process=`
* 会把 base64 payload 直接拼坏。已经带 `x-oss-process` 的 URL(后端预处理过 / 重复调用)也原样返回,
* 不再叠加第二个同名 query key(OSS 对重复 key 的解析行为未定义)。
*/
declare function ossImage(source: string | null | undefined, options?: OssImageOptions): string | null;
/**
* 生成 1x/2x/3x 三档 `srcset`(配合 `<img sizes>` 用),让浏览器按实际设备像素比自己选图。
* `width` 是 1x(CSS px)基准宽度;2x/3x 档在此基础上按比例放大取图。
*/
declare function ossImageSrcSet(source: string | null | undefined, width: number, options?: Omit<OssImageOptions, "dpr" | "width">): string | null;

/**
* 初始化并返回 TopicSDK 实例。
*
Expand All @@ -449,4 +479,4 @@ declare class UnsupportedError extends Error {
*/
declare function createTopicSDK(options?: TopicSDKOptions): Promise<TopicSDK>;

export { type AllowedRoute, BridgeClient, type BridgeClient$1 as BridgeClientType, BridgeError, type CampaignCard, Capability, type CharacterCard, type ClientContext, type CreatorCard, type HelloResult, type HighlightPage, type Leaderboard, type LoreEvent, type MyStoryKind, type Page, PageCursor, type RankEntity, type RankEntry, type RankWindow, type RichText, type SDKActivity, type SDKAuth, type SDKEvents, type SDKNav, type SDKRank, type SDKTopic, type SDKUi, type StoryCard, TopicApiError, type TopicDetail, type TopicSDK, type TopicSDKOptions, type TopicTab, UnsupportedError, type ViewportInfo, createTopicSDK };
export { type AllowedRoute, BridgeClient, type BridgeClient$1 as BridgeClientType, BridgeError, type CampaignCard, Capability, type CharacterCard, type ClientContext, type CreatorCard, type HelloResult, type HighlightPage, type Leaderboard, type LoreEvent, type MyStoryKind, type OssImageOptions, type Page, PageCursor, type RankEntity, type RankEntry, type RankWindow, type RichText, type SDKActivity, type SDKAuth, type SDKEvents, type SDKNav, type SDKRank, type SDKTopic, type SDKUi, type StoryCard, TopicApiError, type TopicDetail, type TopicSDK, type TopicSDKOptions, type TopicTab, UnsupportedError, type ViewportInfo, createTopicSDK, ossImage, ossImageSrcSet };
32 changes: 31 additions & 1 deletion dist/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,36 @@ declare class UnsupportedError extends Error {
constructor(capability: Capability | string, context: ClientContext);
}

/**
* OSS 图片处理参数化:拼接 `x-oss-process`(resize/format/quality)。
* 与 talesofai 其余前端(weapp/event/bff 的 getImageLink)同一套 OSS 图片处理约定,
* 移植到这里是为了让内嵌页也别再把原图尺寸直出——不同卡片位置渲染宽度不同,
* 高分屏(devicePixelRatio > 1)不按比例多请求像素会糊,全量按比例请求又会浪费流量。
*/
interface OssImageOptions {
/** 目标 CSS 展示宽度(px)。省略则不做 resize,只处理 format/quality。 */
width?: number;
/** 设备像素比倍率;省略则取 `window.devicePixelRatio`(非浏览器环境兜底 1),并 clamp 到 [1, MAX_DPR]。 */
dpr?: number;
/** 输出质量 1-100,默认 80(与其余前端一致)。 */
quality?: number;
/** 是否转 webp,默认 true。 */
webp?: boolean;
}
/**
* 按目标展示宽度 + 设备像素比拼接 OSS 图片处理参数。
* 卡片封面/头像等按实际渲染宽度传 `width`(如缩略图 200、大图 750),SDK 按屏幕 dpr 换算成实取像素宽。
* 非 http(s)(如 `data:` 内联图)解析失败时原样返回,不强行拼参数——`data:` URL 拼 `?x-oss-process=`
* 会把 base64 payload 直接拼坏。已经带 `x-oss-process` 的 URL(后端预处理过 / 重复调用)也原样返回,
* 不再叠加第二个同名 query key(OSS 对重复 key 的解析行为未定义)。
*/
declare function ossImage(source: string | null | undefined, options?: OssImageOptions): string | null;
/**
* 生成 1x/2x/3x 三档 `srcset`(配合 `<img sizes>` 用),让浏览器按实际设备像素比自己选图。
* `width` 是 1x(CSS px)基准宽度;2x/3x 档在此基础上按比例放大取图。
*/
declare function ossImageSrcSet(source: string | null | undefined, width: number, options?: Omit<OssImageOptions, "dpr" | "width">): string | null;

/**
* 初始化并返回 TopicSDK 实例。
*
Expand All @@ -449,4 +479,4 @@ declare class UnsupportedError extends Error {
*/
declare function createTopicSDK(options?: TopicSDKOptions): Promise<TopicSDK>;

export { type AllowedRoute, BridgeClient, type BridgeClient$1 as BridgeClientType, BridgeError, type CampaignCard, Capability, type CharacterCard, type ClientContext, type CreatorCard, type HelloResult, type HighlightPage, type Leaderboard, type LoreEvent, type MyStoryKind, type Page, PageCursor, type RankEntity, type RankEntry, type RankWindow, type RichText, type SDKActivity, type SDKAuth, type SDKEvents, type SDKNav, type SDKRank, type SDKTopic, type SDKUi, type StoryCard, TopicApiError, type TopicDetail, type TopicSDK, type TopicSDKOptions, type TopicTab, UnsupportedError, type ViewportInfo, createTopicSDK };
export { type AllowedRoute, BridgeClient, type BridgeClient$1 as BridgeClientType, BridgeError, type CampaignCard, Capability, type CharacterCard, type ClientContext, type CreatorCard, type HelloResult, type HighlightPage, type Leaderboard, type LoreEvent, type MyStoryKind, type OssImageOptions, type Page, PageCursor, type RankEntity, type RankEntry, type RankWindow, type RichText, type SDKActivity, type SDKAuth, type SDKEvents, type SDKNav, type SDKRank, type SDKTopic, type SDKUi, type StoryCard, TopicApiError, type TopicDetail, type TopicSDK, type TopicSDKOptions, type TopicTab, UnsupportedError, type ViewportInfo, createTopicSDK, ossImage, ossImageSrcSet };
47 changes: 46 additions & 1 deletion dist/index.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion dist/index.js.map

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions skill-internal-publish/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,18 @@ is_internal: true

# nieta-app 内嵌话题页 — 内部上线 runbook

## ⚠ 开工前必做:自我更新(不允许跳过)

每次执行本 runbook、开始任何实际操作之前,先确认手上这份 `skill-internal-publish/`(来自 `topic-sdk` 仓库根目录)不是过期副本:

> **例外**:如果你正在对 `topic-sdk` 仓库本身开发/提交代码,你就是版本源头,跳过下面这一步。

- 本目录是 git 仓库、且当前在 `main` 分支:`git pull --ff-only origin main`。拉不动 → 停下来向用户说明情况,不要在不确定的版本上对 prod 环境操作。当前不在 `main`:`git fetch origin main` 看落后多少,不要擅自切分支/强制同步。
- 本目录是从别处复制来的纯拷贝:去 `https://github.com/talesofai/topic-sdk` 重新拉取 `skill-internal-publish/` 最新内容覆盖本地(仅覆盖这个目录自身文件,不动你正在操作的创作者项目里的 `.env`)。
- **没有"看起来没变就跳过"这种例外**——每次都要重新做一次。这个 runbook 操作的是 prod 上线(`target=prod`/`activate`/`unbind`),权限校验和流程细节随时可能改动,用过期版本操作 prod 的风险比多花几秒钟高得多。

---

本 runbook 供**内部运营(`is_internal` 账号)**使用。创作者完成开发、发草稿并完成 dev 真机调试后,由内部团队执行本流程将页面正式上线(激活)。

> **权限说明**:`target=prod`(上线)/ `activate`(切换已有草稿为 active)/ `unbind`(下线)仅允许 `is_internal` 完整登录态,scoped dev 令牌和创作者账号会被后端直接拒绝(403)。请务必使用**内部有权限的账号**(`is_internal=true`)。
Expand Down
16 changes: 15 additions & 1 deletion skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,19 @@ description: >-

# nieta-app 自定义内嵌话题页 — 搭建、开发与 dev 发布 skill

## ⚠ 开工前必做:自我更新(不允许跳过)

每次执行本 skill、开始任何实际工作之前,先确认手上这份 `skill/`(以及消费方项目里 `@talesofai/topic-sdk` 依赖)不是过期副本:

> **例外**:如果你正在对 `topic-sdk` 仓库本身开发/提交代码(比如在某个 feature 分支上改 SDK 源码或本文档),你就是版本源头,跳过下面这一步——`git pull --ff-only origin main` 在领先 main 的分支上不是 fast-forward,硬跑只会把自己卡死。

- 本目录是 git 仓库、且当前在 `main` 分支(`git branch --show-current` 确认):`git pull --ff-only origin main`。拉不动(本地有未提交改动导致冲突等)→ 停下来向用户说明情况,不要在不确定的版本上继续。当前不在 `main`(比如在别人留下的 feature 分支上):`git fetch origin main`,对照 `git log HEAD..origin/main` 看看落后多少,不要不问青红皂白就切分支/强制同步。
- 本目录是从别处复制来的纯拷贝(没有 `.git`,比如放进了 cohub 的 skills 目录):去 `https://github.com/talesofai/topic-sdk` 重新拉取 `skill/` 目录的最新内容,只覆盖 `SKILL.md`/`references/`/`assets/` 这些 skill 自身文件,不要动创作者项目自己的 `.env`/`dist/`/未提交改动。仓库如需鉴权(私有仓库),用你环境里已有的 GitHub 凭据;实在拿不到就如实告诉用户"无法确认新鲜度",不要假装拉取成功了。
- 若正在给创作者项目跑 `pnpm install`:确认锁定解析到的 `@talesofai/topic-sdk` 确实是最新 `dist/`(git 依赖有本地缓存,必要时 `pnpm update @talesofai/topic-sdk` 或清缓存重装)。
- **没有"看起来最近更新过就跳过"这种例外**——每次执行本 skill 都要重新做一次这一步。SDK 契约、红线、脚手架随时可能已经改过,用过期版本干活出的问题算没做这一步的责任。

---

你(agent)正代创作者开发一个**内嵌话题页**:一个独立的 Web 单页应用,由 nieta-app 在 `/tag?hashtag=X` 路由内以 **iframe** 内嵌。页面只**读**产品内数据(`/v1/embed/*`),所有写动作由宿主固定浮层承载,页面既不绘制也不调用。

按下面的工作流走,每步带**校验门**,过了再进下一步。详细契约见 `references/api-cheatsheet.md`,红线见 `references/compliance.md`(**交付前必须逐项过**)。
Expand Down Expand Up @@ -84,8 +97,9 @@ description: >-
- `listCharacters` 的 `parentType` 是 `string[]`(省略时后端默认 `['oc','elementum']`)。
- `sdk.rank.get(entity, window, at)`:`oc`/`elementum` **只支持 `at='latest'`**,传时间戳会抛错。
- 分页用 `page.hasNext` 判断是否还有下一页(不要自己用 total 推算)。
- **`coverUrl`/`avatarUrl` 一律用 `ossImage(url, { width })` 包一层再塞进 `<img src>`**(`width` 传该卡片实际渲染宽度,不是原图宽度),不要把原图直出。详见 cheatsheet「图片」一节的推荐宽度参考值。

**校验门**:`getDetail` + `listStories` 能渲染;对所有可空字段已判空(grep 一遍 `.uuid`/`.aspect`/`.startTime` 的使用点)。
**校验门**:`getDetail` + `listStories` 能渲染;对所有可空字段已判空(grep 一遍 `.uuid`/`.aspect`/`.startTime` 的使用点);所有图片字段都经过 `ossImage` 处理(grep 一遍 `coverUrl`/`avatarUrl` 的使用点,确认没有裸传进 `<img src>`)。

## 5. 导航(唯一漏斗:nav.internal)

Expand Down
5 changes: 3 additions & 2 deletions skill/assets/scaffold/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { type StoryCard, type TopicDetail, type TopicSDK } from "@talesofai/topic-sdk";
import { ossImage, type StoryCard, type TopicDetail, type TopicSDK } from "@talesofai/topic-sdk";
import { getHashtag, getSdk } from "./sdk";

/**
Expand Down Expand Up @@ -83,7 +83,8 @@ export function App() {
>
{s.coverUrl && (
<img
src={s.coverUrl}
// 列表卡片按渲染宽度取图(这里约 340px),别把原图直出——ossImage 按 devicePixelRatio 自动适配高分屏。
src={ossImage(s.coverUrl, { width: 340 }) ?? undefined}
alt={s.title ?? ""}
// aspect 可空 → 兜底
style={{ width: "100%", aspectRatio: s.aspect ?? "1 / 1", objectFit: "cover", borderRadius: 6 }}
Expand Down
Loading
Loading