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
4 changes: 4 additions & 0 deletions .agents/skills/anon-tweet/references/anon-tweet-openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -1692,6 +1692,10 @@
"type": "object",
"description": "预览卡片(LinkPreviewCard)"
},
"space": {
"type": "object",
"description": "X Space 卡片(SpaceDetails,见 app/types/space.ts;仅 Space 推文有)"
},
"comments": {
"type": "array",
"items": {
Expand Down
6 changes: 5 additions & 1 deletion .commandcode/taste/taste/taste.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
- Prefers minimal, non-noisy UI copy: dislikes long/verbose disclosure text placed directly on the page (calls it "太吵" / too noisy), and values restraint over richness ("有时候丰富不代表好") — favor trimming or dropping informative-but-cluttering content instead of adding more. Confidence: 0.8
- Scopes a screen to its actual purpose: when a view's real use is a single action (e.g. IG stories are for downloading the media), wants the unrelated actions (screenshot/translate/copy) removed from that surface rather than offered for completeness. Confidence: 0.6
- When a feature or UI copy is removed, also delete the now-orphaned tests that assert it — keep the test suite in sync with what actually ships rather than leaving stale assertions behind. Confidence: 0.7
- Wants third-party SDK/library behavior verified empirically before building on it: explicitly asks the agent to "跑跑那个 SDK 看看有啥输出" — write a throwaway probe that runs the library against real inputs and dumps the actual message/metadata shapes, instead of inferring behavior from type definitions or assumptions. Confidence: 0.7
- Wants third-party SDK/library behavior verified empirically before building on it: explicitly asks the agent to "跑跑那个 SDK 看看有啥输出" — write a throwaway probe that runs the library against real inputs and dumps the actual message/metadata shapes, instead of inferring behavior from type definitions or assumptions. The same applies when adding support for a new content/entity type: expects the agent to first look at the real upstream payload (the original API response / provided sample HTML) to derive the actual shape — "加个功能支持它,可以先看看它的原始返回". Confidence: 0.75
- Throwaway/verification scripts belong inside the project's own (gitignored) `tmp/` directory, not the agent's external scratchpad or OS temp dir — the project location is what lets them resolve the repo's `node_modules` and pick up repo config. Confidence: 0.75
- Expects the agent to source credentials/config from the repo's root `.env` and to check there before declaring something unavailable (e.g. "cookie 是在这的 `I:\dev\anonTweet\.env`"). Confidence: 0.65
- When an installed dependency's behavior is unclear or suspected, wants the agent to go read that library's own source repository (kept as a local checkout, e.g. `I:\dev\gallery-dl-ins`) — `src/`, tests, `scripts/` and any recorded sample data/output — instead of inferring from the installed `dist/` or the type definitions. Confidence: 0.7
Expand All @@ -48,3 +48,7 @@
- Wants the standard GitHub community-health surface to actually exist and be cross-linked (issue forms, PR template, SECURITY.md with a private disclosure channel, CODE_OF_CONDUCT.md) instead of only being promised/referenced — when offered a scope menu he took all of it rather than the minimum. Confidence: 0.6
- Treats a changelog as a historical record: reformatting it into a standard structure (Keep a Changelog sections, its original grouping demoted to sub-headings) is fine, but the recorded entries' content must not be rewritten. Confidence: 0.55
rouping demoted to sub-headings) is fine, but the recorded entries' content must not be rewritten. Confidence: 0.55
- For a missing/unmapped data field, expects the fix to run the concrete pipeline end to end — temp probe against the real upstream, add the value into the corresponding field/type, then render it — instead of broad codebase exploration or an upfront design discussion; points at the closest existing repo file (e.g. `app/lib/react-tweet/utils/get-tweet.ts`) to copy as the pattern. Confidence: 0.65
- Expects assumptions about external services/URL schemes to be validated against the live endpoint per variant rather than over-generalized from one working example — reports back after real-world testing (e.g. twimg `/media/<slug>` works as both `?format=jpg` and `/slug.jpg`, but `/card_img/<id>/<slug>` only resolves in query form and 404s as `/slug.jpg`, so a blanket normalization broke link-card images). Confidence: 0.65
- Expects degraded/edge states of external data to be surfaced in the UI rather than the element silently disappearing: after verifying the happy path himself, flags that deleted/unavailable resources or replay-disabled cases have "没有对应的UI提示" and wants a visible fallback/tombstone (or an explicit non-actionable state such as "不可回放") instead of a bare or missing element. Confidence: 0.7
- When reporting a gap or reviewing a feature, supplies a concrete real-world sample (a live URL / specific resource id) to reproduce against rather than describing the case abstractly. Confidence: 0.55
2 changes: 2 additions & 0 deletions app/components/tweet/PlainTweet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { cn } from '~/lib/utils'
import { AIVisionBlock } from './AIVisionBlock'
import { TweetLinkCard } from './TweetCard'
import { TweetMediaAlt } from './TweetMediaAlt'
import { TweetSpaceCard } from './TweetSpaceCard'

function TweetTextBody({ tweet, enableTranslation }: { tweet: EnrichedTweet, enableTranslation: boolean }) {
if (!enableTranslation) {
Expand Down Expand Up @@ -111,6 +112,7 @@ function UnifiedTweet({ tweet, variant, enableTranslation }: UnifiedTweetProps)
<AIVisionBlock tweet={tweet} hideChrome />

{tweet.card && <TweetLinkCard tweet={tweet} />}
{tweet.space && <TweetSpaceCard tweet={tweet} />}

{quotedTweet && (
<UnifiedTweet
Expand Down
2 changes: 2 additions & 0 deletions app/components/tweet/TweetNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { cn } from '~/lib/utils'
import { AIVisionBlock } from './AIVisionBlock'
import { TweetLinkCard } from './TweetCard'
import { TweetMediaAlt } from './TweetMediaAlt'
import { TweetSpaceCard } from './TweetSpaceCard'
import { TweetTextBody } from './TweetTextBody'

export type TweetVariant = 'thread' | 'quoted' | 'main' | 'main-in-thread'
Expand Down Expand Up @@ -137,6 +138,7 @@ export const TweetNode = forwardRef<HTMLDivElement, TweetNodeProps>(({
<TweetMediaAlt tweet={tweet} />
<AIVisionBlock tweet={tweet} editor={visionEditor} />
{tweet.card && <TweetLinkCard tweet={tweet} />}
{tweet.space && <TweetSpaceCard tweet={tweet} />}

{tweet.quotedTweet && (
<TweetNode
Expand Down
175 changes: 175 additions & 0 deletions app/components/tweet/TweetSpaceCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import type { EnrichedTweet, SpaceAvailability } from '~/types'
import { MicOff, Play } from 'lucide-react'
import { useMemo } from 'react'
import { MediaImage } from '~/components/ui/media'
import { normalizeMediaUrl } from '~/lib/media-url'
import { VerifiedBadge } from '~/lib/react-tweet/twitter-theme/verified-badge'
import {
formatSpaceDate,
formatSpaceDuration,
formatSpaceListeners,
resolveSpacePlaybackState,
} from '~/lib/react-tweet/utils/space'
import { useProxyMedia } from '~/lib/stores/appConfig'
import { cn } from '~/lib/utils'

interface TweetSpaceCardProps {
tweet: EnrichedTweet
className?: string
}

/**
* 各可播放状态的行动区与无障碍名称。
*
* 关键:**只有 `replayable` 才给播放入口**——已结束但未开启回放的 Space 没有录音可放,
* 给「播放录音」会把人送进一个播不了的页面(本期站内不播放,整卡跳转 X,按钮即承诺)。
*/
const ACTION_BY_AVAILABILITY: Record<
Exclude<SpaceAvailability, 'unavailable'>,
{ label: string, aria: string, primary: boolean, icon: 'play' | 'mic-off' | null }
> = {
'replayable': { label: '播放录音', aria: '播放录音', primary: true, icon: 'play' },
'live': { label: '直播中', aria: '正在直播', primary: true, icon: null },
'upcoming': { label: '尚未开始', aria: '尚未开始', primary: false, icon: null },
'no-replay': { label: '录音不可回放', aria: '录音不可回放', primary: false, icon: 'mic-off' },
}

/**
* X Space 卡片(语音直播 / 录音回放)。
*
* 对标官方渲染(`tmp/録音を再生….html` 实测 DOM):紫色底 + 白字,主播行(头像 + 名称 +
* 认证徽标)、标题、`收听/回放 · 日期 · 时长` 元信息行、行动按钮。
*
* 边界态(本卡片存在的主要理由):
* - **Space 已删除 / 不可访问**(上游无 metadata)→ 中性墓碑条「Space 已删除或不可访问」,
* 不再只剩正文里一个裸链接
* - **已结束但未开启回放** → 标题照常展示,行动区降级为「录音不可回放」(不给播放入口)
* - 进行中 / 未开始 → 行动区分别为「直播中」/「尚未开始」
*
* 范围裁定(owner 2026-09-16):**卡片 + 跳转 X 播放**。整卡是指向 Space 页面的链接——
* 站内播放需要 hls.js + 服务端媒体代理(pscp.tv 的 m3u8 只给 `x.com` 放行 CORS),本期不做,
* 故行动区是视觉元素而非嵌套的可交互元素(避免交互元素嵌套)。
*/
export function TweetSpaceCard({ tweet, className }: TweetSpaceCardProps) {
const proxyMedia = useProxyMedia()
const space = tweet.space

const data = useMemo(() => {
if (!space)
return null

const avatarUrl = space.host.profile_image_url_https

return {
duration: formatSpaceDuration(space.durationMs),
listeners: `${formatSpaceListeners(space.listenersCount)} 人收听/回放`,
date: formatSpaceDate(space.createdAt),
avatarSrc: avatarUrl ? proxyMedia(normalizeMediaUrl(avatarUrl)) : '',
}
}, [space, proxyMedia])

if (!space || !data)
return null

// 墓碑态:上游已明确「没有这个 Space」,没有任何可展示的元数据
if (space.availability === 'unavailable') {
return (
<div
className={cn(
'mt-2 flex items-center gap-2 rounded-md border border-border/60 bg-muted/40 px-3 py-2.5 text-xs text-muted-foreground',
className,
)}
>
<MicOff className="size-3.5 shrink-0" aria-hidden="true" />
<span>Space 已删除或不可访问</span>
<a
href={space.url}
target="_blank"
rel="noopener noreferrer"
className="ml-auto shrink-0 underline underline-offset-2 hover:text-foreground"
>
在 X 查看
</a>
</div>
)
}

const { duration, listeners, date, avatarSrc } = data
// 经 resolveSpacePlaybackState 兜底:旧缓存里的 space 没有 availability 字段
const action = ACTION_BY_AVAILABILITY[resolveSpacePlaybackState(space)]

return (
<a
href={space.url}
target="_blank"
rel="noopener noreferrer"
aria-label={`${action.aria}: ${space.title}`}
className={cn(
'mt-2 block overflow-hidden rounded-md bg-[#9c63fa] text-white transition-colors hover:bg-[#8b4ff2]',
className,
)}
>
<div className="flex flex-col gap-3 p-3">
<div className="flex items-center gap-2">
<div className="size-6 shrink-0 overflow-hidden rounded-full bg-white/25">
{avatarSrc
? (
<MediaImage
src={avatarSrc}
alt=""
className="size-full object-cover"
loading="lazy"
decoding="async"
/>
)
: null}
</div>

<div className="flex min-w-0 items-center gap-1">
<span className="truncate text-sm font-bold" title={space.host.name}>
{space.host.name}
</span>
<VerifiedBadge user={space.host} className="shrink-0" />
</div>
</div>

{space.title
? (
<div className="text-[1.0625rem] font-bold leading-snug line-clamp-2">
{space.title}
</div>
)
: null}

<div className="flex flex-wrap items-center gap-1.5 text-[13px] text-white/80">
<span>{listeners}</span>
<span aria-hidden="true" className="text-white/40">·</span>
<span>{date}</span>
{duration
? (
<>
<span aria-hidden="true" className="text-white/40">·</span>
<span aria-label={`时长 ${duration}`}>{duration}</span>
</>
)
: null}
</div>

<span
className={cn(
'inline-flex w-fit items-center gap-2 rounded-full px-4 py-1.5 text-sm font-bold',
action.primary ? 'bg-white text-[#0f1419]' : 'bg-white/15 text-white/85',
)}
>
{action.icon === 'play'
? <Play className="size-4 fill-current" aria-hidden="true" />
: null}
{action.icon === 'mic-off'
? <MicOff className="size-4" aria-hidden="true" />
: null}
{action.label}
</span>
</div>
</a>
)
}
1 change: 1 addition & 0 deletions app/components/tweet/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,5 @@ export { TweetInputForm } from './TweetInputForm'
export { TweetMediaAlt } from './TweetMediaAlt'
export { TweetNode } from './TweetNode'
export { TweetOptionsMenu } from './TweetOptionsMenu'
export { TweetSpaceCard } from './TweetSpaceCard'
export { TweetTextBody } from './TweetTextBody'
3 changes: 2 additions & 1 deletion app/lib/llms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ ${apiBullets}

后端主要接口(搜索 / 获取推文)的出参是 EnrichedTweet 数组,字段对齐 react-tweet:

- EnrichedTweet: id_str(推文 ID)、text(推文文本)、url(完整链接)、lang(语言码)、created_at(ISO 8601 创建时间)、user(作者 TweetUser)、entities(实体 Entity[])、visionInfo(可选,AI 视觉描述)、quotedTweet(可选,引用推文)、card(可选,预览卡片)
- EnrichedTweet: id_str(推文 ID)、text(推文文本)、url(完整链接)、lang(语言码)、created_at(ISO 8601 创建时间)、user(作者 TweetUser)、entities(实体 Entity[])、visionInfo(可选,AI 视觉描述)、quotedTweet(可选,引用推文)、card(可选,预览卡片)、space(可选,X Space 卡片:标题/主播/收听人数/时长)
- TweetUser: id_str、name、screen_name、profile_image_url_https(头像)、verified、is_blue_verified
- Entity: type(text / hashtag / mention / url / media / symbol / media_alt / separator)、text、index(文本偏移)、href(多数类型带链接)、translation(手动翻译)、aiTranslation(AI 翻译)
- SearchResponse / RepliesResponse: { tweets: EnrichedTweet[], nextCursor }(nextCursor 为 string 或 null,null 表示没有更多)
Expand Down Expand Up @@ -729,6 +729,7 @@ export function buildOpenApiDoc(baseUrl: string): Record<string, unknown> {
quoted_tweet_id: { type: 'string', description: '引用推文 ID' },
quotedTweet: { $ref: '#/components/schemas/EnrichedTweet', description: '引用推文(递归)' },
card: { type: 'object', description: '预览卡片(LinkPreviewCard)' },
space: { type: 'object', description: 'X Space 卡片(SpaceDetails,见 app/types/space.ts;仅 Space 推文有)' },
comments: { type: 'array', items: { $ref: '#/components/schemas/EnrichedTweet' }, description: '内联评论线程' },
in_reply_to_status_id_str: { type: 'string' },
mediaDetails: { type: 'array', items: { type: 'object', description: '媒体详情(图片/视频,见 app/types/media.ts)' } },
Expand Down
1 change: 1 addition & 0 deletions app/lib/obs-log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export type ObsEvent
| 'pool.rotate' // RettiwtPool 冷却轮换:keySuffix / failStreak / cooldownMs / reason
| 'pool.exhaust' // RettiwtPool 全部 Key 耗尽:attempts / states
| 'jetfuel.parse.fallback' // jetfuel payload 解析失败回退 unified_card:card / payloadHash / reason
| 'space.fetch.failed' // Space 元数据(AudioSpaceById)获取失败,卡片降级不展示:spaceId / tweetId / reason

export interface ObsFields {
[key: string]: string | number | boolean | null | undefined
Expand Down
5 changes: 5 additions & 0 deletions app/lib/react-tweet/twitter-theme/tweet-body.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { EnrichedTweet } from '~/types'
import { cn } from '~/lib/utils'
import { isSpaceUrl } from '../utils/space'
import { TweetLink } from './tweet-link'

interface TweetBodyProps {
Expand All @@ -23,6 +24,10 @@ export function TweetBody({ tweet, isTranslated, lang, className }: TweetBodyPro

switch (item.type) {
case 'url':
// Space 卡片(TweetSpaceCard)已承载同一跳转,官方前端同样不重复展示该链接。
// 仅在卡片数据确实渲染出来时才跳过——元数据获取失败时链接照常显示(优雅降级)。
if (tweet.space && isSpaceUrl(item.href))
return null
return (
<TweetLink key={item.index} href={item.href}>
{text.length > 36 ? item.display_url : text}
Expand Down
3 changes: 2 additions & 1 deletion app/lib/react-tweet/twitter-theme/verified-badge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import {
} from './icons/index'

interface Props {
user: TweetUser
/** 只依赖认证三字段,故不限 `TweetUser` 本体(Space 主播等精简用户对象也可直接传入) */
user: Pick<TweetUser, 'verified' | 'is_blue_verified' | 'verified_type'>
className?: string
}

Expand Down
Loading
Loading