@ateam-ai-mobile/core 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/acs/api.ts ADDED
@@ -0,0 +1,345 @@
1
+ /**
2
+ * Client for Core's /api/acs/* proxy → ACS service. Powers the "widgets"
3
+ * surface on home: AI-generated artifacts that render as carousel tiles and
4
+ * full-screen interactive panels via the bundleUrl — plus the home
5
+ * "top actions" feed.
6
+ *
7
+ * Endpoints:
8
+ * POST /api/acs/surface — list active artifacts for a surface
9
+ * POST /api/acs/event — usage tracking (clicked/dismissed/etc.)
10
+ * GET /api/acs/made-for-you — full library, all lifecycle stages
11
+ * DELETE /api/acs/artifacts/{id} — user-initiated retire (idempotent)
12
+ * PATCH /api/acs/artifacts/{id} — visibility + rename
13
+ * PUT /api/acs/order — drag-reorder
14
+ * GET /api/acs/home-actions — top-actions feed (+ PATCH/PUT order)
15
+ * WS topics acs.artifacts / acs.home-actions — cross-device sync (see ./ws)
16
+ */
17
+
18
+ import { getCoreApiUrl, getToken } from '../config';
19
+ import { reportAcsServerUp, reportAcsServerDown } from './providers';
20
+
21
+ export interface AcsArtifact {
22
+ id: string;
23
+ name: string;
24
+ description?: string;
25
+ iconHint?: string;
26
+ manifest?: Record<string, any>;
27
+ bundleUrl: string;
28
+ version: number;
29
+ provenance?: Record<string, any>;
30
+
31
+ // Tile-shape fields (consumers branch on these when present; safe defaults
32
+ // when absent).
33
+ kind?: 'user' | 'featured';
34
+ visibility?: 'pinned' | 'hidden';
35
+ pinOrder?: number;
36
+ canDelete?: boolean;
37
+ canRename?: boolean;
38
+ canHide?: boolean;
39
+ previewMode?: 'live' | 'snapshot' | 'none';
40
+ previewUrl?: string;
41
+ }
42
+
43
+ // Closed-ish enum — accept arbitrary strings so the backend can introduce new
44
+ // surface names without a client release blocking it.
45
+ export type AcsSurface = 'home' | 'library' | (string & {});
46
+
47
+ // Closed enum on the backend side.
48
+ export type AcsEvent = 'clicked' | 'dismissed' | 'acted' | 'ignored';
49
+
50
+ export type AcsDeleteReason =
51
+ | 'user_long_press_delete'
52
+ | 'user_swipe_delete'
53
+ | 'user_bulk_delete'
54
+ | (string & {});
55
+
56
+ export type AcsPatchReason =
57
+ | 'user_long_press_hide'
58
+ | 'user_long_press_unhide'
59
+ | 'user_long_press_rename'
60
+ | (string & {});
61
+
62
+ async function authedFetch(path: string, init?: RequestInit): Promise<Response> {
63
+ const token = getToken();
64
+ try {
65
+ const res = await fetch(`${getCoreApiUrl()}${path}`, {
66
+ ...init,
67
+ headers: {
68
+ 'Content-Type': 'application/json',
69
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
70
+ ...(init?.headers || {}),
71
+ },
72
+ });
73
+ // Any response (even 4xx/5xx) proves the server is reachable.
74
+ reportAcsServerUp();
75
+ return res;
76
+ } catch (e) {
77
+ // Thrown = network error / timeout / DNS / TLS → server unreachable.
78
+ reportAcsServerDown();
79
+ throw e;
80
+ }
81
+ }
82
+
83
+ /**
84
+ * List artifacts surfaced for the given surface name. Returns [] on error
85
+ * (network, auth, or empty result) — callers shouldn't have to distinguish.
86
+ * Use surface="home" for the carousel.
87
+ */
88
+ export async function listSurface(
89
+ surface: AcsSurface,
90
+ context?: Record<string, any>,
91
+ ): Promise<AcsArtifact[]> {
92
+ try {
93
+ const res = await authedFetch('/api/acs/surface', {
94
+ method: 'POST',
95
+ body: JSON.stringify({ surface, ...(context ? { context } : {}) }),
96
+ });
97
+ if (!res.ok) {
98
+ console.warn(`[acs] /surface ${surface} returned ${res.status}`);
99
+ return [];
100
+ }
101
+ const data = await res.json();
102
+ return Array.isArray(data?.items) ? (data.items as AcsArtifact[]) : [];
103
+ } catch (e: any) {
104
+ console.warn('[acs] /surface failed:', e?.message || e);
105
+ return [];
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Full library — every artifact across all lifecycle stages. Used by a
111
+ * Library screen's Yours + Hidden sections.
112
+ */
113
+ export async function listMadeForYou(): Promise<AcsArtifact[]> {
114
+ try {
115
+ const res = await authedFetch('/api/acs/made-for-you', { method: 'GET' });
116
+ if (!res.ok) {
117
+ console.warn(`[acs] /made-for-you returned ${res.status}`);
118
+ return [];
119
+ }
120
+ const data = await res.json();
121
+ return Array.isArray(data?.artifacts) ? (data.artifacts as AcsArtifact[]) : [];
122
+ } catch (e: any) {
123
+ console.warn('[acs] /made-for-you failed:', e?.message || e);
124
+ return [];
125
+ }
126
+ }
127
+
128
+ export interface DeleteResult {
129
+ ok: boolean;
130
+ /** True when the server already had this artifact retired. Treat as success. */
131
+ already?: boolean;
132
+ /** True for HTTP 404 — artifact gone or never owned by caller. Treat as success
133
+ * (remove from local state) per the backend's contract: no existence leak. */
134
+ notFound?: boolean;
135
+ /** HTTP status returned by the server. */
136
+ status: number;
137
+ error?: string;
138
+ }
139
+
140
+ /**
141
+ * Retire a user widget. Per backend contract:
142
+ * 200 ok → success
143
+ * 200 ok + already:true → idempotent, treat as success
144
+ * 404 → already gone or not owned, treat as success
145
+ * 5xx / network → real failure, caller should rollback optimistic remove
146
+ */
147
+ export async function deleteArtifact(
148
+ artifactId: string,
149
+ reason: AcsDeleteReason = 'user_long_press_delete',
150
+ ): Promise<DeleteResult> {
151
+ try {
152
+ const res = await authedFetch(
153
+ `/api/acs/artifacts/${encodeURIComponent(artifactId)}`,
154
+ { method: 'DELETE', body: JSON.stringify({ reason }) },
155
+ );
156
+ const data = await res.json().catch(() => ({} as any));
157
+ if (res.ok) {
158
+ return { ok: true, already: !!data?.already, status: res.status };
159
+ }
160
+ if (res.status === 404) {
161
+ return { ok: true, notFound: true, status: 404 };
162
+ }
163
+ return { ok: false, status: res.status, error: data?.error || `HTTP ${res.status}` };
164
+ } catch (e: any) {
165
+ return { ok: false, status: 0, error: e?.message || 'network error' };
166
+ }
167
+ }
168
+
169
+ export interface PatchResult {
170
+ ok: boolean;
171
+ /** Updated tile when the server returns the new state. */
172
+ artifact?: AcsArtifact;
173
+ already?: boolean;
174
+ notFound?: boolean;
175
+ status: number;
176
+ error?: string;
177
+ }
178
+
179
+ /**
180
+ * Update an artifact's visibility (hide/unhide) and/or name (rename). One
181
+ * call covers both because backend offers a single PATCH endpoint with
182
+ * optional fields. At least one of visibility/name must be provided.
183
+ *
184
+ * Per backend contract (mirrors DELETE semantics):
185
+ * 200 ok → success
186
+ * 200 ok + already:true → idempotent no-op, treat as success
187
+ * 404 → not found / not owned, treat as success
188
+ * 400 / 403 → real failure, surface to user
189
+ * 5xx / network → real failure, caller should rollback
190
+ */
191
+ export async function patchArtifact(
192
+ artifactId: string,
193
+ body: { visibility?: 'pinned' | 'hidden'; name?: string },
194
+ reason: AcsPatchReason,
195
+ ): Promise<PatchResult> {
196
+ try {
197
+ const res = await authedFetch(
198
+ `/api/acs/artifacts/${encodeURIComponent(artifactId)}`,
199
+ { method: 'PATCH', body: JSON.stringify({ ...body, reason }) },
200
+ );
201
+ const data = await res.json().catch(() => ({} as any));
202
+ if (res.ok) {
203
+ return {
204
+ ok: true,
205
+ already: !!data?.already,
206
+ artifact: data?.artifact || undefined,
207
+ status: res.status,
208
+ };
209
+ }
210
+ if (res.status === 404) {
211
+ return { ok: true, notFound: true, status: 404 };
212
+ }
213
+ return {
214
+ ok: false,
215
+ status: res.status,
216
+ error: data?.error || `HTTP ${res.status}`,
217
+ };
218
+ } catch (e: any) {
219
+ return { ok: false, status: 0, error: e?.message || 'network error' };
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Persist a new pin order. Body lists currently-pinned artifact IDs in the
225
+ * new visual order; server validates ownership + pinned state of each ID.
226
+ *
227
+ * Returns { ok: false, error } on failure. 400 means the array contained
228
+ * an id that's not owned or not pinned — caller should refetch /surface
229
+ * and re-render rather than fight the server.
230
+ */
231
+ export async function setOrder(pinned: string[]): Promise<{ ok: boolean; status: number; error?: string }> {
232
+ try {
233
+ const res = await authedFetch('/api/acs/order', {
234
+ method: 'PUT',
235
+ body: JSON.stringify({ pinned }),
236
+ });
237
+ if (res.ok) return { ok: true, status: res.status };
238
+ const data = await res.json().catch(() => ({} as any));
239
+ return { ok: false, status: res.status, error: data?.error || `HTTP ${res.status}` };
240
+ } catch (e: any) {
241
+ return { ok: false, status: 0, error: e?.message || 'network error' };
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Record a usage event. Fire-and-forget — never throws, never blocks the UI.
247
+ * Use surface to tell the backend which surface the event came from
248
+ * (e.g. 'home' for the carousel).
249
+ */
250
+ export async function recordEvent(
251
+ artifactId: string,
252
+ event: AcsEvent,
253
+ surface?: AcsSurface,
254
+ ): Promise<void> {
255
+ try {
256
+ await authedFetch('/api/acs/event', {
257
+ method: 'POST',
258
+ body: JSON.stringify({ artifactId, event, ...(surface ? { surface } : {}) }),
259
+ });
260
+ } catch {
261
+ // intentional swallow — usage tracking failures shouldn't surface to the user
262
+ }
263
+ }
264
+
265
+ // ─── Home Actions (top-actions feed) ────────────────────────────────────
266
+ // Server-managed recommended-actions feed for the home "What's on your mind?"
267
+ // screen. The card universe is open + agent-authored; the only fixed contract
268
+ // is this wire shape + the leaf dispatch (send_message | open_widget).
269
+ // Backend: GET /api/acs/home-actions, PUT /order, PATCH /:id,
270
+ // WS topic acs.home-actions.
271
+
272
+ export type HomeActionLeaf =
273
+ | { type: 'send_message'; prompt: string; skillSlug?: string }
274
+ | { type: 'open_widget'; artifactId: string }
275
+ | { type: string; [k: string]: any }; // forward-compat: open_url/run_tool/…
276
+
277
+ export interface HomeAction {
278
+ actionId: string;
279
+ pinOrder?: number;
280
+ title: string;
281
+ subtitle?: string;
282
+ icon?: string;
283
+ action: HomeActionLeaf;
284
+ source?: 'curated' | 'pinned_by_user' | 'skill_authored' | (string & {});
285
+ canDismiss?: boolean;
286
+ canPin?: boolean;
287
+ pinned?: boolean;
288
+ }
289
+
290
+ /** List the home top-actions feed. Returns [] on any error. */
291
+ export async function listHomeActions(): Promise<HomeAction[]> {
292
+ try {
293
+ const res = await authedFetch('/api/acs/home-actions', { method: 'GET' });
294
+ if (!res.ok) {
295
+ console.warn(`[acs] /home-actions returned ${res.status}`);
296
+ return [];
297
+ }
298
+ const data = await res.json();
299
+ return Array.isArray(data?.actions) ? (data.actions as HomeAction[]) : [];
300
+ } catch (e: any) {
301
+ console.warn('[acs] /home-actions failed:', e?.message || e);
302
+ return [];
303
+ }
304
+ }
305
+
306
+ export type HomeActionReason =
307
+ | 'user_pin' | 'user_unpin' | 'user_dismiss' | 'user_drag_reorder'
308
+ | (string & {});
309
+
310
+ /** Pin/unpin or dismiss a card. 200 already / 404 both count as success. */
311
+ export async function patchHomeAction(
312
+ actionId: string,
313
+ body: { pinned?: boolean; dismissed?: boolean },
314
+ reason: HomeActionReason,
315
+ ): Promise<{ ok: boolean; action?: HomeAction; status: number; error?: string }> {
316
+ try {
317
+ const res = await authedFetch(
318
+ `/api/acs/home-actions/${encodeURIComponent(actionId)}`,
319
+ { method: 'PATCH', body: JSON.stringify({ ...body, reason }) },
320
+ );
321
+ const data = await res.json().catch(() => ({} as any));
322
+ if (res.ok) return { ok: true, action: data?.action || undefined, status: res.status };
323
+ if (res.status === 404) return { ok: true, status: 404 };
324
+ return { ok: false, status: res.status, error: data?.error || `HTTP ${res.status}` };
325
+ } catch (e: any) {
326
+ return { ok: false, status: 0, error: e?.message || 'network error' };
327
+ }
328
+ }
329
+
330
+ /** Persist a new card order. Optimistic; caller rolls back on failure. */
331
+ export async function setHomeActionOrder(
332
+ order: string[],
333
+ ): Promise<{ ok: boolean; status: number; error?: string }> {
334
+ try {
335
+ const res = await authedFetch('/api/acs/home-actions/order', {
336
+ method: 'PUT',
337
+ body: JSON.stringify({ order }),
338
+ });
339
+ if (res.ok) return { ok: true, status: res.status };
340
+ const data = await res.json().catch(() => ({} as any));
341
+ return { ok: false, status: res.status, error: data?.error || `HTTP ${res.status}` };
342
+ } catch (e: any) {
343
+ return { ok: false, status: 0, error: e?.message || 'network error' };
344
+ }
345
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * The home "What's on your mind?" top-actions feed. Server-managed: the list,
3
+ * its order, pin and dismiss state all live on the backend (ACS home-actions
4
+ * API). This store is a thin local mirror with optimistic mutations + rollback
5
+ * — same discipline as widgetsStore.
6
+ *
7
+ * The cards themselves are agent-authored and open-ended; clients only know
8
+ * the wire shape + the fixed leaf dispatch (send_message | open_widget), so
9
+ * this store stays dumb on purpose.
10
+ */
11
+
12
+ import { create } from 'zustand';
13
+ import {
14
+ listHomeActions,
15
+ patchHomeAction,
16
+ setHomeActionOrder,
17
+ type HomeAction,
18
+ } from './api';
19
+ import { getAcsCacheStorage } from './providers';
20
+
21
+ // Persisted (when the host wires setAcsCacheStorage) so the home cards render
22
+ // instantly on cold start and survive a dead/empty server response. The
23
+ // server is still source of truth — when a non-empty result arrives we
24
+ // overwrite the cache; an empty/failed fetch leaves the last good cards on
25
+ // screen.
26
+ const CACHE_KEY = 'ateam.homeActions.v1';
27
+
28
+ function persist(actions: HomeAction[]): void {
29
+ // Fire-and-forget; never block the UI on disk.
30
+ getAcsCacheStorage()?.setItem(CACHE_KEY, JSON.stringify(actions)).catch(() => {});
31
+ }
32
+
33
+ interface HomeActionsState {
34
+ actions: HomeAction[];
35
+ hydrated: boolean;
36
+ loading: boolean;
37
+
38
+ hydrate: () => Promise<void>;
39
+ refresh: () => Promise<void>;
40
+ clear: () => void;
41
+
42
+ /** Optimistic dismiss — removes locally, rolls back on real failure. */
43
+ dismiss: (actionId: string) => Promise<{ ok: boolean; error?: string }>;
44
+ /** Optimistic pin/unpin. */
45
+ setPinned: (actionId: string, pinned: boolean) => Promise<{ ok: boolean; error?: string }>;
46
+ /** Optimistic reorder — `orderedIds` is the new visual order. */
47
+ reorder: (orderedIds: string[]) => Promise<{ ok: boolean; error?: string }>;
48
+
49
+ // ─── WS-driven (local mutation only, no API call) ───
50
+ applyServerUpsert: (action: HomeAction) => void;
51
+ applyServerRemove: (actionId: string) => void;
52
+ applyServerOrder: (orderedIds: string[]) => void;
53
+ }
54
+
55
+ function sortByPin(a: HomeAction, b: HomeAction): number {
56
+ // pinned first, then pinOrder asc, then stable.
57
+ if (!!a.pinned !== !!b.pinned) return a.pinned ? -1 : 1;
58
+ const ao = typeof a.pinOrder === 'number' ? a.pinOrder : Number.MAX_SAFE_INTEGER;
59
+ const bo = typeof b.pinOrder === 'number' ? b.pinOrder : Number.MAX_SAFE_INTEGER;
60
+ return ao - bo;
61
+ }
62
+
63
+ export const useHomeActionsStore = create<HomeActionsState>((set, get) => ({
64
+ actions: [],
65
+ hydrated: false,
66
+ loading: false,
67
+
68
+ hydrate: async () => {
69
+ if (get().hydrated || get().loading) return;
70
+ // 1. Paint cached cards immediately (survives cold start / offline).
71
+ try {
72
+ const cached = await getAcsCacheStorage()?.getItem(CACHE_KEY);
73
+ if (cached) {
74
+ const parsed = JSON.parse(cached) as HomeAction[];
75
+ if (Array.isArray(parsed) && parsed.length > 0) {
76
+ // Preserve the order we cached (which was the server's priority
77
+ // order) — home layouts may split positionally, so re-sorting
78
+ // would reshuffle the hero cards.
79
+ set({ actions: parsed, hydrated: true });
80
+ }
81
+ }
82
+ } catch { /* corrupt cache — ignore, server refresh will fix it */ }
83
+ // 2. Then refresh from the server.
84
+ await get().refresh();
85
+ },
86
+
87
+ refresh: async () => {
88
+ if (get().loading) return;
89
+ set({ loading: true });
90
+ const actions = await listHomeActions();
91
+ // Only overwrite when the server actually returned cards. An empty result
92
+ // (dead API, transient 5xx, or genuinely-empty) must NOT wipe the cards
93
+ // already on screen — keep the last good set persisted.
94
+ if (actions.length > 0) {
95
+ // Server delivers cards already priority-ordered — do NOT re-sort
96
+ // (home layouts may be positional, e.g. a top-3 / rest split).
97
+ set({ actions, loading: false, hydrated: true });
98
+ persist(actions);
99
+ } else {
100
+ set({ loading: false, hydrated: true });
101
+ }
102
+ },
103
+
104
+ // clear() wipes in-memory state on sign-out AND drops the disk cache — a
105
+ // different user must not see the prior user's cards.
106
+ clear: () => {
107
+ set({ actions: [], hydrated: false, loading: false });
108
+ getAcsCacheStorage()?.setItem(CACHE_KEY, '[]').catch(() => {});
109
+ },
110
+
111
+ dismiss: async (actionId) => {
112
+ const before = get().actions;
113
+ const next = before.filter(a => a.actionId !== actionId);
114
+ set({ actions: next });
115
+ persist(next);
116
+ const r = await patchHomeAction(actionId, { dismissed: true }, 'user_dismiss');
117
+ if (r.ok) return { ok: true };
118
+ set({ actions: before });
119
+ persist(before);
120
+ return { ok: false, error: r.error || `HTTP ${r.status}` };
121
+ },
122
+
123
+ setPinned: async (actionId, pinned) => {
124
+ const before = get().actions;
125
+ const next = before.map(a => (a.actionId === actionId ? { ...a, pinned } : a)).sort(sortByPin);
126
+ set({ actions: next });
127
+ persist(next);
128
+ const r = await patchHomeAction(actionId, { pinned }, pinned ? 'user_pin' : 'user_unpin');
129
+ if (r.ok) {
130
+ if (r.action) {
131
+ const merged = get().actions.map(a => (a.actionId === actionId ? { ...a, ...r.action! } : a)).sort(sortByPin);
132
+ set({ actions: merged });
133
+ persist(merged);
134
+ }
135
+ return { ok: true };
136
+ }
137
+ set({ actions: before });
138
+ persist(before);
139
+ return { ok: false, error: r.error || `HTTP ${r.status}` };
140
+ },
141
+
142
+ reorder: async (orderedIds) => {
143
+ const before = get().actions;
144
+ const idx = new Map(orderedIds.map((id, i) => [id, i] as const));
145
+ const next = before.map(a => {
146
+ const o = idx.get(a.actionId);
147
+ return o == null ? a : { ...a, pinOrder: o };
148
+ }).sort(sortByPin);
149
+ set({ actions: next });
150
+ persist(next);
151
+ const r = await setHomeActionOrder(orderedIds);
152
+ if (r.ok) return { ok: true };
153
+ set({ actions: before });
154
+ persist(before);
155
+ return { ok: false, error: r.error || `HTTP ${r.status}` };
156
+ },
157
+
158
+ applyServerUpsert: (action) => {
159
+ const rest = get().actions.filter(a => a.actionId !== action.actionId);
160
+ const next = [...rest, action].sort(sortByPin);
161
+ set({ actions: next });
162
+ persist(next);
163
+ },
164
+ applyServerRemove: (actionId) => {
165
+ const next = get().actions.filter(a => a.actionId !== actionId);
166
+ set({ actions: next });
167
+ persist(next);
168
+ },
169
+ applyServerOrder: (orderedIds) => {
170
+ const idx = new Map(orderedIds.map((id, i) => [id, i] as const));
171
+ const next = get().actions.map(a => {
172
+ const o = idx.get(a.actionId);
173
+ return o == null ? a : { ...a, pinOrder: o };
174
+ }).sort(sortByPin);
175
+ set({ actions: next });
176
+ persist(next);
177
+ },
178
+ }));
@@ -0,0 +1,44 @@
1
+ /**
2
+ * ACS host DI seams. Both optional — the ACS layer works without them:
3
+ * no reachability reporting, and the home-actions cache lives in memory only.
4
+ */
5
+
6
+ /** Host hook fired on every ACS fetch outcome — a response (any status)
7
+ * proves the server is reachable; a thrown fetch means it isn't. The
8
+ * reference app wires this to its connectivity banner store. */
9
+ export interface AcsReachabilityReporter {
10
+ up: () => void;
11
+ down: () => void;
12
+ }
13
+
14
+ let _reachability: AcsReachabilityReporter | null = null;
15
+
16
+ export function setAcsReachabilityReporter(r: AcsReachabilityReporter | null): void {
17
+ _reachability = r;
18
+ }
19
+
20
+ export function reportAcsServerUp(): void {
21
+ try { _reachability?.up(); } catch {}
22
+ }
23
+
24
+ export function reportAcsServerDown(): void {
25
+ try { _reachability?.down(); } catch {}
26
+ }
27
+
28
+ /** Minimal async KV used to persist the home-actions feed across cold starts
29
+ * (AsyncStorage-shaped). @ateam-ai-mobile/expo wires AsyncStorage in
30
+ * setupExpoProviders(); without a provider the cache is a no-op. */
31
+ export interface AcsKvStorage {
32
+ getItem(key: string): Promise<string | null>;
33
+ setItem(key: string, value: string): Promise<void>;
34
+ }
35
+
36
+ let _kv: AcsKvStorage | null = null;
37
+
38
+ export function setAcsCacheStorage(kv: AcsKvStorage | null): void {
39
+ _kv = kv;
40
+ }
41
+
42
+ export function getAcsCacheStorage(): AcsKvStorage | null {
43
+ return _kv;
44
+ }