From fb0cd7879248935cb5fe8db99bb33e5fd070647e Mon Sep 17 00:00:00 2001 From: Gaubee Date: Sat, 24 Jan 2026 17:52:14 +0800 Subject: [PATCH] feat: complete implementation --- scripts/agent-flow/workflows/task.workflow.ts | 52 +++- src/services/ecosystem/registry.ts | 227 ++++++++++++++---- .../activities/tabs/EcosystemTab.tsx | 16 +- src/stores/ecosystem.ts | 80 ++++-- 4 files changed, 298 insertions(+), 77 deletions(-) diff --git a/scripts/agent-flow/workflows/task.workflow.ts b/scripts/agent-flow/workflows/task.workflow.ts index b2ccaedb8..0fec32a6b 100755 --- a/scripts/agent-flow/workflows/task.workflow.ts +++ b/scripts/agent-flow/workflows/task.workflow.ts @@ -254,25 +254,26 @@ const syncWorkflow = defineWorkflow({ name: "sync", description: "同步进度到 Issue (更新 Issue Description)", args: { - content: { type: "string", description: "新的任务列表/进度 (Markdown)", required: true }, + content: { type: "string", description: "新的任务列表/进度 (Markdown)", required: false }, + issue: { type: "string", description: "Issue 编号(可在非 worktree 目录中使用)", required: false }, }, handler: async (args) => { - const wt = getCurrentWorktreeInfo(); - if (!wt || !wt.issueId) { - console.error("❌ 错误: 必须在 issue worktree 中运行"); - Deno.exit(1); - } - const content = args.content || args._.join(" "); if (!content) { console.error("❌ 错误: 请提供同步内容"); Deno.exit(1); } - console.log(`🔄 同步进度到 Issue #${wt.issueId}...`); + const issueId = resolveIssueId(args.issue); + if (!issueId) { + console.error("❌ 错误: 无法定位 Issue。请在 issue worktree 中运行,或传入 --issue "); + Deno.exit(1); + } + + console.log(`🔄 同步进度到 Issue #${issueId}...`); await updateIssue({ - issueId: wt.issueId, + issueId, body: content, }); @@ -334,6 +335,39 @@ function getCurrentWorktreeInfo() { return null; } +function resolveIssueId(explicitIssue?: string): string | null { + if (explicitIssue && explicitIssue.trim().length > 0) { + return explicitIssue.trim(); + } + + const wt = getCurrentWorktreeInfo(); + if (wt?.issueId) return wt.issueId; + + const branch = getGitBranchName(); + if (branch) { + const match = branch.match(/issue-(\d+)/); + if (match?.[1]) return match[1]; + } + + return null; +} + +function getGitBranchName(): string | null { + try { + const p = new Deno.Command("git", { + args: ["branch", "--show-current"], + stdout: "piped", + stderr: "null", + }); + const { code, stdout } = p.outputSync(); + if (code !== 0) return null; + const branch = new TextDecoder().decode(stdout).trim(); + return branch.length > 0 ? branch : null; + } catch { + return null; + } +} + // ============================================================================= // Main Router // ============================================================================= diff --git a/src/services/ecosystem/registry.ts b/src/services/ecosystem/registry.ts index 5b8d0334e..123424eb0 100644 --- a/src/services/ecosystem/registry.ts +++ b/src/services/ecosystem/registry.ts @@ -61,11 +61,12 @@ async function getCachedSource(url: string): Promise { async function setCachedSource(entry: CacheEntry): Promise { try { const db = await openCacheDB(); - return new Promise((resolve, reject) => { + await new Promise((resolve) => { const tx = db.transaction(STORE_NAME, 'readwrite'); const store = tx.objectStore(STORE_NAME); - const request = store.put(entry); - request.onerror = () => reject(request.error); + const key = entry.url; + const request = store.keyPath ? store.put(entry) : store.put(entry, key); + request.onerror = () => resolve(); request.onsuccess = () => resolve(); }); } catch { @@ -78,6 +79,37 @@ async function setCachedSource(entry: CacheEntry): Promise { const TTL_MS = 5 * 60 * 1000; // 5 minutes const SEARCH_TTL_MS = 30 * 1000; // 30 seconds const ttlCache = new Map(); +const DEBUG_ECOSYSTEM = import.meta.env.DEV || __DEV_MODE__; + +function debugLog(message: string, data?: Record): void { + if (!DEBUG_ECOSYSTEM) return; + if (data) { + console.log(`[ecosystem] ${message}`, data); + } else { + console.log(`[ecosystem] ${message}`); + } +} + +function normalizeFetchUrl(rawUrl: string): string { + if (typeof window === 'undefined') return rawUrl; + const trimmed = rawUrl.trim(); + if (trimmed.length === 0) return rawUrl; + + if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(trimmed)) { + return trimmed; + } + + if (trimmed.startsWith('//')) { + return `${window.location.protocol}${trimmed}`; + } + + try { + const baseUrl = typeof document !== 'undefined' ? document.baseURI : window.location.origin; + return new URL(trimmed, baseUrl).toString(); + } catch { + return rawUrl; + } +} function getTTLCached(key: string): T | null { const entry = ttlCache.get(key); @@ -104,38 +136,61 @@ function invalidateTTLCache(prefix: string): void { // ==================== Fetch with ETag/Cache ==================== async function fetchSourceWithEtag(url: string): Promise { + const fetchUrl = normalizeFetchUrl(url); + const cacheKey = fetchUrl; // Check TTL cache first - const ttlCached = getTTLCached(`source:${url}`); + const ttlCached = getTTLCached(`source:${cacheKey}`); if (ttlCached) return ttlCached; // Get cached entry for ETag - const cached = await getCachedSource(url); + const cached = await getCachedSource(cacheKey); const headers: Record = {}; - if (cached?.etag) { + if (cached?.etag && (typeof window === 'undefined' || new URL(fetchUrl).origin === window.location.origin)) { headers['If-None-Match'] = cached.etag; } try { - const response = await fetch(url, { headers }); + debugLog('fetch source start', { url, fetchUrl }); + const response = await fetch(fetchUrl, { headers }); // 304 Not Modified - use cache if (response.status === 304 && cached) { - setTTLCached(`source:${url}`, cached.data, TTL_MS); + debugLog('fetch source not modified', { url, fetchUrl }); + setTTLCached(`source:${cacheKey}`, cached.data, TTL_MS); return cached.data; } if (!response.ok) { + debugLog('fetch source failed', { url, fetchUrl, status: response.status }); // Fall back to cache on error if (cached) { - setTTLCached(`source:${url}`, cached.data, TTL_MS); + setTTLCached(`source:${cacheKey}`, cached.data, TTL_MS); return cached.data; } return null; } - const json = await response.json(); + const contentType = response.headers.get('content-type') ?? ''; + let json: unknown; + try { + json = await response.json(); + } catch (error) { + debugLog('fetch source parse error', { + url, + fetchUrl, + contentType, + message: error instanceof Error ? error.message : String(error), + }); + if (cached) return cached.data; + return null; + } const parsed = EcosystemSourceSchema.safeParse(json); if (!parsed.success) { + debugLog('fetch source invalid payload', { + url, + fetchUrl, + issues: parsed.error.issues.map((issue) => issue.path.join('.')), + }); if (cached) return cached.data; return null; } @@ -144,14 +199,20 @@ async function fetchSourceWithEtag(url: string): Promise const etag = response.headers.get('etag') ?? undefined; // Update cache - await setCachedSource({ url, data, etag, cachedAt: Date.now() }); - setTTLCached(`source:${url}`, data, TTL_MS); + await setCachedSource({ url: cacheKey, data, etag, cachedAt: Date.now() }); + setTTLCached(`source:${cacheKey}`, data, TTL_MS); + debugLog('fetch source success', { url, fetchUrl, apps: data.apps?.length ?? 0 }); return data; - } catch { + } catch (error) { + debugLog('fetch source error', { + url, + fetchUrl, + message: error instanceof Error ? error.message : String(error), + }); // Fall back to cache on error if (cached) { - setTTLCached(`source:${url}`, cached.data, TTL_MS); + setTTLCached(`source:${cacheKey}`, cached.data, TTL_MS); return cached.data; } return null; @@ -191,6 +252,7 @@ function fetchSearchResults(url: string): Effect.Effect<{ version: string; data: } let cachedApps: MiniappManifest[] = []; +const sourcePayloads = new Map(); type AppsSubscriber = (apps: MiniappManifest[]) => void; const appSubscribers: AppsSubscriber[] = []; @@ -200,6 +262,88 @@ function notifyApps(): void { appSubscribers.forEach((fn) => fn(snapshot)); } +function computeGaussianWeightedAverage(values: number[]): number { + if (values.length === 0) return 0; + if (values.length === 1) return values[0] ?? 0; + + const mean = values.reduce((sum, v) => sum + v, 0) / values.length; + const variance = + values.reduce((sum, v) => sum + Math.pow(v - mean, 2), 0) / Math.max(1, values.length - 1); + const sigma = Math.sqrt(variance); + const sigmaSafe = sigma > 0 ? sigma : 1; + const minWeight = 0.05; + + let weightedSum = 0; + let weightTotal = 0; + + for (const v of values) { + const z = (v - mean) / sigmaSafe; + const gaussianWeight = Math.exp(-0.5 * z * z); + const weight = Math.max(minWeight, gaussianWeight); + weightedSum += v * weight; + weightTotal += weight; + } + + if (weightTotal === 0) return mean; + return weightedSum / weightTotal; +} + +function mergeAppsFromSources( + sources: SourceRecord[], + payloadsByUrl: Map, +): MiniappManifest[] { + const merged: MiniappManifest[] = []; + const indexById = new Map(); + const officialScoresById = new Map(); + const communityScoresById = new Map(); + + for (const source of sources) { + const payload = payloadsByUrl.get(source.url); + if (!payload) continue; + + for (const app of payload.apps ?? []) { + const normalized = normalizeAppFromSource(app, source, payload); + if (!normalized) continue; + + const id = normalized.id; + if (!indexById.has(id)) { + indexById.set(id, merged.length); + merged.push(normalized); + } + + if (typeof normalized.officialScore === 'number') { + const list = officialScoresById.get(id) ?? []; + list.push(normalized.officialScore); + officialScoresById.set(id, list); + } + + if (typeof normalized.communityScore === 'number') { + const list = communityScoresById.get(id) ?? []; + list.push(normalized.communityScore); + communityScoresById.set(id, list); + } + } + } + + for (const [id, index] of indexById) { + const base = merged[index]; + if (!base) continue; + + const officialScores = officialScoresById.get(id); + if (officialScores && officialScores.length > 0) { + base.officialScore = computeGaussianWeightedAverage(officialScores); + } + + const communityScores = communityScoresById.get(id); + if (communityScores && communityScores.length > 0) { + base.communityScore = computeGaussianWeightedAverage(communityScores); + } + } + + debugLog('merge apps', { sources: sources.length, apps: merged.length }); + return merged; +} + export function subscribeApps(listener: AppsSubscriber): () => void { appSubscribers.push(listener); return () => { @@ -239,45 +383,32 @@ async function fetchSourceWithCache(url: string): Promise, -): Promise { - const next: MiniappManifest[] = []; - const seen = new Set(); - - for (const { source, payload } of sources) { - if (!payload) continue; - - for (const app of payload.apps ?? []) { - const normalized = normalizeAppFromSource(app, source, payload); - if (!normalized) continue; - - if (seen.has(normalized.id)) continue; - seen.add(normalized.id); - next.push(normalized); - } - } - - cachedApps = next; - notifyApps(); -} - async function rebuildCachedAppsFromCache(): Promise { const enabledSources = ecosystemSelectors.getEnabledSources(ecosystemStore.state); + if (enabledSources.length === 0) { + debugLog('no enabled sources'); + } - const entries = await Promise.all( + await Promise.all( enabledSources.map(async (source) => { const payload = await fetchSourceWithCache(source.url); - return { source, payload }; + sourcePayloads.set(source.url, payload); + cachedApps = mergeAppsFromSources(enabledSources, sourcePayloads); + notifyApps(); }), ); +} - await rebuildCachedAppsFromSources(entries); +function getSourcesSignature(sources: SourceRecord[]): string { + return sources + .map((source) => `${source.url}|${source.enabled ? '1' : '0'}`) + .sort() + .join(','); } let lastSourcesSignature = ''; ecosystemStore.subscribe(() => { - const nextSignature = JSON.stringify(ecosystemStore.state.sources); + const nextSignature = getSourcesSignature(ecosystemStore.state.sources); if (nextSignature === lastSourcesSignature) return; lastSourcesSignature = nextSignature; void rebuildCachedAppsFromCache(); @@ -298,27 +429,31 @@ export function getSources(): SourceRecord[] { export async function refreshSources(options?: { force?: boolean }): Promise { const force = options?.force === true; const enabledSources = ecosystemSelectors.getEnabledSources(ecosystemStore.state); + if (enabledSources.length === 0) { + debugLog('refresh skipped: no enabled sources'); + } if (force) { invalidateTTLCache('source:'); } - const results = await Promise.all( + await Promise.all( enabledSources.map(async (source) => { ecosystemActions.updateSourceStatus(source.url, 'loading'); try { const payload = await fetchSourceWithEtag(source.url); ecosystemActions.updateSourceStatus(source.url, 'success'); - return { source, payload }; + sourcePayloads.set(source.url, payload); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; ecosystemActions.updateSourceStatus(source.url, 'error', message); - return { source, payload: null }; + sourcePayloads.set(source.url, null); + } finally { + cachedApps = mergeAppsFromSources(enabledSources, sourcePayloads); + notifyApps(); } }), ); - - await rebuildCachedAppsFromSources(results); return [...cachedApps]; } diff --git a/src/stackflow/activities/tabs/EcosystemTab.tsx b/src/stackflow/activities/tabs/EcosystemTab.tsx index c2a725210..d281a8446 100644 --- a/src/stackflow/activities/tabs/EcosystemTab.tsx +++ b/src/stackflow/activities/tabs/EcosystemTab.tsx @@ -25,11 +25,20 @@ export function EcosystemTab() { // 初始化数据 useEffect(() => { - const unsubscribe = subscribeApps((nextApps) => setApps(nextApps)); + let receivedUpdate = false; + const unsubscribe = subscribeApps((nextApps) => { + setApps(nextApps); + if (!receivedUpdate) { + receivedUpdate = true; + setLoading(false); + } + }); initRegistry({ refresh: true }).then(() => { - setApps(getApps()); - setLoading(false); + if (!receivedUpdate) { + setApps(getApps()); + setLoading(false); + } }); return () => unsubscribe(); @@ -118,4 +127,3 @@ export function EcosystemTab() { /> ); } - diff --git a/src/stores/ecosystem.ts b/src/stores/ecosystem.ts index 239c03cdb..337276375 100644 --- a/src/stores/ecosystem.ts +++ b/src/stores/ecosystem.ts @@ -68,6 +68,64 @@ function arraysEqual(a: T[], b: T[]): boolean { return a.length === b.length && a.every((v, i) => v === b[i]); } +function getDefaultSources(): SourceRecord[] { + return [ + { + url: `${import.meta.env.BASE_URL}miniapps/ecosystem.json`, + name: 'Bio 官方生态', // i18n-ignore: config data + lastUpdated: new Date().toISOString(), + enabled: true, + status: 'idle' as const, + }, + ]; +} + +function mergeSourcesWithDefault(sources: SourceRecord[]): SourceRecord[] { + const defaults = getDefaultSources(); + const merged = [...sources]; + + for (const fallback of defaults) { + if (!merged.some((source) => source.url === fallback.url)) { + merged.push(fallback); + } + } + + return merged; +} + +function normalizeSources(input: unknown): SourceRecord[] { + if (!Array.isArray(input)) return []; + const now = new Date().toISOString(); + + return input + .map((item): SourceRecord | null => { + if (typeof item === 'string') { + return { + url: item, + name: 'Bio 官方生态', // i18n-ignore: migration fallback + lastUpdated: now, + enabled: true, + status: 'idle', + }; + } + if (!item || typeof item !== 'object') return null; + + const record = item as Partial & { url?: unknown }; + if (typeof record.url !== 'string' || record.url.length === 0) return null; + + return { + url: record.url, + name: typeof record.name === 'string' && record.name.length > 0 ? record.name : 'Bio 官方生态', + lastUpdated: typeof record.lastUpdated === 'string' && record.lastUpdated.length > 0 ? record.lastUpdated : now, + enabled: typeof record.enabled === 'boolean' ? record.enabled : true, + status: record.status ?? 'idle', + errorMessage: record.errorMessage, + icon: record.icon, + }; + }) + .filter((item): item is SourceRecord => item !== null); +} + /** 从 localStorage 加载状态 */ function loadState(): EcosystemState { try { @@ -85,17 +143,11 @@ function loadState(): EcosystemState { ? availableSubPages : [...availableSubPages, activeSubPage]; + const normalizedSources = normalizeSources(parsed.sources); + const mergedSources = mergeSourcesWithDefault(normalizedSources); return { permissions: parsed.permissions ?? [], - sources: parsed.sources ?? [ - { - url: `${import.meta.env.BASE_URL}miniapps/ecosystem.json`, - name: 'Bio 官方生态', // i18n-ignore: config data - lastUpdated: new Date().toISOString(), - enabled: true, - status: 'idle' as const, - }, - ], + sources: mergedSources.length > 0 ? mergedSources : getDefaultSources(), myApps: loadMyApps(), availableSubPages: fixedAvailableSubPages, activeSubPage, @@ -108,15 +160,7 @@ function loadState(): EcosystemState { } return { permissions: [], - sources: [ - { - url: `${import.meta.env.BASE_URL}miniapps/ecosystem.json`, - name: 'Bio 官方生态', // i18n-ignore: config data - lastUpdated: new Date().toISOString(), - enabled: true, - status: 'idle' as const, - }, - ], + sources: mergeSourcesWithDefault([]), myApps: loadMyApps(), availableSubPages: DEFAULT_AVAILABLE_SUBPAGES, activeSubPage: 'discover',