diff --git a/app/lib/backend/http/api/facts.dart b/app/lib/backend/http/api/facts.dart index 113960fcdd..30d87322bb 100644 --- a/app/lib/backend/http/api/facts.dart +++ b/app/lib/backend/http/api/facts.dart @@ -20,9 +20,9 @@ Future createFact(String content, FactCategory category) async { return response.statusCode == 200; } -Future> getFacts({int limit = 5000, int offset = 0}) async { +Future> getFacts({int limit = 100, int offset = 0}) async { var response = await makeApiCall( - url: '${Env.apiBaseUrl}v1/facts', // limit=$limit&offset=$offset + url: '${Env.apiBaseUrl}v2/facts?limit=${limit}&offset=${offset}', headers: {}, method: 'GET', body: '', diff --git a/app/lib/pages/home/page.dart b/app/lib/pages/home/page.dart index e8839fc7e3..441593c355 100644 --- a/app/lib/pages/home/page.dart +++ b/app/lib/pages/home/page.dart @@ -7,12 +7,14 @@ import 'package:friend_private/backend/http/api/users.dart'; import 'package:friend_private/backend/preferences.dart'; import 'package:friend_private/backend/schema/geolocation.dart'; import 'package:friend_private/main.dart'; +import 'package:friend_private/pages/apps/page.dart'; import 'package:friend_private/pages/chat/page.dart'; +import 'package:friend_private/pages/facts/page.dart'; import 'package:friend_private/pages/home/widgets/chat_apps_dropdown_widget.dart'; import 'package:friend_private/pages/home/widgets/speech_language_sheet.dart'; import 'package:friend_private/pages/memories/page.dart'; -import 'package:friend_private/pages/apps/page.dart'; import 'package:friend_private/pages/settings/page.dart'; +import 'package:friend_private/providers/app_provider.dart'; import 'package:friend_private/providers/capture_provider.dart'; import 'package:friend_private/providers/connectivity_provider.dart'; import 'package:friend_private/providers/device_provider.dart'; @@ -20,7 +22,6 @@ import 'package:friend_private/providers/home_provider.dart'; import 'package:friend_private/providers/memory_provider.dart' as mp; import 'package:friend_private/providers/memory_provider.dart'; import 'package:friend_private/providers/message_provider.dart'; -import 'package:friend_private/providers/app_provider.dart'; import 'package:friend_private/services/notifications.dart'; import 'package:friend_private/utils/analytics/analytics_manager.dart'; import 'package:friend_private/utils/analytics/mixpanel.dart'; @@ -116,7 +117,10 @@ class _HomePageState extends State with WidgetsBindingObserver, Ticker } ///Screens with respect to subpage - final Map screensWithRespectToPath = {'/settings': const SettingsPage()}; + final Map screensWithRespectToPath = { + '/settings': const SettingsPage(), + '/facts': const FactsPage(), + }; bool? previousConnection; void _onReceiveTaskData(dynamic data) async { diff --git a/app/lib/services/notifications.dart b/app/lib/services/notifications.dart index 5f1bb468b7..9e659d8b52 100644 --- a/app/lib/services/notifications.dart +++ b/app/lib/services/notifications.dart @@ -265,8 +265,8 @@ class NotificationUtil { // Always ensure that all plugins was initialized WidgetsFlutterBinding.ensureInitialized(); - if (payload.containsKey('navigateTo')) { - SharedPreferencesUtil().subPageToShowFromNotification = payload['navigateTo'] ?? ''; + if (payload.containsKey('navigateTo') || payload.containsKey('navigate_to')) { + SharedPreferencesUtil().subPageToShowFromNotification = payload['navigateTo'] ?? payload['navigate_to'] ?? ''; } // Notification page diff --git a/backend/database/apps.py b/backend/database/apps.py index 51a010691e..62e52b17e6 100644 --- a/backend/database/apps.py +++ b/backend/database/apps.py @@ -225,6 +225,7 @@ def record_app_usage( 'timestamp': datetime.now(timezone.utc) if timestamp is None else timestamp, 'type': usage_type, } + db.collection('plugins').document(app_id).collection('usage_history').document(memory_id or message_id).set(data) return data diff --git a/backend/database/facts.py b/backend/database/facts.py index 3d876a639f..8957297197 100644 --- a/backend/database/facts.py +++ b/backend/database/facts.py @@ -11,14 +11,24 @@ def get_facts(uid: str, limit: int = 100, offset: int = 0): print('get_facts', uid, limit, offset) facts_ref = db.collection('users').document(uid).collection('facts') facts_ref = ( - facts_ref.order_by('created_at', direction=firestore.Query.DESCENDING) + facts_ref.order_by('scoring', direction=firestore.Query.DESCENDING) + .order_by('created_at', direction=firestore.Query.DESCENDING) + .where(filter=FieldFilter('user_review', '!=', False)) .where(filter=FieldFilter('deleted', '==', False)) - # .where(filter=FieldFilter('user_review', '!=', False)) ) facts_ref = facts_ref.limit(limit).offset(offset) - facts = [doc.to_dict() for doc in facts_ref.stream()] - result = [fact for fact in facts if fact['user_review'] is not False] - return result + return [doc.to_dict() for doc in facts_ref.stream()] + +def get_non_filtered_facts(uid: str, limit: int = 100, offset: int = 0): + print('get_non_filtered_facts', uid, limit, offset) + facts_ref = db.collection('users').document(uid).collection('facts') + facts_ref = ( + facts_ref.order_by('scoring', direction=firestore.Query.DESCENDING) + .order_by('created_at', direction=firestore.Query.DESCENDING) + .where(filter=FieldFilter('deleted', '==', False)) + ) + facts_ref = facts_ref.limit(limit).offset(offset) + return [doc.to_dict() for doc in facts_ref.stream()] def create_fact(uid: str, data: dict): diff --git a/backend/models/app.py b/backend/models/app.py index 60e2ae103b..3622c15361 100644 --- a/backend/models/app.py +++ b/backend/models/app.py @@ -107,6 +107,7 @@ def get_image_url(self) -> str: class UsageHistoryType(str, Enum): memory_created_external_integration = 'memory_created_external_integration' + transcript_processed_external_integration = 'transcript_processed_external_integration' memory_created_prompt = 'memory_created_prompt' chat_message_sent = 'chat_message_sent' diff --git a/backend/models/facts.py b/backend/models/facts.py index f93157f7de..1098f5bbed 100644 --- a/backend/models/facts.py +++ b/backend/models/facts.py @@ -22,6 +22,15 @@ class FactCategory(str, Enum): other = "other" +CATEGORY_BOOSTS = {FactCategory.core.value: 1, + FactCategory.habits.value:10, + FactCategory.work.value:20, + FactCategory.skills.value:30, + FactCategory.lifestyle.value: 40, + FactCategory.hobbies.value: 40, + FactCategory.interests.value:40, + FactCategory.other.value: 50,} + class Fact(BaseModel): content: str = Field(description="The content of the fact") category: FactCategory = Field(description="The category of the fact", default=FactCategory.other) @@ -57,10 +66,21 @@ class FactDB(Fact): manually_added: bool = False edited: bool = False deleted: bool = False + scoring: Optional[str] = None + + @staticmethod + def calculate_score(fact: 'FactDB') -> 'FactDB': + cat_boost = (999 - CATEGORY_BOOSTS[fact.category.value]) if fact.category.value in CATEGORY_BOOSTS else 0 + + user_manual_added_boost = 1 + if fact.manually_added is False: + user_manual_added_boost = 0 + + return "{:02d}_{:02d}_{:010d}".format(user_manual_added_boost, cat_boost, int(fact.created_at.timestamp())) @staticmethod def from_fact(fact: Fact, uid: str, memory_id: str, memory_category: CategoryEnum) -> 'FactDB': - return FactDB( + fact_db = FactDB( id=document_id_from_seed(fact.content), uid=uid, content=fact.content, @@ -70,3 +90,5 @@ def from_fact(fact: Fact, uid: str, memory_id: str, memory_category: CategoryEnu memory_id=memory_id, memory_category=memory_category, ) + fact_db.scoring = FactDB.calculate_score(fact_db) + return fact_db diff --git a/backend/models/notification_message.py b/backend/models/notification_message.py index f075655b5d..bfe93a12ed 100644 --- a/backend/models/notification_message.py +++ b/backend/models/notification_message.py @@ -14,6 +14,7 @@ class NotificationMessage(BaseModel): type: str notification_type: str text: Optional[str] = "" + navigate_to: Optional[str] = None @staticmethod def get_message_as_dict( diff --git a/backend/models/plugin.py b/backend/models/plugin.py index 5eef3dda51..13db58825f 100644 --- a/backend/models/plugin.py +++ b/backend/models/plugin.py @@ -90,6 +90,7 @@ def get_image_url(self) -> str: class UsageHistoryType(str, Enum): memory_created_external_integration = 'memory_created_external_integration' + transcript_processed_external_integration = 'transcript_processed_external_integration' memory_created_prompt = 'memory_created_prompt' chat_message_sent = 'chat_message_sent' diff --git a/backend/routers/facts.py b/backend/routers/facts.py index ebd798518b..88193af834 100644 --- a/backend/routers/facts.py +++ b/backend/routers/facts.py @@ -18,7 +18,7 @@ def create_fact(fact: Fact, uid: str = Depends(auth.get_current_user_uid)): @router.get('/v1/facts', tags=['facts'], response_model=List[FactDB]) # filters -def get_facts(limit: int = 5000, offset: int = 0, uid: str = Depends(auth.get_current_user_uid)): +def get_facts_v1(limit: int = 5000, offset: int = 0, uid: str = Depends(auth.get_current_user_uid)): facts = facts_db.get_facts(uid, limit, offset) # facts = list(filter(lambda x: x['category'] == 'skills', facts)) # TODO: consider this "$name" part if really is an issue, when changing name or smth. @@ -45,6 +45,12 @@ def get_facts(limit: int = 5000, offset: int = 0, uid: str = Depends(auth.get_cu # return facts +@router.get('/v2/facts', tags=['facts'], response_model=List[FactDB]) +def get_facts(limit: int = 100, offset: int = 0, uid: str = Depends(auth.get_current_user_uid)): + facts = facts_db.get_facts(uid, limit, offset) + return facts + + @router.delete('/v1/facts/{fact_id}', tags=['facts']) def delete_fact(fact_id: str, uid: str = Depends(auth.get_current_user_uid)): facts_db.delete_fact(uid, fact_id) diff --git a/backend/routers/pusher.py b/backend/routers/pusher.py index 465e1fbc24..d1367fdf63 100644 --- a/backend/routers/pusher.py +++ b/backend/routers/pusher.py @@ -46,9 +46,10 @@ async def receive_audio_bytes(): # Transcript if header_type == 100: - segments = json.loads(bytes(data[4:]).decode("utf-8")) - print(f"transcript received {len(segments)}") - asyncio.run_coroutine_threadsafe(trigger_realtime_integrations(uid, segments), loop) + res = json.loads(bytes(data[4:]).decode("utf-8")) + segments = res.get('segments') + memory_id = res.get('memory_id') + asyncio.run_coroutine_threadsafe(trigger_realtime_integrations(uid, segments, memory_id), loop) asyncio.run_coroutine_threadsafe(realtime_transcript_webhook(uid, segments), loop) continue diff --git a/backend/routers/transcribe_v2.py b/backend/routers/transcribe_v2.py index 062285c1c6..4025779ba9 100644 --- a/backend/routers/transcribe_v2.py +++ b/backend/routers/transcribe_v2.py @@ -309,14 +309,18 @@ def create_pusher_task_handler(): # Transcript transcript_ws = None segment_buffers = [] + in_progress_memory_id = None - def transcript_send(segments): + def transcript_send(segments, memory_id): nonlocal segment_buffers + nonlocal in_progress_memory_id + in_progress_memory_id = memory_id segment_buffers.extend(segments) async def transcript_consume(): nonlocal websocket_active nonlocal segment_buffers + nonlocal in_progress_memory_id nonlocal transcript_ws nonlocal pusher_connected while websocket_active or len(segment_buffers) > 0: @@ -327,7 +331,7 @@ async def transcript_consume(): # 100|data data = bytearray() data.extend(struct.pack("I", 100)) - data.extend(bytes(json.dumps(segment_buffers), "utf-8")) + data.extend(bytes(json.dumps({"segments":segment_buffers,"memory_id":in_progress_memory_id}), "utf-8")) segment_buffers = [] # reset await transcript_ws.send(data) print(f"transcript sent {len(data)}", uid) @@ -404,11 +408,15 @@ async def close(code: int = 1000): pusher_connect, pusher_close, transcript_send, transcript_consume, audio_bytes_send, audio_bytes_consume = create_pusher_task_handler() + + current_memory_id = None + async def stream_transcript_process(): nonlocal websocket_active nonlocal realtime_segment_buffers nonlocal websocket nonlocal seconds_to_trim + nonlocal current_memory_id while websocket_active or len(realtime_segment_buffers) > 0: try: @@ -448,12 +456,14 @@ async def stream_transcript_process(): # Send to external trigger print(f"transcript send {transcript_send} {len(segments)}", uid) if transcript_send: - transcript_send(segments) + transcript_send(segments,current_memory_id) memory = _get_or_create_in_progress_memory(segments) # can trigger race condition? increase soniox utterance? + current_memory_id = memory.id memories_db.update_memory_segments(uid, memory.id, [s.dict() for s in memory.transcript_segments]) memories_db.update_memory_finished_at(uid, memory.id, finished_at) + # threading.Thread(target=process_segments, args=(uid, segments)).start() # restore when plugins work except Exception as e: print(f'Could not process transcript: error {e}', uid) diff --git a/backend/scripts/rag/facts.py b/backend/scripts/rag/facts.py index 5062d2756b..27ee3d2eb6 100644 --- a/backend/scripts/rag/facts.py +++ b/backend/scripts/rag/facts.py @@ -1,3 +1,5 @@ +import database.facts as facts_db +from utils.llm import new_facts_extractor, new_learnings_extractor import threading from typing import Tuple @@ -8,8 +10,6 @@ from models.facts import Fact, FactDB firebase_admin.initialize_app() -from utils.llm import new_facts_extractor, new_learnings_extractor -import database.facts as facts_db def get_facts_from_memories( @@ -85,5 +85,34 @@ def script_migrate_users(): [t.join() for t in chunk] +# migrate scoring for facts +def migration_fact_scoring_for_user(uid: str): + print('migration_fact_scoring_for_user', uid) + offset = 0 + while True: + facts_data = facts_db.get_non_filtered_facts(uid, limit=400, offset=offset) + facts = [FactDB(**d) for d in facts_data] + if not facts or len(facts) == 0: + break + + print('execute_for_user', uid, 'found facts', len(facts)) + for fact in facts: + fact.scoring = FactDB.calculate_score(fact) + facts_db.save_facts(uid, [fact.dict() for fact in facts]) + offset += len(facts) + +def script_migrate_fact_scoring_users(uids: [str]): + threads = [] + for uid in uids: + t = threading.Thread(target=migration_fact_scoring_for_user, args=(uid,)) + threads.append(t) + + chunk_size = 1 + chunks = [threads[i:i + chunk_size] for i in range(0, len(threads), chunk_size)] + for i, chunk in enumerate(chunks): + [t.start() for t in chunk] + [t.join() for t in chunk] + + if __name__ == '__main__': script_migrate_users() diff --git a/backend/utils/apps.py b/backend/utils/apps.py index c91f6aae87..8121d8ff9a 100644 --- a/backend/utils/apps.py +++ b/backend/utils/apps.py @@ -239,11 +239,13 @@ def get_app_money_made(app_id: str) -> dict[str, int | float]: type1 = len(list(filter(lambda x: x.type == UsageHistoryType.memory_created_external_integration, usage))) type2 = len(list(filter(lambda x: x.type == UsageHistoryType.memory_created_prompt, usage))) type3 = len(list(filter(lambda x: x.type == UsageHistoryType.chat_message_sent, usage))) + type4 = len(list(filter(lambda x: x.type == UsageHistoryType.transcript_processed_external_integration, usage))) # tbd based on current prod stats t1multiplier = 0.02 t2multiplier = 0.01 t3multiplier = 0.005 + t4multiplier = 0.00001 # This is for transcript processed triggered for every segment, so it should be very low money = { 'money': round((type1 * t1multiplier) + (type2 * t2multiplier) + (type3 * t3multiplier), 2), diff --git a/backend/utils/llm.py b/backend/utils/llm.py index c2ca49bca6..d6fad26b27 100644 --- a/backend/utils/llm.py +++ b/backend/utils/llm.py @@ -511,7 +511,7 @@ def new_facts_extractor( user_name, facts_str = get_prompt_facts(uid) content = TranscriptSegment.segments_as_string(segments, user_name=user_name) - if not content or len(content) < 100: # less than 20 words, probably nothing + if not content or len(content) < 25: # less than 5 words, probably nothing return [] # TODO: later, focus a lot on user said things, rn is hard because of speech profile accuracy # TODO: include negative facts too? Things the user doesn't like? diff --git a/backend/utils/memories/facts.py b/backend/utils/memories/facts.py index 090ad518bd..0e4373b34d 100644 --- a/backend/utils/memories/facts.py +++ b/backend/utils/memories/facts.py @@ -15,7 +15,7 @@ def get_prompt_facts(uid: str) -> str: def get_prompt_data(uid: str) -> Tuple[str, List[Fact], List[Fact]]: # TODO: cache this - existing_facts = facts_db.get_facts(uid, limit=250) # TODO: what the fuck to do here? too much context for llm + existing_facts = facts_db.get_facts(uid, limit=100) user_made = [Fact(**fact) for fact in existing_facts if fact['manually_added']] # TODO: filter only reviewed True generated = [Fact(**fact) for fact in existing_facts if not fact['manually_added']] diff --git a/backend/utils/memories/process_memory.py b/backend/utils/memories/process_memory.py index 2ed3ac8158..8b3026b94b 100644 --- a/backend/utils/memories/process_memory.py +++ b/backend/utils/memories/process_memory.py @@ -19,6 +19,7 @@ from models.memory import * from models.task import Task, TaskStatus, TaskAction, TaskActionProvider from models.trend import Trend +from models.notification_message import NotificationMessage from utils.apps import get_available_apps from utils.llm import obtain_emotional_message, retrieve_metadata_fields_from_transcript from utils.llm import summarize_open_glass, get_transcript_structure, generate_embedding, \ @@ -128,8 +129,27 @@ def _extract_facts(uid: str, memory: Memory): for fact in new_facts: parsed_facts.append(FactDB.from_fact(fact, uid, memory.id, memory.structured.category)) print('_extract_facts:', fact.category.value.upper(), '|', fact.content) + if len(parsed_facts) == 0: + return + facts_db.save_facts(uid, [fact.dict() for fact in parsed_facts]) + # send notification + token = notification_db.get_token_only(uid) + if token and len(token) > 0: + send_new_facts_notification(token, parsed_facts) + +def send_new_facts_notification(token: str, facts: [FactDB]): + facts_str = ",".join([fact.content for fact in facts]) + message = f"New facts {facts_str}" + ai_message = NotificationMessage( + text=message, + type='text', + navigate_to="/facts", + ) + + send_notification(token, "Omi" + ' says', message, NotificationMessage.get_message_as_dict(ai_message)) + def _extract_trends(memory: Memory): extracted_items = trends_extractor(memory) diff --git a/backend/utils/plugins.py b/backend/utils/plugins.py index f1d6ba8ec9..e96b2b1f9a 100644 --- a/backend/utils/plugins.py +++ b/backend/utils/plugins.py @@ -223,11 +223,11 @@ def _single(app: App): return messages -async def trigger_realtime_integrations(uid: str, segments: list[dict]): +async def trigger_realtime_integrations(uid: str, segments: list[dict], memory_id: str | None): """REALTIME STREAMING""" # TODO: don't retrieve token before knowing if to notify token = notification_db.get_token_only(uid) - _trigger_realtime_integrations(uid, token, segments) + _trigger_realtime_integrations(uid, token, segments, memory_id) # proactive notification @@ -325,7 +325,7 @@ def _process_proactive_notification(uid: str, token: str, plugin: App, data): return message -def _trigger_realtime_integrations(uid: str, token: str, segments: List[dict]) -> dict: +def _trigger_realtime_integrations(uid: str, token: str, segments: List[dict], memory_id: str | None) -> dict: apps: List[App] = get_available_apps(uid) filtered_apps = [ app for app in apps if @@ -354,6 +354,9 @@ def _single(app: App): response.text[:100]) return + if (app.uid is None or app.uid != uid) and memory_id is not None: + record_app_usage(uid, app.id, UsageHistoryType.transcript_processed_external_integration, memory_id=memory_id) + response_data = response.json() if not response_data: return diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 67e0d0e254..ba7a3cc535 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -19,6 +19,7 @@ "@radix-ui/react-tabs": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.4", "@radix-ui/react-visually-hidden": "^1.1.0", + "@types/lodash": "^4.17.13", "algoliasearch": "^5.2.5", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", @@ -26,6 +27,7 @@ "gleap": "^13.9.2", "iconoir-react": "^7.8.0", "instantsearch.css": "^8.5.0", + "lodash": "^4.17.21", "lucide-react": "^0.438.0", "markdown-to-jsx": "^7.5.0", "moment": "^2.30.1", @@ -2152,6 +2154,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/lodash": { + "version": "4.17.13", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.13.tgz", + "integrity": "sha512-lfx+dftrEZcdBPczf9d0Qv0x+j/rfNCMuC6OcfXmO8gkfeNAY88PgKUbvG56whcN23gc27yenwF6oJZXGFpYxg==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.16.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.1.tgz", @@ -5369,6 +5377,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, "node_modules/lodash.castarray": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 550dcf8709..b248c13e45 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,6 +22,7 @@ "@radix-ui/react-tabs": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.4", "@radix-ui/react-visually-hidden": "^1.1.0", + "@types/lodash": "^4.17.13", "algoliasearch": "^5.2.5", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", @@ -29,6 +30,7 @@ "gleap": "^13.9.2", "iconoir-react": "^7.8.0", "instantsearch.css": "^8.5.0", + "lodash": "^4.17.21", "lucide-react": "^0.438.0", "markdown-to-jsx": "^7.5.0", "moment": "^2.30.1", diff --git a/frontend/src/app/apps/[id]/page.tsx b/frontend/src/app/apps/[id]/page.tsx index c518918e3d..4438ae7a14 100644 --- a/frontend/src/app/apps/[id]/page.tsx +++ b/frontend/src/app/apps/[id]/page.tsx @@ -9,6 +9,7 @@ import { Calendar, User, FolderOpen, Puzzle } from 'lucide-react'; import { Metadata, ResolvingMetadata } from 'next'; import { ProductBanner } from '@/src/app/components/product-banner'; import { getAppById, getAppsByCategory } from '@/src/lib/api/apps'; +import envConfig from '@/src/constants/envConfig'; type Props = { params: { id: string }; @@ -28,21 +29,26 @@ export async function generateMetadata( } const categoryName = formatCategoryName(plugin.category); - const canonicalUrl = `https://omi.me/apps/${plugin.id}`; - const appStoreUrl = 'https://apps.apple.com/us/app/friend-ai-wearable/id6502156163'; - const playStoreUrl = 'https://play.google.com/store/apps/details?id=com.friend.ios'; + const canonicalUrl = `${envConfig.WEB_URL}/apps/${plugin.id}`; return { title: `${plugin.name} - ${categoryName} App | Omi`, description: `${plugin.description} Available on Omi, the AI-powered wearable platform.`, - metadataBase: new URL('https://omi.me'), + metadataBase: new URL(envConfig.WEB_URL), alternates: { canonical: canonicalUrl, }, openGraph: { title: `${plugin.name} - ${categoryName} App`, description: plugin.description, - images: [plugin.image], + images: [ + { + url: plugin.image, + width: 1200, + height: 630, + alt: `${plugin.name} App for Omi`, + }, + ], url: canonicalUrl, type: 'website', siteName: 'Omi', @@ -52,8 +58,8 @@ export async function generateMetadata( title: `${plugin.name} - ${categoryName} App`, description: plugin.description, images: [plugin.image], - creator: '@omi', - site: '@omi', + creator: '@omiHQ', + site: '@omiHQ', }, other: { 'application-name': 'Omi', @@ -65,7 +71,7 @@ export async function generateMetadata( // Add a separate function to handle JSON-LD export function generateStructuredData(plugin: Plugin, categoryName: string) { - const canonicalUrl = `https://omi.me/apps/${plugin.id}`; + const canonicalUrl = `${envConfig.WEB_URL}/apps/${plugin.id}`; const appStoreUrl = 'https://apps.apple.com/us/app/friend-ai-wearable/id6502156163'; const playStoreUrl = 'https://play.google.com/store/apps/details?id=com.friend.ios'; const productUrl = 'https://www.omi.me/products/friend-dev-kit-2'; @@ -257,7 +263,7 @@ export default async function PluginDetailView({ params }: { params: { id: strin {/* Store Buttons */} diff --git a/frontend/src/app/apps/components/plugin-card/compact.tsx b/frontend/src/app/apps/components/plugin-card/compact.tsx index 1279f5a5f9..96fb6be6e2 100644 --- a/frontend/src/app/apps/components/plugin-card/compact.tsx +++ b/frontend/src/app/apps/components/plugin-card/compact.tsx @@ -22,6 +22,10 @@ export function CompactPluginCard({ plugin, index }: CompactPluginCardProps) { {/* Index number */} {index} diff --git a/frontend/src/app/apps/components/plugin-card/featured.tsx b/frontend/src/app/apps/components/plugin-card/featured.tsx index 368dfb033b..2471fe4a43 100644 --- a/frontend/src/app/apps/components/plugin-card/featured.tsx +++ b/frontend/src/app/apps/components/plugin-card/featured.tsx @@ -22,6 +22,10 @@ export function FeaturedPluginCard({ plugin, hideStats }: FeaturedPluginCardProp {/* Image */}
diff --git a/frontend/src/app/apps/components/search/search-bar.tsx b/frontend/src/app/apps/components/search/search-bar.tsx new file mode 100644 index 0000000000..640b6ae513 --- /dev/null +++ b/frontend/src/app/apps/components/search/search-bar.tsx @@ -0,0 +1,98 @@ +'use client'; + +import { Search, X } from 'lucide-react'; +import { useCallback, useState, useEffect } from 'react'; +import { cn } from '@/src/lib/utils'; +import debounce from 'lodash/debounce'; + +interface SearchBarProps { + className?: string; +} + +export function SearchBar({ className }: SearchBarProps) { + const [searchQuery, setSearchQuery] = useState(''); + const [isFocused, setIsFocused] = useState(false); + + const handleSearch = useCallback((query: string) => { + setSearchQuery(query); + const searchContent = query.toLowerCase().trim(); + const cards = document.querySelectorAll('[data-plugin-card]'); + cards.forEach((card) => { + const content = card.getAttribute('data-search-content')?.toLowerCase() || ''; + const categories = card.getAttribute('data-categories')?.toLowerCase() || ''; + const capabilities = card.getAttribute('data-capabilities')?.toLowerCase() || ''; + if ( + searchContent === '' || + content.includes(searchContent) || + categories.includes(searchContent) || + capabilities.includes(searchContent) + ) { + card.classList.remove('search-hidden'); + } else { + card.classList.add('search-hidden'); + } + }); + + document.querySelectorAll('section').forEach((section) => { + const visibleCards = section.querySelectorAll( + '[data-plugin-card]:not(.search-hidden)', + ); + if (visibleCards.length === 0) { + section.classList.add('search-hidden'); + } else { + section.classList.remove('search-hidden'); + } + }); + }, []); + + const debouncedSearch = useCallback( + debounce((query: string) => handleSearch(query), 150), + [handleSearch], + ); + + const clearSearch = useCallback(() => { + setSearchQuery(''); + handleSearch(''); + }, [handleSearch]); + + useEffect(() => { + return () => { + debouncedSearch.cancel(); + }; + }, [debouncedSearch]); + + return ( +
+
+ + { + setSearchQuery(e.target.value); + debouncedSearch(e.target.value); + }} + onFocus={() => setIsFocused(true)} + onBlur={() => setIsFocused(false)} + placeholder="Search apps, categories, or capabilities..." + className="h-12 w-full rounded-full bg-[#1A1F2E] pl-11 pr-11 text-sm text-white placeholder-gray-400 outline-none ring-1 ring-white/5 transition-all hover:ring-white/10 focus:bg-[#242938] focus:ring-[#6C8EEF]/50" + /> + {searchQuery && ( + + )} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/app/apps/page.tsx b/frontend/src/app/apps/page.tsx index 8bf6a75ad0..7c985f13d0 100644 --- a/frontend/src/app/apps/page.tsx +++ b/frontend/src/app/apps/page.tsx @@ -9,6 +9,7 @@ import { } from './utils/metadata'; import { ProductBanner } from '../components/product-banner'; import { getApprovedApps } from '@/src/lib/api/apps'; +import envConfig from '@/src/constants/envConfig'; async function getAppsCount() { const plugins = await getApprovedApps(); @@ -19,27 +20,28 @@ export async function generateMetadata(): Promise { const appsCount = await getAppsCount(); const title = 'OMI Apps Marketplace - AI-Powered Apps for Your OMI Necklace'; const description = `Discover and install ${appsCount}+ AI-powered apps for your OMI Necklace. Browse apps across productivity, entertainment, health, and more. Transform your OMI experience with voice-controlled applications.`; - const baseMetadata = getBaseMetadata(title, description); return { - ...baseMetadata, + title, + description, + metadataBase: new URL(envConfig.WEB_URL), keywords: 'OMI apps, AI apps, voice control apps, wearable apps, productivity apps, health apps, entertainment apps', alternates: { - canonical: 'https://omi.me/apps', + canonical: `${envConfig.WEB_URL}/apps`, }, openGraph: { title, description, - url: 'https://omi.me/apps', + url: `${envConfig.WEB_URL}/apps`, siteName: 'OMI', images: [ { - url: '/omi-app.png', + url: `${envConfig.WEB_URL}/omi-app.png`, width: 1200, height: 630, alt: 'OMI Apps Marketplace', - } + }, ], locale: 'en_US', type: 'website', @@ -48,7 +50,7 @@ export async function generateMetadata(): Promise { card: 'summary_large_image', title, description, - images: ['/omi-app.png'], + images: [`${envConfig.WEB_URL}/omi-app.png`], creator: '@omiHQ', }, robots: { @@ -59,15 +61,13 @@ export async function generateMetadata(): Promise { follow: true, }, }, - verification: { - other: { - 'structured-data': JSON.stringify([ - generateCollectionPageSchema(title, description, 'https://omi.me/apps'), - generateProductSchema(), - generateOrganizationSchema(), - generateBreadcrumbSchema(), - ]), - }, + other: { + 'structured-data': JSON.stringify([ + generateCollectionPageSchema(), + generateProductSchema(), + generateOrganizationSchema(), + generateBreadcrumbSchema(), + ]), }, }; } diff --git a/frontend/src/app/apps/utils/metadata.ts b/frontend/src/app/apps/utils/metadata.ts index e74a402d3f..321949f49f 100644 --- a/frontend/src/app/apps/utils/metadata.ts +++ b/frontend/src/app/apps/utils/metadata.ts @@ -1,4 +1,5 @@ import { Metadata } from 'next'; +import envConfig from '@/src/constants/envConfig'; export interface CategoryMetadata { title: string; @@ -94,13 +95,13 @@ export function generateBreadcrumbSchema(category?: string) { '@type': 'ListItem', position: 1, name: 'Home', - item: 'https://omi.me', + item: envConfig.WEB_URL, }, { '@type': 'ListItem', position: 2, name: 'Apps', - item: 'https://omi.me/apps', + item: `${envConfig.WEB_URL}/apps`, }, ], }; @@ -110,7 +111,7 @@ export function generateBreadcrumbSchema(category?: string) { '@type': 'ListItem', position: 3, name: categoryMetadata[category]?.title || category, - item: `https://omi.me/apps/category/${category}`, + item: `${envConfig.WEB_URL}/apps/category/${category}`, }); } @@ -123,47 +124,36 @@ export function generateProductSchema() { '@type': 'Product', name: productInfo.name, description: productInfo.description, - brand: { - '@type': 'Brand', - name: 'OMI', - }, + image: `${envConfig.WEB_URL}/omi-app.png`, offers: { '@type': 'Offer', price: productInfo.price, priceCurrency: productInfo.currency, - availability: 'https://schema.org/InStock', url: productInfo.url, + availability: 'https://schema.org/InStock', + }, + brand: { + '@type': 'Brand', + name: 'OMI', }, - additionalProperty: [ - { - '@type': 'PropertyValue', - name: 'App Store', - value: appStoreInfo.ios, - }, - { - '@type': 'PropertyValue', - name: 'Play Store', - value: appStoreInfo.android, - }, - ], }; } export function generateCollectionPageSchema( title: string, description: string, - url: string, + canonicalUrl: string, ) { return { '@context': 'https://schema.org', '@type': 'CollectionPage', - name: title, - description: description, - url: url, + name: 'OMI Apps Marketplace', + description: 'Discover and install AI-powered apps for your OMI Necklace.', + url: `${envConfig.WEB_URL}/apps`, isPartOf: { '@type': 'WebSite', name: 'OMI Apps Marketplace', - url: 'https://omi.me/apps', + url: envConfig.WEB_URL, }, }; } @@ -173,8 +163,13 @@ export function generateOrganizationSchema() { '@context': 'https://schema.org', '@type': 'Organization', name: 'OMI', - url: 'https://omi.me', - sameAs: [appStoreInfo.ios, appStoreInfo.android], + url: envConfig.WEB_URL, + logo: `${envConfig.WEB_URL}/omi-app.png`, + sameAs: [ + 'https://twitter.com/omiHQ', + 'https://www.instagram.com/omi.me/', + 'https://www.linkedin.com/company/omi-me/', + ], }; } @@ -205,23 +200,10 @@ export function getBaseMetadata(title: string, description: string): Metadata { return { title, description, - metadataBase: new URL('https://omi.me'), - openGraph: { - title, - description, - type: 'website', - siteName: 'OMI Apps Marketplace', - }, - twitter: { - card: 'summary_large_image', - title, - description, - creator: '@omi', - site: '@omi', - }, + metadataBase: new URL(envConfig.WEB_URL), other: { - 'apple-itunes-app': `app-id=6502156163`, - 'google-play-app': `app-id=com.friend.ios`, + 'apple-itunes-app': `app-id=${appStoreInfo.ios.split('/id')[1]}`, + 'google-play-app': `app-id=${appStoreInfo.android.split('id=')[1]}`, }, }; } diff --git a/frontend/src/app/components/product-banner/index.tsx b/frontend/src/app/components/product-banner/index.tsx index 9c5b741d9d..1d260ca0c3 100644 --- a/frontend/src/app/components/product-banner/index.tsx +++ b/frontend/src/app/components/product-banner/index.tsx @@ -230,7 +230,11 @@ export function ProductBanner({ - + Memory diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 11f10bf7ee..4b845aacd2 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -29,3 +29,7 @@ animation: gradient-x 15s ease infinite; background-size: 400% 400%; } + +.search-hidden { + display: none !important; +} diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 8e42d3685c..0bdb2bd817 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -5,6 +5,7 @@ import AppHeader from '../components/shared/app-header'; import Footer from '../components/shared/footer'; import envConfig from '../constants/envConfig'; import { GleapInit } from '@/src/components/shared/gleap'; +import { GoogleAnalytics } from '@/src/components/shared/google-analytics'; const inter = Mulish({ subsets: ['latin'], @@ -36,6 +37,7 @@ export default function RootLayout({