diff --git a/examples/child_process_bridge/__init__.py b/examples/child_process_bridge/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/child_process_bridge/call_from_nodejs.py b/examples/child_process_bridge/call_from_nodejs.py new file mode 100644 index 00000000..115969b9 --- /dev/null +++ b/examples/child_process_bridge/call_from_nodejs.py @@ -0,0 +1,74 @@ +import asyncio +import inspect +import json +import sys + +from reddit_user_generate import generate_user_data + +async def handle_request(request): + try: + method = request['method'] + req_id = request['id'] + + # retrieve function call in python file + method_func = globals()[method] + + # check params + sig = inspect.signature(method_func) + param_count = len(sig.parameters) + + # call function with/without params + if param_count == 0: + result = await method_func() + else: + result = await method_func(request["params"]) + + return { + "id": req_id, + "result": result, + "error": None + } + except Exception as e: + return { + "id": req_id if 'id' in request else None, + "result": None, + "error": str(e) + } + +async def main(): + loop = asyncio.get_event_loop() + + # create async stdin reader + reader = asyncio.StreamReader() + protocol = asyncio.StreamReaderProtocol(reader) + await loop.connect_read_pipe(lambda: protocol, sys.stdin) + + # listening stdin: function call with params + while True: + line = await reader.readline() + if not line: + break + + try: + request = json.loads(line.decode()) + # create async task + task = asyncio.create_task(handle_request(request)) + + # callback with response + def send_response(fut): + response = fut.result() + json_response = json.dumps(response) + "\n" + sys.stdout.write(json_response) + sys.stdout.flush() + + task.add_done_callback(send_response) + + except json.JSONDecodeError: + error_response = json.dumps({ + "error": "Invalid JSON format" + }) + "\n" + sys.stdout.write(error_response) + sys.stdout.flush() + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/examples/child_process_bridge/reddit_user_generate.py b/examples/child_process_bridge/reddit_user_generate.py new file mode 100644 index 00000000..d07eb243 --- /dev/null +++ b/examples/child_process_bridge/reddit_user_generate.py @@ -0,0 +1,139 @@ +import asyncio +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import datetime +import json +import random + +from openai import OpenAI + +# Set your OpenAI API key +client = OpenAI(api_key='sk-**', base_url='') +model_type = '' + +def create_user_profile(config_params, config_prompts): + while True: + try: + agent_profile = {} + for key in config_params: + match key: + case "age": + agent_profile[key] = customize_random_age(config_params[key]["groups"], config_params[key]["ratios"]) + case "country": + agent_profile[key] = customize_random_country(config_params[key]["groups"], config_params[key]["ratios"]) + case "interested topics": + pass + case _: # Default case for unmatched keys, including default gender, mbti, profession + agent_profile[key] = customize_random_traits(config_params[key]["groups"], config_params[key]["ratios"]) + + topics = customize_randow_topics(config_params[key]["names"], config_params[key]["descs"], agent_profile) # create user interest topics + agent_profile['interested topics'] = topics + + profile = generate_user_profile(agent_profile, config_prompts) # create user profile + + return { **profile, **agent_profile } + except Exception as e: + print(f"Profile generation failed: {e}. Retrying...") + + +async def generate_user_data(config): + config_count=config["count"] + config_params=config["params"] + config_prompts=config["prompts"] + + loop = asyncio.get_event_loop() + + user_data = [] + start_time = datetime.now() + max_workers = 100 # Adjust according to your system capability + with ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = [loop.run_in_executor(executor, create_user_profile, config_params, config_prompts) for _ in range(config_count)] + results = await asyncio.gather(*futures) + + for i, profile in enumerate(results): + user_data.append(profile) + elapsed_time = datetime.now() - start_time + print(f"Generated {i + 1}/{config_count} user profiles. Time elapsed: " + f"{elapsed_time}") + + return user_data + +def customize_random_country(groups, ratios): + country = random.choices(groups, ratios)[0] + if country == "Other": + response = client.chat.completions.create( + model=model_type, + messages=[{ + "role": "system", + "content": "Select a real country name randomly, only country name is needed" # GPT might be right, Qwen need this complement + }]) + return response.choices[0].message.content.strip() + return country + +def customize_random_traits(groups, ratios): + return random.choices(groups, ratios)[0] + +def customize_random_age(groups, ratios): + group = random.choices(groups, ratios)[0] + if group == 'underage': + return random.randint(10, 17) + elif group == '18-29': + return random.randint(18, 29) + elif group == '30-49': + return random.randint(30, 49) + elif group == '50-64': + return random.randint(50, 64) + else: + return random.randint(65, 100) + +def customize_randow_topics(names, descs, traits): + topic_index_lst = customize_interested_topics(names, descs, traits) + return index_to_topics(topic_index_lst, names) + +def index_to_topics(index_lst, names): + topic_dict = {str(index): value for index, value in enumerate(names)} + result = [] + for index in index_lst: + topic = topic_dict[str(index)] + result.append(topic) + return result + +def customize_interested_topics(names, descs, traits): + prompt = f"""Based on the provided personality traits, age, gender and profession, please select 2-3 topics of interest from the given list. + Input:\n""" + for key in traits: + prompt += f" {'Personality Traits' if key == 'mbti' else key}: {traits[key]}\n" + + prompt += f"Available Topics:\n" + for index, name in enumerate(names): + prompt += f" {index + 1}. {name}: {descs[index]}\n" + + prompt += f"""Output: + [list of topic numbers] + Ensure your output could be parsed to **list**, don't output anything else.""" + + response = client.chat.completions.create(model=model_type, + messages=[{ + "role": "system", + "content": prompt + }]) + + topics = response.choices[0].message.content.strip() + return json.loads(topics) + +def generate_user_profile(traits, prompts): + prompt = f"""Please generate a social media user profile based on the provided personal information, including a real name, username, user bio, and a new user persona. The focus should be on creating a fictional background story and detailed interests based on their hobbies and profession. + Input:\n""" + for key in traits: + prompt += f" {key}: {traits[key]}\n" + + prompt += prompts + prompt += f"""Ensure the output can be directly parsed to **JSON**, do not output anything else.""" # noqa: E501 + + response = client.chat.completions.create(model=model_type, + messages=[{ + "role": "system", + "content": prompt + }]) + + profile = response.choices[0].message.content.strip() + return json.loads(profile) diff --git a/ui/web/.gitignore b/ui/web/.gitignore new file mode 100644 index 00000000..74a2aa60 --- /dev/null +++ b/ui/web/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +/node_modules/ +*.tsbuildinfo + +# React Router +/.react-router/ +/build/ + +/log/ \ No newline at end of file diff --git a/ui/web/.vite/deps/_metadata.json b/ui/web/.vite/deps/_metadata.json new file mode 100644 index 00000000..a919bee4 --- /dev/null +++ b/ui/web/.vite/deps/_metadata.json @@ -0,0 +1,8 @@ +{ + "hash": "3c89e79a", + "configHash": "b38cd1b6", + "lockfileHash": "b8144884", + "browserHash": "4e6ee8ad", + "optimized": {}, + "chunks": {} +} \ No newline at end of file diff --git a/ui/web/.vite/deps/package.json b/ui/web/.vite/deps/package.json new file mode 100644 index 00000000..3dbc1ca5 --- /dev/null +++ b/ui/web/.vite/deps/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/ui/web/Dockerfile b/ui/web/Dockerfile new file mode 100644 index 00000000..5137992e --- /dev/null +++ b/ui/web/Dockerfile @@ -0,0 +1,55 @@ +# Stage 1: Build the application +FROM node:22-alpine AS builder + +# Enable pnpm and set up cache +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" +RUN corepack enable + +WORKDIR /app + +# Copy package.json and lockfile first to leverage Docker cache +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY apps/server/package.json ./apps/server/ + +# Install dependencies using pnpm with store cache +RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm install --no-frozen-lockfile + +# Copy the rest of the code +COPY . . + +# Build the application +RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm --filter @cbnsndwch/react-router-nest-server run build + +# Stage 2: Production image +FROM node:22-alpine + +# Add metadata labels +LABEL org.opencontainers.image.title="React Router Nest Server" +LABEL org.opencontainers.image.description="A demo server application built with React Router and NestJS" +LABEL org.opencontainers.image.source="https://github.com/cbnsndwch/react-router-nest" +LABEL org.opencontainers.image.licenses="MIT" + +# Set environment +ENV NODE_ENV=production +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" +RUN corepack enable + +WORKDIR /app + +# Copy package.json and lockfile +COPY --from=builder /app/apps/server/package.json /app/package.json + +# Install production dependencies using the same cache +RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store pnpm install --prod --no-frozen-lockfile + +# Copy all built application artifacts +COPY --from=builder /app/apps/server/dist /app/dist +COPY --from=builder /app/apps/server/build /app/build + +# Expose the application port +EXPOSE 3000 + +# Run the application +CMD ["node", "dist/main.js"] diff --git a/ui/web/README-TESTING.md b/ui/web/README-TESTING.md new file mode 100644 index 00000000..860850a1 --- /dev/null +++ b/ui/web/README-TESTING.md @@ -0,0 +1,82 @@ +# Testing Setup for React Router 7 in NestJS + +## Overview + +This project includes test setup for the React Router 7 integration with NestJS. The tests are designed to validate: + +1. The custom server integration between React Router 7 and NestJS +2. Route function behavior (loaders, actions, meta functions) +3. Server-side rendering capabilities + +## Testing Structure + +The testing is organized as follows: + +- `src/react-router.spec.ts` - Tests for the NestJS-React Router integration +- `test/router-test-utils.tsx` - Utilities for testing React Router components +- `test/setup.ts` - Global test setup for Vitest +- `docs/TESTING.md` - Detailed documentation on testing strategies + +## Working Tests + +- ✅ `src/react-router.spec.ts` - Successfully tests the NestJS integration with React Router +- ✅ Unit tests for NestJS services and controllers + +## Current Limitations + +There are some limitations when testing React Router 7 components: + +1. **Client/Server Boundaries**: React Router 7 enforces strict separation between server and client code. This makes it difficult to directly test certain files like `entry.server.tsx` or route components. + +2. **Vite Plugin Restrictions**: The React Router 7 Vite plugin enforces these boundaries during testing, resulting in errors like "React Router Vite plugin can't detect preamble" when attempting to import route components. + +3. **Testing Route Components**: Due to the limitations above, testing route components directly can be challenging. Instead, we recommend: + - Testing individual exports (meta, loader, action) in isolation + - Using mock data to test the rendering outside the context of React Router + +## Recommended Testing Approach + +1. **Unit Test Functions**: Test loader, action, and meta functions directly with mocked context. + +2. **Test NestJS Integration**: Test how NestJS mounts the React Router handler. + +3. **Component Testing**: Test UI components with mocked props rather than within the React Router context. + +## Example Test Structure + +``` +// For testing loader functions +import { loader } from './route'; +import { createMockLoaderArgs } from '../../test/router-test-utils'; + +describe('Route Loader', () => { + it('returns expected data', async () => { + const args = createMockLoaderArgs(); + const result = await loader(args); + expect(result).toEqual(/*...*/); + }); +}); +``` + +## Future Improvements + +We can improve the testing setup by: + +1. Configuring a special test environment that bypasses the Vite plugin restrictions +2. Creating more comprehensive mocks for the React Router context +3. Investigating ways to unit test server-side rendering behavior + +## Running Tests + +```bash +# Run all tests +pnpm test + +# Run tests in watch mode +pnpm test:watch + +# Run tests with coverage +pnpm test:cov +``` + +For more detailed information about testing approaches, see the [TESTING.md](./docs/TESTING.md) documentation. diff --git a/ui/web/app/assets/data/env_configs.ts b/ui/web/app/assets/data/env_configs.ts new file mode 100644 index 00000000..be01fb3a --- /dev/null +++ b/ui/web/app/assets/data/env_configs.ts @@ -0,0 +1,134 @@ +export interface typeEnvSettings { + agent_count: number; + social_media_style: 'reddit' | 'twitter'; + actions_scope: []; +} +export interface typeEnvAgentConfig { + title: string; + // config items name + controls: (string | undefined)[]; + // config item value + defaults: (number | undefined)[]; + // nanoid for config items, length equals to controls/defaults + keys?: string[]; + // default config as mbti, gender, age, profession, country + lock?: boolean; + // when temporary config panel saved + saved?: boolean; + // when config panel checked to send to python + checked?: boolean; +} + +export const ENV_AGENT_PARAMS = [ + { + title: 'Gender', + controls: ['female', 'male'], + defaults: [0.351, 0.636], + lock: true, + }, + { + title: 'Age', + controls: ['18-29', '30-49', '50-64', '65-100', 'underage'], + defaults: [0.44, 0.31, 0.11, 0.03, 0.11], + lock: true, + }, + { + title: 'MBTI', + controls: [ + 'ISTJ', + 'ISFJ', + 'INFJ', + 'INTJ', + 'ISTP', + 'ISFP', + 'INFP', + 'INTP', + 'ESTP', + 'ESFP', + 'ENFP', + 'ENTP', + 'ESTJ', + 'ESFJ', + 'ENFJ', + 'ENTJ', + ], + defaults: [ + 0.12625, 0.11625, 0.02125, 0.03125, 0.05125, 0.07125, 0.04625, 0.04125, + 0.04625, 0.06625, 0.07125, 0.03625, 0.10125, 0.11125, 0.03125, 0.03125, + ], + lock: true, + }, + { + title: 'Profession', + controls: [ + 'Agriculture, Food & Natural Resources', + 'Architecture & Construction', + 'Arts, Audio/Video Technology & Communications', + 'Business Management & Administration', + 'Education & Training', + 'Finance', + 'Government & Public Administration', + 'Health Science', + 'Hospitality & Tourism', + 'Human Services', + 'Information Technology', + 'Law, Public Safety, Corrections & Security', + 'Manufacturing', + 'Marketing', + 'Science, Technology, Engineering & Mathematics', + 'Transportation, Distribution & Logistics', + ], + defaults: [ + 0.0625, 0.0625, 0.0625, 0.0625, 0.0625, 0.0625, 0.0625, 0.0625, 0.0625, + 0.0625, 0.0625, 0.0625, 0.0625, 0.0625, 0.0625, 0.0625, + ], + lock: true, + }, +]; + +export const ENV_AGENT_INTERESTS = [ + { + name: 'Economics', + desc: 'The study and management of production, distribution, and consumption of goods and services. Economics focuses on how individuals, businesses, governments, and nations make choices about allocating resources to satisfy their wants and needs, and tries to determine how these groups should organize and coordinate efforts to achieve maximum output.', + lock: true, + }, + { + name: 'IT (Information Technology)', + desc: 'The use of computers, networking, and other physical devices, infrastructure, and processes to create, process, store, secure, and exchange all forms of electronic data. IT is commonly used within the context of business operations as opposed to personal or entertainment technologies.', + lock: true, + }, + { + name: 'Culture & Society', + desc: 'The way of life for an entire society, including codes of manners, dress, language, religion, rituals, norms of behavior, and systems of belief. This topic explores how cultural expressions and societal structures influence human behavior, relationships, and social norms.', + lock: true, + }, + { + name: 'General News', + desc: 'A broad category that includes current events, happenings, and trends across a wide range of areas such as politics, business, science, technology, and entertainment. General news provides a comprehensive overview of the latest developments affecting the world at large.', + lock: true, + }, + { + name: 'Politics', + desc: 'The activities associated with the governance of a country or other area, especially the debate or conflict among individuals or parties having or hoping to achieve power. Politics is often a battle over control of resources, policy decisions, and the direction of societal norms.', + lock: true, + }, + { + name: 'Business', + desc: `The practice of making one's living through commerce, trade, or services. This topic encompasses the entrepreneurial, managerial, and administrative processes involved in starting, managing, and growing a business entity.`, + lock: true, + }, + { + name: 'Fun', + desc: `Activities or ideas that are light-hearted or amusing. This topic covers a wide range of entertainment choices and leisure activities that bring joy, laughter, and enjoyment to individuals and groups.`, + lock: true, + }, +]; + +export const ENV_AGENT_PROMPTS = `Please generate a social media user profile based on the provided personal information, including a real name, username, user bio, and a new user persona. The focus should be on creating a fictional background story and detailed interests based on their hobbies and profession. +Output: +{{ + "realname": "str", + "username": "str", + "bio": "str", + "persona": "str" +}}`; diff --git a/ui/web/app/components/ui/alert-dialog.tsx b/ui/web/app/components/ui/alert-dialog.tsx new file mode 100644 index 00000000..05324860 --- /dev/null +++ b/ui/web/app/components/ui/alert-dialog.tsx @@ -0,0 +1,155 @@ +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "app/lib/utils" +import { buttonVariants } from "app/components/ui/button" + +function AlertDialog({ + ...props +}: React.ComponentProps) { + return +} + +function AlertDialogTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + ) +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogAction({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogCancel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/ui/web/app/components/ui/button.tsx b/ui/web/app/components/ui/button.tsx new file mode 100644 index 00000000..212830fd --- /dev/null +++ b/ui/web/app/components/ui/button.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "app/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", + destructive: + "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", + ghost: + "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2 has-[>svg]:px-3", + sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", + lg: "h-10 rounded-md px-6 has-[>svg]:px-4", + icon: "size-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Button({ + className, + variant, + size, + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean + }) { + const Comp = asChild ? Slot : "button" + + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/ui/web/app/components/ui/input.tsx b/ui/web/app/components/ui/input.tsx new file mode 100644 index 00000000..8023959d --- /dev/null +++ b/ui/web/app/components/ui/input.tsx @@ -0,0 +1,21 @@ +import * as React from 'react'; + +import { cn } from 'app/lib/utils'; + +function Input({ className, type, ...props }: React.ComponentProps<'input'>) { + return ( + + ); +} + +export { Input }; diff --git a/ui/web/app/components/ui/select.tsx b/ui/web/app/components/ui/select.tsx new file mode 100644 index 00000000..2501eb89 --- /dev/null +++ b/ui/web/app/components/ui/select.tsx @@ -0,0 +1,183 @@ +import * as React from 'react'; +import * as SelectPrimitive from '@radix-ui/react-select'; +import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react'; + +import { cn } from 'app/lib/utils'; + +function Select({ + ...props +}: React.ComponentProps) { + return ; +} + +function SelectGroup({ + ...props +}: React.ComponentProps) { + return ; +} + +function SelectValue({ + ...props +}: React.ComponentProps) { + return ; +} + +function SelectTrigger({ + className, + size = 'default', + children, + ...props +}: React.ComponentProps & { + size?: 'sm' | 'default'; +}) { + return ( + + {children} + + + + + ); +} + +function SelectContent({ + className, + children, + position = 'popper', + ...props +}: React.ComponentProps) { + return ( + + + + + {children} + + + + + ); +} + +function SelectLabel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function SelectItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function SelectSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function SelectScrollUpButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, +}; diff --git a/ui/web/app/components/ui/sonner.tsx b/ui/web/app/components/ui/sonner.tsx new file mode 100644 index 00000000..7c2fbb60 --- /dev/null +++ b/ui/web/app/components/ui/sonner.tsx @@ -0,0 +1,23 @@ +import { useTheme } from 'next-themes'; +import { Toaster as Sonner, type ToasterProps } from 'sonner'; + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = 'system' } = useTheme(); + + return ( + + ); +}; + +export { Toaster }; diff --git a/ui/web/app/components/ui/textarea.tsx b/ui/web/app/components/ui/textarea.tsx new file mode 100644 index 00000000..1f3862d9 --- /dev/null +++ b/ui/web/app/components/ui/textarea.tsx @@ -0,0 +1,18 @@ +import * as React from 'react'; + +import { cn } from 'app/lib/utils'; + +function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) { + return ( +