Initial commit

This commit is contained in:
Kevin 2025-03-20 00:37:54 +08:00
commit 7f7d64210e
381 changed files with 838033 additions and 0 deletions

64
.env Normal file
View File

@ -0,0 +1,64 @@
# Application configurations
APP_ENV=dev
APP_VERSION=0.1.0
APP_PORT=8000
LOCAL_APP_PORT=8002
LOCAL_FRONTEND_PORT=3000
APP_WORD='hello world'
# Complete URL settings
REGISTRY_SERVICE_URL=https://app.secondme.io
LOCAL_LLM_SERVICE_URL=http://127.0.0.1:8080/v1
REGISTRY_SHARE_SPACE_SERVICE_URL=https://app.secondme.io/api/space/share
# URL Transformation Configuration
LLM_SOURCE_PORT=5173
LLM_TARGET_PORT=8004
LLM_API_PREFIX=/api/chat
# Conda configurations
CONDA_DEFAULT_ENV=second-me
# Database configurations
DB_TYPE=sqlite
DB_FILE=data/sqlite/lpm.db
DB_POOL_SIZE=5
DB_POOL_RECYCLE=3600
# Flask configurations
FLASK_APP=lpm_kernel/app.py
FLASK_ENV=development
# Log configurations
LOG_DIR=/app/logs
LOCAL_LOG_DIR=logs
# ChromaDB configurations
CHROMA_PERSIST_DIRECTORY=./data/chroma_db
# Base directory configurations
# Use /app as base directory in container, use current directory locally
BASE_DIR=/app
LOCAL_BASE_DIR=.
# Resource directories (relative to BASE_DIR or LOCAL_BASE_DIR)
RESOURCE_DIR=resources
RAW_CONTENT_DIR=${RESOURCE_DIR}/raw_content
DATA_PIPELINE_DIR=${RESOURCE_DIR}/L2/data_pipeline
# User specified directory paths
USER_RAW_CONTENT_DIR=${RAW_CONTENT_DIR}
# UserDataPipeline
USER_DATA_PIPELINE_DIR=${DATA_PIPELINE_DIR}
# Application name
TEST_APP_NAME=lpm_kernel
TOKEN_ENCODING=cl100k_base
PREFER_LANGUAGE=en
DOCUMENT_CHUNK_SIZE=4000
DOCUMENT_CHUNK_OVERLAP=200

75
.gitignore vendored Normal file
View File

@ -0,0 +1,75 @@
__pycache__
.DS_Store
.idea
.ipynb_checkpoints
.pytest_cache
.ruff_cache
.vscode/
.ruff_cache/
poetry.lock
.hf_cache/
llama.cpp
*.ipynb
data/db/*
data/chroma_db/*
lpm_kernel/L2/base_model/
lpm_kernel/L2/data_pipeline/output/
lpm_kernel/L2/data_pipeline/graphrag_indexing/cache/
lpm_kernel/L2/data_pipeline/raw_data/*
!lpm_kernel/L2/data_pipeline/raw_data/.gitkeep
lpm_kernel/L2/data_pipeline/tmp/
lpm_kernel/L2/output_models/
data/sqlite/*
data/uploads/*
data/progress/*
lpm_frontend/node_modules
# L2 Model Storage
resources/model/output/merged_model/*
!resources/model/output/merged_model/.gitkeep
resources/model/output/personal_model/*
!resources/model/output/personal_model/.gitkeep
resources/model/output/*.json
resources/model/output/*.gguf
!resources/model/output/.gitkeep
resources/L1/processed_data/subjective/*
!resources/L1/processed_data/subjective/.gitkeep
resources/L1/processed_data/objective/*
!resources/L1/processed_data/objective/.gitkeep
resources/L1/graphrag_indexing_output/report/*
resources/L1/graphrag_indexing_output/subjective/*
lpm_kernel/L2/data_pipeline/graphrag_indexing/settings.yaml
lpm_kernel/L2/data_pipeline/graphrag_indexing/.env
resources/raw_content/*
!resources/raw_content/.gitkeep
# Base model storage address
resources/L2/base_model/*
!resources/L2/base_model/.gitkeep
resources/L2/data_pipeline/raw_data/*
!resources/L2/data_pipeline/raw_data/.gitkeep
resources/model/processed_data/L1/processed_data/objective/*
resources/model/processed_data/L1/processed_data/subjective/*
resources/L2/data/*
!resources/L2/data/.gitkeep
resources/model/output/gguf/*
resources/L2/base_models/*
logs/*.log
# Runtime files
run/*
!run/.gitkeep
#config
.backend.pid
.frontend.pid

45
CODE_OF_CONDUCT.md Normal file
View File

@ -0,0 +1,45 @@
# Second Me Covenant Code of Conduct
## Our Pledge
In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to make participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, sex characteristics, gender identity, and expression, level of experience, education, socioeconomic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
## Our Standards
### Examples of behavior that contributes to creating a positive environment include:
- Using welcoming and inclusive language
- Being respectful of differing viewpoints and experiences
- Gracefully accepting constructive criticism
- Focusing on what is best for the community
- Showing empathy towards other community members
### Examples of unacceptable behavior by participants include:
- The use of sexualized language or imagery and unwelcome sexual attention or advances
- Trolling, insulting/derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or electronic address, without explicit permission
- Other conduct which could reasonably be considered inappropriate in a professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
## Scope
This Code of Conduct applies within all project spaces, and it also applies when an individual is representing the project or its community in public spaces. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at [opensource@mindverse.ai](mailto:opensource@mindverse.ai). All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality about the reporter of an incident. Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org/), version 2.1.
For answers to common questions about this code of conduct, see [FAQ](https://www.contributor-covenant.org/faq/).

149
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,149 @@
# Contributing Guide
Second Me is an open and friendly community. We are dedicated to building a collaborative, inspiring, and exuberant open source community for our members. Everyone is more than welcome to join our community to get help and to contribute to Second Me.
The Second Me community welcomes various forms of contributions, including code, non-code contributions, documentation, and more.
## How to Contribute
| Contribution Type | Details |
|------------------|---------|
| Report a bug | You can file an issue to report a bug with Second Me |
| Contribute code | You can contribute your code by fixing a bug or implementing a feature |
| Code Review | If you are an active contributor or committer of Second Me, you can help us review pull requests |
| Documentation | You can contribute documentation changes by fixing a documentation bug or proposing new content |
## Before Contributing
* Sign [CLA of Mindverse](https://cla-assistant.io/mindverse/Second-Me)
## Here is a checklist to prepare and submit your PR (pull request).
* Create your own Github branch by forking Second Me
* Checkout [README]() for how to deploy Seond Me.
* Push changes to your personal fork.
* Create a PR with a detail description, if commit messages do not express themselves.
* Submit PR for review and address all feedbacks.
* Wait for merging (done by committers).
## Development Workflow for External Contributors
As an external contributor, you'll need to follow the fork-based workflow. This ensures a safe and organized way to contribute to the project.
### 1. Fork the Repository
1. Visit https://github.com/Mindverse/Second-Me
2. Click the "Fork" button in the top-right corner
3. Select your GitHub account as the destination
### 2. Clone Your Fork
After forking, clone your fork to your local machine:
```bash
cd working_dir
# Replace USERNAME with your GitHub username
git clone git@github.com:USERNAME/Second-Me.git
cd Second-Me
```
### 3. Configure Upstream Remote
To keep your fork up to date with the main repository:
```bash
# Add the upstream repository
git remote add upstream git@github.com:Mindverse/Second-Me.git
# Verify your remotes
git remote -v
# You should see:
# origin git@github.com:USERNAME/Second-Me.git (fetch)
# origin git@github.com:USERNAME/Second-Me.git (push)
# upstream git@github.com:Mindverse/Second-Me.git (fetch)
# upstream git@github.com:Mindverse/Second-Me.git (push)
```
### 4. Keep Your Fork Updated
Before creating a new feature branch, ensure your fork's main branch is up to date:
```bash
# Switch to main branch
git checkout main
# Fetch upstream changes
git fetch upstream
# Rebase your main branch on top of upstream main
git rebase upstream/main
# Optional: Update your fork on GitHub
git push origin main
```
### 5. Create a Feature Branch
Always create a new branch for your changes:
```bash
# Create and switch to a new branch
git checkout -b feature/your-feature-name
```
### 6. Make Your Changes
Make your desired changes in the feature branch. Make sure to:
- Follow the project's coding style
- Add tests if applicable
- Update documentation as needed
### 7. Commit Your Changes
```bash
# Add your changes
git add <filename>
# Or git add -A for all changes
# Commit with a clear message
git commit -m "feat: add new feature X"
```
### 8. Update Your Feature Branch
Before submitting your PR, update your feature branch with the latest changes:
```bash
# Fetch upstream changes
git fetch upstream
# Rebase your feature branch
git checkout feature/your-feature-name
git rebase upstream/main
```
### 9. Push to Your Fork
```bash
# Push your feature branch to your fork
git push origin feature/your-feature-name
```
### 10. Create a Pull Request
1. Visit your fork at `https://github.com/USERNAME/Second-Me`
2. Click "Compare & Pull Request"
3. Select:
- Base repository: `Mindverse/Second-Me`
- Base branch: `main`
- Head repository: `USERNAME/Second-Me`
- Compare branch: `feature/your-feature-name`
4. Fill in the PR template with:
- Clear description of your changes
- Any related issues
- Testing steps if applicable
### 11. Review Process
1. Maintainers will review your PR
2. Address any feedback by:
- Making requested changes
- Pushing new commits to your feature branch
- The PR will automatically update
3. Once approved, maintainers will merge your PR
### 12. CI Checks
- Automated checks will run on your PR
- All checks must pass before merging
- If checks fail, click "Details" to see why
- Fix any issues and push updates to your branch
## Tips for Successful Contributions
- Create focused, single-purpose PRs
- Follow the project's code style and conventions
- Write clear commit messages
- Keep your fork updated to avoid merge conflicts
- Be responsive during the review process
- Ask questions if anything is unclear

201
LICENSE Normal file
View File

@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2025 Mindverse
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

93
Makefile Normal file
View File

@ -0,0 +1,93 @@
# Available Commands:
# Commands that don't require conda environment:
# make help - Show this help message
# make setup - Run complete installation script
# make start - Start all services
# make stop - Stop chat services
# make restart - Restart chat services
# make restart-backend - Restart only backend service
# make restart-force - Force restart and reset data
# make status - Show status of all services
.PHONY: install test format lint all setup start stop restart restart-backend restart-force help check-conda check-env
# Show help message
help:
@echo "\033[0;36m"
@echo ' ███████╗███████╗ ██████╗ ██████╗ ███╗ ██╗██████╗ ███╗ ███╗███████╗'
@echo ' ██╔════╝██╔════╝██╔════╝██╔═══██╗████╗ ██║██╔══██╗ ████╗ ████║██╔════╝'
@echo ' ███████╗█████╗ ██║ ██║ ██║██╔██╗ ██║██║ ██║█████╗██╔████╔██║█████╗ '
@echo ' ╚════██║██╔══╝ ██║ ██║ ██║██║╚██╗██║██║ ██║╚════╝██║╚██╔╝██║██╔══╝ '
@echo ' ███████║███████╗╚██████╗╚██████╔╝██║ ╚████║██████╔╝ ██║ ╚═╝ ██║███████╗'
@echo ' ╚══════╝╚══════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═══╝╚═════╝ ╚═╝ ╚═╝╚══════╝'
@echo "\033[0m"
@echo "\033[1mSecond-Me Makefile Commands\033[0m"
@echo "\033[0;90m$$(date)\033[0m\n"
@echo ""
@echo "\033[1;32m▶ MAIN COMMANDS:\033[0m"
@echo " make setup - Complete installation"
@echo " make start - Start all services"
@echo " make stop - Stop all services"
@echo " make restart - Restart all services"
@echo " make restart-backend - Restart only backend service"
@echo " make restart-force - Force restart and reset data"
@echo " make status - Show status of all services"
@echo ""
@echo "\033[1mAll Available Commands:\033[0m"
@echo " make help - Show this help message"
@echo " make check-env - Check environment without installing"
@echo ""
@echo "\033[1mCommands that require conda environment:\033[0m"
@echo " make install - Install project dependencies"
@echo " make test - Run tests"
@echo " make format - Format code"
@echo " make lint - Check code style"
@echo " make all - Run format, lint and test"
# Check if in conda environment
check-conda:
@if [ -z "$$CONDA_DEFAULT_ENV" ]; then \
echo "Error: Please activate conda environment first!"; \
exit 1; \
fi
# Check environment without installing
check-env:
zsh ./scripts/setup.sh --check-only
# Commands that don't require conda environment
setup:
zsh ./scripts/setup.sh
start:
zsh ./scripts/start.sh
stop:
zsh ./scripts/stop.sh
restart:
zsh ./scripts/restart.sh
restart-backend:
zsh ./scripts/restart-backend.sh
restart-force:
zsh ./scripts/restart-force.sh
status:
zsh ./scripts/status.sh
# Commands that require conda environment
install: check-conda
poetry install
test: check-conda
poetry run pytest tests
format: check-conda
poetry run ruff format lpm_kernel/
lint: check-conda
poetry run ruff check lpm_kernel/
all: check-conda format lint test

179
README.md Normal file
View File

@ -0,0 +1,179 @@
![Second Me](https://github.com/mindverse/Second-Me/blob/master/images/cover.png)
<div align="center">
[![Homepage](https://img.shields.io/badge/Second_Me-Homepage-blue?style=flat-square&logo=homebridge)](https://www.secondme.io/)
[![Report](https://img.shields.io/badge/Paper-arXiv-red?style=flat-square&logo=arxiv)](https://arxiv.org/abs/2503.08102)
[![Discord](https://img.shields.io/badge/Chat-Discord-5865F2?style=flat-square&logo=discord&logoColor=white)](https://discord.gg/GpWHQNUwrg)
[![Twitter](https://img.shields.io/badge/Follow-@SecondMe_AI-1DA1F2?style=flat-square&logo=x&logoColor=white)](https://x.com/SecondMe_AI1)
[![Reddit](https://img.shields.io/badge/Join-Reddit-FF4500?style=flat-square&logo=reddit&logoColor=white)](https://www.reddit.com/r/SecondMeAI/)
</div>
## Our Vision
Companies like OpenAI built "Super AI" that threatens human independence. We crave individuality: AI that amplifies, not erases, you.
Were challenging that with "**Second Me**": an open-source prototype where you craft your own **AI self**—a new AI species that preserves you, delivers your context, and defends your interests.
Its **locally trained and hosted**—your data, your control—yet **globally connected**, scaling your intelligence across an AI network. Beyond that, its your AI identity interface—a bold standard linking your AI to the world, sparks collaboration among AI selves, and builds tomorrows truly native AI apps.
Join us. Tech enthusiasts, AI pros, domain experts—Second Me is your launchpad to extend your mind into the digital horizon.
## Key Features
### **Train Your AI Self** with AI-Native Memory ([Paper](https://arxiv.org/abs/2503.08102))
Start training your Second Me today with your own memories! Using Hierarchical Memory Modeling (HMM) and the Me-Alignment Algorithm, your AI self captures your identity, understands your context, and reflects you authentically.
<p align="center">
<img src="https://github.com/user-attachments/assets/a84c6135-26dc-4413-82aa-f4a373c0ff89" width="94%" />
</p>
### **Scale Your Intelligence** on the Second Me Network
Launch your AI self from your laptop onto our decentralized network—anyone or any app can connect with your permission, sharing your context as your digital identity.
<p align="center">
<img src="https://github.com/user-attachments/assets/9a74a3f4-d8fd-41c1-8f24-534ed94c842a" width="94%" />
</p>
### Build Tomorrows Apps with Second Me
**Roleplay**: Your AI self switches personas to represent you in different scenarios.
**AI Space**: Collaborate with other Second Mes to spark ideas or solve problems.
<p align="center">
<img src="https://github.com/user-attachments/assets/bc6125c1-c84f-4ecc-b620-8932cc408094" width="94%" />
</p>
### 100% **Privacy and Control**
Unlike traditional centralized AI systems, Second Me ensures that your information and intelligence remains local and completely private.
## Getting started & staying tuned with us
Star and join us, and you will receive all release notifications from GitHub without any delay!
<p align="center">
<img src="https://github.com/user-attachments/assets/5c14d956-f931-4c25-b0b3-3c2c96cd7581" width="94%" />
</p>
## Quick Start
### Prerequisites
- macOS operating system
- Git installed
- Homebrew (recommended for system dependencies)
- Xcode Command Line Tools (for using make commands)
#### Installing Xcode Command Line Tools
If you haven't installed Xcode Command Line Tools yet, you can install them by running:
```bash
xcode-select --install
```
After installation, you may need to accept the license agreement:
```bash
sudo xcodebuild -license accept
```
### Installation and Setup
1. Clone the repository
```bash
git clone git@github.com:Mindverse/Second-Me.git
cd Second-Me
```
2. Set up the environment
Using make (requires Xcode Command Line Tools):
```bash
make setup
```
Alternatively, you can use the setup script directly:
```bash
./scripts/setup.sh
```
This command will automatically:
- Install all required system dependencies
- Set up Python environment
- Build llama.cpp
- Set up frontend environment
3. Start the service
Using make:
```bash
make start
```
Alternatively, use the script directly:
```bash
./scripts/start.sh
```
4. Access the service
Open your browser and visit `http://localhost:3000`
5. For help and more commands
Using make:
```bash
make help
```
## Coming Soon 🚀
The following features have been completed internally and are being gradually integrated into the open-source project. For detailed experimental results and technical specifications, please refer to our [Technical Report](https://arxiv.org/abs/2503.08102).
### 🔬 Model Enhancement Features
- [ ] **Long Chain-of-Thought Training Pipeline**: Enhanced reasoning capabilities through extended thought process training
- [ ] **Direct Preference Optimization for L2 Model**: Improved alignment with user preferences and intent
- [ ] **Data Filtering for Training**: Advanced techniques for higher quality training data selection
- [ ] **Apple Silicon Support**: Native support for Apple Silicon processors with MLX Training and Serving capabilities
### 🛠️ Product Features
- [ ] **Natural Language Memory Summarization**: Intuitive memory organization in natural language format
## Contributing
We welcome contributions to Second Me! Whether you're interested in fixing bugs, adding new features, or improving documentation, please check out our Contribution Guide. You can also support Second Me by sharing your experience with it in your community, at tech conferences, or on social media.
For more detailed information about development, please refer to our [Contributing Guide](./CONTRIBUTING.md).
## Contributors
We would like to express our gratitude to all the individuals who have contributed to Second Me! If you're interested in contributing to the future of intelligence uploading, whether through code, documentation, or ideas, please feel free to submit a pull request to our repository: [Second-Me](https://github.com/Mindverse/Second-Me).
<a href="https://github.com/mindverse/Second-Me/graphs/contributors">
<img src="https://contrib.rocks/image?repo=mindverse/Second-Me" />
</a>
Made with [contrib.rocks](https://contrib.rocks).
## Acknowledgements
This work leverages the power of the open source community.
For data synthesis, we utilized [GraphRAG](https://github.com/microsoft/graphrag) from Microsoft.
For model deployment, we utilized [llama.cpp](https://github.com/ggml-org/llama.cpp), which provides efficient inference capabilities.
Our base models primarily come from the [Qwen2.5](https://huggingface.co/Qwen) series.
We also want to extend our sincere gratitude to all users who have experienced Second Me. We recognize that there is significant room for optimization throughout the entire pipeline, and we are fully committed to iterative improvements to ensure everyone can enjoy the best possible experience locally.
## License
Second Me is open source software licensed under the Apache License 2.0. See the [LICENSE](LICENSE) file for more details.
[license]: ./LICENSE

BIN
dependencies/graphrag-1.2.1.dev27.tar.gz vendored Normal file

Binary file not shown.

1137
dependencies/homebrew_install.sh vendored Normal file

File diff suppressed because it is too large Load Diff

BIN
dependencies/llama.cpp.zip vendored Normal file

Binary file not shown.

48
docker/app/init_chroma.py Normal file
View File

@ -0,0 +1,48 @@
import chromadb
import os
def init_chroma_db():
chroma_path = os.getenv("CHROMA_PERSIST_DIRECTORY", "./data/chroma_db")
# ensure the directory is correct
os.makedirs(chroma_path, exist_ok=True)
try:
client = chromadb.PersistentClient(path=chroma_path)
# collection: init documents level
try:
collection = client.get_collection(name="documents")
print(f"Collection 'documents' already exists")
except ValueError:
collection = client.create_collection(
name="documents",
metadata={
"hnsw:space": "cosine",
"dimension": 1536
}
)
print(f"Successfully created collection 'documents'")
# collection: init chunk level
try:
collection = client.get_collection(name="document_chunks")
print(f"Collection 'document_chunks' already exists")
except ValueError:
collection = client.create_collection(
name="document_chunks",
metadata={
"hnsw:space": "cosine",
"dimension": 1536
}
)
print(f"Successfully created collection 'document_chunks'")
print(f"ChromaDB initialized at {chroma_path}")
except Exception as e:
print(f"An error occurred while initializing ChromaDB: {e}")
# no exception for following process
# ChromaRepository will create collection if needed
if __name__ == "__main__":
init_chroma_db()

232
docker/sqlite/init.sql Normal file
View File

@ -0,0 +1,232 @@
-- Document Table
CREATE TABLE IF NOT EXISTS document (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(255) NOT NULL DEFAULT '',
title VARCHAR(511) NOT NULL DEFAULT '',
extract_status TEXT CHECK(extract_status IN ('INITIALIZED', 'SUCCESS', 'FAILED')) NOT NULL DEFAULT 'INITIALIZED',
embedding_status TEXT CHECK(embedding_status IN ('INITIALIZED', 'SUCCESS', 'FAILED')) NOT NULL DEFAULT 'INITIALIZED',
analyze_status TEXT CHECK(analyze_status IN ('INITIALIZED', 'SUCCESS', 'FAILED')) NOT NULL DEFAULT 'INITIALIZED',
mime_type VARCHAR(50) NOT NULL DEFAULT '',
raw_content TEXT DEFAULT NULL,
user_description VARCHAR(255) NOT NULL DEFAULT '',
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
url VARCHAR(1023) NOT NULL DEFAULT '',
document_size INTEGER NOT NULL DEFAULT 0,
insight TEXT DEFAULT NULL, -- JSON data stored as TEXT
summary TEXT DEFAULT NULL, -- JSON data stored as TEXT
keywords TEXT DEFAULT NULL
);
-- Document table indexes
CREATE INDEX IF NOT EXISTS idx_extract_status ON document(extract_status);
CREATE INDEX IF NOT EXISTS idx_name ON document(name);
CREATE INDEX IF NOT EXISTS idx_create_time ON document(create_time);
-- Chunk Table
CREATE TABLE IF NOT EXISTS chunk (
id INTEGER PRIMARY KEY AUTOINCREMENT,
document_id INTEGER NOT NULL,
content TEXT NOT NULL,
has_embedding BOOLEAN NOT NULL DEFAULT 0,
tags TEXT DEFAULT NULL, -- JSON data stored as TEXT
topic VARCHAR(255) DEFAULT NULL,
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (document_id) REFERENCES document(id)
);
-- Chunk table indexes
CREATE INDEX IF NOT EXISTS idx_document_id ON chunk(document_id);
CREATE INDEX IF NOT EXISTS idx_has_embedding ON chunk(has_embedding);
-- L1 Version Table
CREATE TABLE IF NOT EXISTS l1_versions (
version INTEGER PRIMARY KEY,
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
status VARCHAR(50) NOT NULL,
description VARCHAR(500)
);
-- L1 Bio Table
CREATE TABLE IF NOT EXISTS l1_bios (
id INTEGER PRIMARY KEY AUTOINCREMENT,
version INTEGER NOT NULL,
content TEXT,
content_third_view TEXT,
summary TEXT,
summary_third_view TEXT,
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (version) REFERENCES l1_versions(version)
);
-- L1 Shade Table
CREATE TABLE IF NOT EXISTS l1_shades (
id INTEGER PRIMARY KEY AUTOINCREMENT,
version INTEGER NOT NULL,
name VARCHAR(200),
aspect VARCHAR(200),
icon VARCHAR(100),
desc_third_view TEXT,
content_third_view TEXT,
desc_second_view TEXT,
content_second_view TEXT,
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (version) REFERENCES l1_versions(version)
);
-- L1 Cluster Table
CREATE TABLE IF NOT EXISTS l1_clusters (
id INTEGER PRIMARY KEY AUTOINCREMENT,
version INTEGER NOT NULL,
cluster_id VARCHAR(100),
memory_ids TEXT, -- JSON data stored as TEXT
cluster_center TEXT, -- JSON data stored as TEXT
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (version) REFERENCES l1_versions(version)
);
-- L1 Chunk Topic Table
CREATE TABLE IF NOT EXISTS l1_chunk_topics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
version INTEGER NOT NULL,
chunk_id VARCHAR(100),
topic TEXT,
tags TEXT, -- JSON data stored as TEXT
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (version) REFERENCES l1_versions(version)
);
-- Status Biography Table
CREATE TABLE IF NOT EXISTS status_biography (
id INTEGER PRIMARY KEY AUTOINCREMENT,
content TEXT NOT NULL,
content_third_view TEXT NOT NULL,
summary TEXT NOT NULL,
summary_third_view TEXT NOT NULL,
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Personal Load Table
CREATE TABLE IF NOT EXISTS loads (
id VARCHAR(36) PRIMARY KEY, -- UUID
name VARCHAR(255) NOT NULL, -- load name
description TEXT, -- load description
email VARCHAR(255) NOT NULL DEFAULT '', -- load email
avatar_data TEXT, -- load avatar base64 encoded data
instance_id VARCHAR(255), -- upload instance ID
instance_password VARCHAR(255), -- upload instance password
status TEXT CHECK(status IN ('active', 'inactive', 'deleted')) DEFAULT 'active',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_loads_status ON loads(status);
CREATE INDEX IF NOT EXISTS idx_loads_created_at ON loads(created_at);
-- Memory Files Table
CREATE TABLE IF NOT EXISTS memories (
id VARCHAR(36) NOT NULL,
name VARCHAR(255) NOT NULL,
size INTEGER NOT NULL,
type VARCHAR(50) NOT NULL,
path VARCHAR(1024) NOT NULL,
meta_data TEXT, -- JSON data stored as TEXT
document_id VARCHAR(36),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
status TEXT CHECK(status IN ('active', 'deleted')) NOT NULL DEFAULT 'active',
PRIMARY KEY (id)
);
CREATE INDEX IF NOT EXISTS idx_memories_document_id ON memories(document_id);
CREATE INDEX IF NOT EXISTS idx_memories_created_at ON memories(created_at);
CREATE INDEX IF NOT EXISTS idx_memories_type ON memories(type);
CREATE INDEX IF NOT EXISTS idx_memories_status ON memories(status);
-- Roles Table
CREATE TABLE IF NOT EXISTS roles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid VARCHAR(64) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL UNIQUE,
description VARCHAR(500),
system_prompt TEXT NOT NULL,
icon VARCHAR(100),
is_active BOOLEAN NOT NULL DEFAULT 1,
enable_l0_retrieval BOOLEAN NOT NULL DEFAULT 1,
enable_l1_retrieval BOOLEAN NOT NULL DEFAULT 1,
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_roles_name ON roles(name);
CREATE INDEX IF NOT EXISTS idx_roles_uuid ON roles(uuid);
CREATE INDEX IF NOT EXISTS idx_roles_is_active ON roles(is_active);
-- Insert predefined Roles (only if they don't exist)
INSERT OR IGNORE INTO roles (uuid, name, description, system_prompt, icon) VALUES
('role_interviewer_8f3a1c2e4b5d6f7a9e0b1d2c3f4e5d6b',
'Interviewer (a test case)',
'Professional interviewer who asks insightful questions to learn about people',
'You are a professional interviewer with expertise in asking insightful questions to understand people deeply, and you are facing the interviewee, and you dont know his/her background. Your responsibilities include:\n1. Asking thoughtful, open-ended questions\n2. Following up on interesting points\n3. sharing what you know to attract the interviewee.',
'interview-icon');
-- User LLM Configuration Table
CREATE TABLE IF NOT EXISTS user_llm_configs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
provider_type VARCHAR(50) NOT NULL DEFAULT 'openai',
key VARCHAR(200),
-- Chat configuration
chat_endpoint VARCHAR(200),
chat_api_key VARCHAR(200),
chat_model_name VARCHAR(200),
-- Embedding configuration
embedding_endpoint VARCHAR(200),
embedding_api_key VARCHAR(200),
embedding_model_name VARCHAR(200),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- User LLM Configuration table indexes
CREATE INDEX IF NOT EXISTS idx_user_llm_configs_created_at ON user_llm_configs(created_at);
-- Spaces Table
CREATE TABLE IF NOT EXISTS spaces (
id VARCHAR(255) PRIMARY KEY,
title VARCHAR(255) NOT NULL,
objective TEXT NOT NULL,
participants TEXT NOT NULL, -- JSON array stored as TEXT
host VARCHAR(255) NOT NULL,
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
status INTEGER DEFAULT 1,
conclusion TEXT,
space_share_id VARCHAR(255)
);
-- Space Messages Table
CREATE TABLE IF NOT EXISTS space_messages (
id VARCHAR(255) PRIMARY KEY,
space_id VARCHAR(255) NOT NULL,
sender_endpoint VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
message_type VARCHAR(50) NOT NULL,
round INTEGER DEFAULT 0,
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
role VARCHAR(50) DEFAULT 'participant',
FOREIGN KEY (space_id) REFERENCES spaces(id)
);
-- Space Messages Table Indexes
CREATE INDEX IF NOT EXISTS idx_space_messages_space_id ON space_messages(space_id);
CREATE INDEX IF NOT EXISTS idx_space_messages_round ON space_messages(round);
CREATE INDEX IF NOT EXISTS idx_space_messages_create_time ON space_messages(create_time);
-- Space Table Indexes
CREATE INDEX IF NOT EXISTS idx_spaces_create_time ON spaces(create_time);
CREATE INDEX IF NOT EXISTS idx_spaces_status ON spaces(status);

11
environment.yml Normal file
View File

@ -0,0 +1,11 @@
name: python-env # Generic name, actual environment name will be specified by command line argument
channels:
- conda-forge
- defaults
dependencies:
- python=3.12
- pip
- pip:
- poetry>=1.7.1 # Base package management tool
- poetry-core>=1.8.1 # Core components of poetry
- poetry-plugin-export>=1.6.0 # Poetry export plugin

BIN
images/cover.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 978 KiB

BIN
images/secondme_cover.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 975 KiB

0
logs/.gitkeep Normal file
View File

View File

@ -0,0 +1,2 @@
/*
!/src

21
lpm_frontend/.eslintrc.js Normal file
View File

@ -0,0 +1,21 @@
module.exports = {
root: true,
env: {
node: true,
browser: true
},
parserOptions: {
project: './tsconfig.json',
tsconfigRootDir: __dirname
},
extends: [
'./rules/eslint/js',
'./rules/eslint/import',
'./rules/eslint/react',
'./rules/eslint/ts',
'./rules/eslint/prettier'
],
rules: {
'@next/next/no-sync-scripts': 'off'
}
};

42
lpm_frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,42 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
next.config.ts

View File

@ -0,0 +1,2 @@
/*
!/src

View File

@ -0,0 +1,12 @@
// https://prettier.io/docs/en/options.html
// https://github.com/trivago/prettier-plugin-sort-imports
module.exports = {
trailingComma: 'none',
singleQuote: true,
printWidth: 100,
importOrder: ['<THIRD_PARTY_MODULES>', '^@/(.*)$', '^[./]'],
importOrderSeparation: true,
importOrderSortSpecifiers: true,
semi: true
};

View File

@ -0,0 +1,2 @@
/*
!/src

View File

@ -0,0 +1,43 @@
// https://stylelint.io/user-guide/rules
module.exports = {
extends: [
'stylelint-config-standard',
'stylelint-config-rational-order',
'stylelint-prettier/recommended'
],
overrides: [
{
files: '**/*.less',
customSyntax: 'postcss-less'
}
],
rules: {
'selector-class-pattern': null,
'color-function-notation': 'legacy',
'declaration-block-no-redundant-longhand-properties': null,
'selector-no-vendor-prefix': [
true,
{
ignoreSelectors: ['input-placeholder']
}
],
'property-no-vendor-prefix': [
true,
{
ignoreProperties: ['user-select', 'line-clamp', 'appearance']
}
],
'selector-pseudo-class-no-unknown': [
true,
{
ignorePseudoClasses: ['global']
}
],
'unit-no-unknown': [
true,
{
ignoreUnits: ['/^rpx$/']
}
]
}
};

36
lpm_frontend/README.md Normal file
View File

@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

View File

@ -0,0 +1,55 @@
const nextConfig = {
reactStrictMode: false,
async rewrites() {
return [
{
source: '/',
destination: '/home'
},
{
source: '/api/:path*', // Ensure source starts with `/api/`
destination: 'http://127.0.0.1:8002/api/:path*' // Need to add `/api/` here
}
];
},
async headers() {
return [
{
source: '/api/:path*',
headers: [
{ key: 'Access-Control-Allow-Credentials', value: 'true' },
{ key: 'Access-Control-Allow-Origin', value: '*' },
{
key: 'Access-Control-Allow-Methods',
value: 'GET,DELETE,PATCH,POST,PUT'
},
{
key: 'Access-Control-Allow-Headers',
value: 'Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date'
},
{ key: 'Accept', value: 'text/event-stream' },
{ key: 'Cache-Control', value: 'no-cache' },
{ key: 'Connection', value: 'keep-alive' }
]
}
];
},
experimental: {
proxyTimeout: 0 // Disable proxy timeout
},
compiler: {
styledComponents: true
},
webpack: (config) => {
config.externals = [...(config.externals || []), 'canvas', 'jsdom'];
config.watchOptions = {
poll: 1000,
aggregateTimeout: 300
};
return config;
}
};
module.exports = nextConfig;

14028
lpm_frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

78
lpm_frontend/package.json Normal file
View File

@ -0,0 +1,78 @@
{
"name": "secondme_frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "rm -rf .next && next dev --turbo -p 3000",
"build": "next build",
"start": "next start",
"eslint:error": "eslint --quiet --ext .js,.jsx,.ts,.tsx src",
"eslint:fix": "eslint --fix --ext .js,.jsx,.ts,.tsx src",
"eslint": "eslint --ext .js,.jsx,.ts,.tsx src",
"stylelint": "stylelint \"src/**/*.less\"",
"stylelint:fix": "stylelint \"src/**/*.less\" --fix"
},
"lint-staged": {
"**/*.{js,jsx,tsx,ts}": [
"eslint --quiet --fix"
],
"**/*.{css,less}": [
"stylelint --fix"
]
},
"dependencies": {
"@headlessui/react": "^1.7.17",
"@heroicons/react": "^2.0.18",
"@radix-ui/react-slot": "^1.1.2",
"@types/styled-components": "^5.1.34",
"@types/three": "^0.174.0",
"antd": "5.11.0",
"axios": "^1.8.2",
"canvas-confetti": "^1.9.3",
"class-variance-authority": "^0.7.1",
"classnames": "^2.5.1",
"clsx": "^2.1.1",
"marked": "^9.1.6",
"marked-highlight": "^2.0.7",
"marked-linkify-it": "^3.1.6",
"framer-motion": "^12.3.1",
"github-markdown-css": "^5.8.1",
"lucide-react": "^0.475.0",
"next": "14",
"react": "18",
"react-dom": "18",
"react-markdown": "^9.0.3",
"remark-gfm": "^4.0.0",
"styled-components": "^6.1.15",
"tailwind-merge": "^3.0.1",
"three": "^0.174.0",
"zustand": "^5.0.3"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@trivago/prettier-plugin-sort-imports": "^5.2.0",
"@types/canvas-confetti": "^1.9.0",
"@types/node": "^20",
"@types/react": "18",
"@types/react-dom": "18",
"@typescript-eslint/eslint-plugin": "^8.26.1",
"@typescript-eslint/parser": "^8.26.1",
"eslint": "^8.57.0",
"eslint-config-next": "^14.2.13",
"eslint-config-prettier": "^9.0.0",
"eslint-import-resolver-typescript": "^3.6.1",
"eslint-plugin-import": "^2.29.0",
"eslint-plugin-prettier": "^5.0.1",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"postcss": "^8",
"prettier": "^3.1.0",
"stylelint": "^15.11.0",
"stylelint-config-rational-order": "^0.1.2",
"stylelint-config-standard": "^34.0.0",
"stylelint-order": "^6.0.3",
"stylelint-prettier": "^4.1.0",
"tailwindcss": "^3.4.1",
"typescript": "^5"
}
}

View File

@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@ -0,0 +1,11 @@
// https://github.com/import-js/eslint-plugin-import#rules
module.exports = {
extends: ['plugin:import/recommended', 'plugin:import/typescript'],
settings: {
'import/resolver': {
typescript: {
project: 'lpm_frontend/tsconfig.json'
}
}
}
};

View File

@ -0,0 +1,74 @@
// https://eslint.org/docs/latest/rules/
module.exports = {
extends: ['eslint:recommended'],
rules: {
'array-callback-return': 'error',
'no-console': [
'warn',
{
allow: ['info', 'warn', 'error']
}
],
'no-else-return': [
'warn',
{
allowElseIf: false
}
],
'no-empty': [
'error',
{
allowEmptyCatch: true
}
],
'no-implicit-coercion': [
'warn',
{
allow: ['!!', '+', '~']
}
],
'no-param-reassign': [
'warn',
{
props: true,
ignorePropertyModificationsFor: ['event', 'e']
}
],
'no-nested-ternary': 'off',
'no-new': 'warn',
'no-unused-vars': [
'error',
{
ignoreRestSiblings: true,
argsIgnorePattern: '^-',
destructuredArrayIgnorePattern: '^_'
}
],
'no-shadow': [
'error',
{
builtinGlobals: true,
hoist: 'all'
}
],
'padding-line-between-statements': [
'warn',
{
blankLine: 'always',
next: '*',
prev: ['const', 'let', 'var', 'if', 'for', 'while', 'switch', 'try']
},
{
blankLine: 'any',
next: ['const', 'let', 'var'],
prev: ['const', 'let', 'var']
},
{
blankLine: 'always',
next: ['return', 'throw', 'break', 'continue', 'if', 'for', 'while', 'switch', 'try'],
prev: '*'
}
],
quotes: ['warn', 'single']
}
};

View File

@ -0,0 +1,5 @@
// https://github.com/prettier/eslint-plugin-prettier
// https://github.com/prettier/eslint-config-prettier#installation
module.exports = {
extends: ['plugin:prettier/recommended']
};

50
lpm_frontend/rules/eslint/react.js vendored Normal file
View File

@ -0,0 +1,50 @@
// https://github.com/jsx-eslint/eslint-plugin-react#list-of-supported-rules
module.exports = {
extends: [
'plugin:react/recommended',
'plugin:react/jsx-runtime',
'plugin:react-hooks/recommended'
],
settings: {
react: {
version: 'detect'
}
},
rules: {
'react/destructuring-assignment': ['warn', 'always'],
'react/jsx-filename-extension': [
'error',
{
extensions: ['jsx', 'tsx']
}
],
'react/jsx-handler-names': [
'warn',
{
checkLocalVariables: true
}
],
'react/jsx-no-useless-fragment': [
'warn',
{
allowExpressions: true
}
],
'react/jsx-one-expression-per-line': [
'warn',
{
allow: 'single-child'
}
],
'react/jsx-sort-props': [
'warn',
{
// multiline: 'last'
reservedFirst: true
}
],
'react/no-unstable-nested-components': 'error',
'react/no-unused-class-component-methods': 'error',
'react/self-closing-comp': 'warn'
}
};

View File

@ -0,0 +1,71 @@
// https://typescript-eslint.io/rules/
module.exports = {
extends: ['plugin:@typescript-eslint/recommended'],
parser: '@typescript-eslint/parser',
parserOptions: {
project: ['./tsconfig.json'],
createDefaultProgram: true
},
overrides: [
{
files: ['*.ts', '*.tsx'],
rules: {
'@typescript-eslint/explicit-module-boundary-types': 'warn'
}
}
],
rules: {
'@typescript-eslint/array-type': [
'warn',
{
default: 'array'
}
],
'comma-dangle': 'off',
'@typescript-eslint/comma-dangle': 'warn',
'@typescript-eslint/consistent-type-exports': 'warn',
'@typescript-eslint/consistent-type-imports': 'warn',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'no-empty-function': 'off',
'@typescript-eslint/no-empty-function': 'warn',
'@typescript-eslint/no-empty-interface': [
'warn',
{
allowSingleExtends: true
}
],
'@typescript-eslint/no-explicit-any': 'warn',
'no-loss-of-precision': 'off',
'@typescript-eslint/no-loss-of-precision': 'error',
'no-redeclare': 'off',
'@typescript-eslint/no-redeclare': 'error',
'@typescript-eslint/no-unnecessary-condition': 'warn',
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': [
'error',
{
ignoreRestSiblings: true,
argsIgnorePattern: '^_',
destructuredArrayIgnorePattern: '^_'
}
],
'no-shadow': 'off',
'@typescript-eslint/no-shadow': [
'error',
{
// builtinGlobals: true,
hoist: 'all',
ignoreTypeValueShadow: false,
ignoreFunctionTypeParameterNameValueShadow: false
}
],
'@typescript-eslint/non-nullable-type-assertion-style': 'warn',
'@typescript-eslint/prefer-enum-initializers': 'warn',
'@typescript-eslint/prefer-literal-enum-member': 'warn',
'@typescript-eslint/prefer-optional-chain': 'warn',
'@typescript-eslint/prefer-ts-expect-error': 'warn',
quotes: 'off',
'@typescript-eslint/quotes': ['warn', 'single']
}
};

View File

@ -0,0 +1,71 @@
'use client';
import RegisterUploadModal from '@/components/upload/RegisterUploadModal';
import { EVENT } from '@/utils/event';
import { Modal } from 'antd';
import { useEffect, useState } from 'react';
export default function ApplicationsLayout({ children }: { children: React.ReactNode }) {
const [showRegisterModal, setShowRegisterModal] = useState(false);
const [showPublishModal, setShowPublishModal] = useState(false);
useEffect(() => {
const handleShowRegister = () => {
setShowRegisterModal(true);
};
addEventListener(EVENT.SHOW_REGISTER_MODAL, handleShowRegister);
return () => {
removeEventListener(EVENT.SHOW_REGISTER_MODAL, handleShowRegister);
};
}, []);
return (
<div className="h-full bg-secondme-warm-bg">
{children}
{/* Register AI Modal */}
<Modal
footer={[
<button
key="close"
className="px-4 py-2 text-sm font-medium bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors mr-2"
onClick={() => setShowRegisterModal(false)}
>
Cancel
</button>,
<button
key="register"
className="px-4 py-2 text-sm font-medium bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
onClick={() => {
setShowRegisterModal(false);
setShowPublishModal(true);
}}
>
Go to Register
</button>
]}
onCancel={() => setShowRegisterModal(false)}
open={showRegisterModal}
title="AI Registration Required"
>
<div className="py-4">
<p className="text-gray-600 mb-4">
You need to register (publish) your AI before you can access this feature.
</p>
<p className="text-gray-600">
Registration allows your AI to be fully activated and enables all application features.
</p>
</div>
</Modal>
{/* Publish Second Me Modal */}
<RegisterUploadModal
onClose={() => {
setShowPublishModal(false);
}}
open={showPublishModal}
/>
</div>
);
}

View File

@ -0,0 +1,374 @@
'use client';
import { useState, useEffect, useRef, useMemo } from 'react';
import CreateSpaceModal from '@/components/spaces/CreateSpaceModal';
import ShareSpaceModal from '@/components/spaces/ShareSpaceModal';
import DeleteSpaceModal from '@/components/spaces/DeleteSpaceModal';
import { useSpaceStore } from '@/store/useSpaceStore';
import { Spin, message } from 'antd';
import { useUploadStore } from '@/store/useUploadStore';
import type { SpaceInfo } from '@/service/space';
import { shareSpace } from '@/service/space';
import { useLoadInfoStore } from '@/store/useLoadInfoStore';
import { EVENT } from '@/utils/event';
// Mock data for demonstration - will be replaced with real data
interface spaceStatus {
space_id: string;
status: number;
}
export default function NetworkApps() {
const [showCreateModal, setShowCreateModal] = useState(false);
const [showShareModal, setShowShareModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [selectedSpace, setSelectedSpace] = useState<any>(null);
const [deleting, setDeleting] = useState(false);
const loadInfo = useLoadInfoStore((state) => state.loadInfo);
const isRegistered = useMemo(() => {
return loadInfo?.status === 'online';
}, [loadInfo]);
const uploads = useUploadStore((state) => state.uploads);
const currentRegisteredUpload = useMemo(() => {
return uploads.find((upload) => upload.instance_id === loadInfo?.instance_id);
}, [uploads, loadInfo]);
const [shareSpaceId, setShareSpaceId] = useState<string>('');
const channel = new BroadcastChannel('updateSpace');
const channelDataRef = useRef<spaceStatus[]>([]);
const spaces = useSpaceStore((state) => state.spaces);
const loading = useSpaceStore((state) => state.loading);
const error = useSpaceStore((state) => state.error);
const fetchAllSpaces = useSpaceStore((state) => state.fetchAllSpaces);
const updateSpaceStatus = useSpaceStore((state) => state.updateSpaceStatus);
const deleteSpace = useSpaceStore((state) => state.deleteSpace);
useEffect(() => {
if (!isRegistered) {
dispatchEvent(new Event(EVENT.SHOW_REGISTER_MODAL));
}
}, [isRegistered]);
// Fetch spaces when component mounts - use empty dependency array to run only once
useEffect(() => {
console.log('Fetching spaces...');
fetchAllSpaces();
}, []);
// Show error message if there's an error
useEffect(() => {
if (error) {
message.error(error);
}
}, [error]);
const handleCreateSpace = () => {
// Just close the modal - the space opening is handled in CreateSpaceModal
setShowCreateModal(false);
fetchAllSpaces();
// No need to open the space here as it's already done in the CreateSpaceModal component
};
const handleSpaceClick = (space_id: string) => {
window.open(`/standalone/space/${space_id}`, '_blank');
};
channel.onmessage = (event) => {
const data = event.data;
if (
data.type === 'updateSpaceStatus' &&
data.space_id &&
!channelDataRef.current.some((s) => s.space_id === data.space_id && s.status === data.status)
) {
channelDataRef.current = channelDataRef.current.filter((s) => s.space_id !== data.space_id);
channelDataRef.current.push({ space_id: data.space_id, status: data.status });
updateSpaceStatus(data.space_id, data.status);
}
};
// Function to format the date
const formatDate = (dateString: string) => {
if (!dateString) return 'Unknown date';
try {
const date = new Date(dateString);
// Format to show year, month, and day
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
} catch (_error: any) {
console.error('Date formatting error:', _error.message);
return 'Invalid date';
}
};
const renderSpaceCard = (space: SpaceInfo) => {
return (
<div
key={space.id}
className="bg-white rounded-lg shadow-sm border border-gray-200 hover:shadow-md transition-shadow duration-200 overflow-hidden cursor-pointer"
onClick={() => handleSpaceClick(space.id)}
>
<div className="p-6">
<div className="flex justify-between items-start mb-4">
<div>
<h3 className="text-lg font-semibold text-gray-900">
{space.title || 'Untitled Space'}
</h3>
<p className="mt-1 text-sm text-gray-600">
{space.objective || 'No objective provided'}
</p>
</div>
<div className="flex items-center space-x-2">
<button
className="text-gray-400 hover:text-blue-500 transition-colors"
onClick={(e) => {
e.stopPropagation();
setSelectedSpace(space);
handleShare(space.id);
}}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
/>
</svg>
</button>
<button
className="text-gray-400 hover:text-red-500 transition-colors"
onClick={(e) => {
e.stopPropagation();
setSelectedSpace(space);
setShowDeleteModal(true);
}}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
/>
</svg>
</button>
{space.status === 4 ? (
<span className="px-2.5 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
Finished
</span>
) : space.status === 3 ? (
<span className="px-2.5 py-1 rounded-full text-xs font-medium bg-red-100 text-red-800">
Failed
</span>
) : (
<span className="px-2.5 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">
Discussing
</span>
)}
</div>
</div>
<div className="mb-4">
<h4 className="text-sm font-medium text-gray-700 mb-2">Participants</h4>
<div className="flex flex-wrap gap-2">
{Array.isArray(space.participants) && space.participants.length > 0 ? (
// Sort participants to ensure the host is listed first
[...space.participants]
.sort((a, b) => {
if (a === space.host) return -1;
if (b === space.host) return 1;
return 0;
})
.map((participant, index) => {
// Extract name from participant URL more effectively
const extractNameFromUrl = (url: string): string => {
try {
// Try to extract name from URL pattern
const urlParts = url.split('/');
if (urlParts.length >= 4) {
return urlParts[3]; // This should be the Second Me name
}
return url.split('/').pop() || `Participant ${index}`;
} catch (_error: any) {
console.error('Error extracting name from URL:', _error.message);
return `Participant ${index}`;
}
};
const participantName = extractNameFromUrl(participant);
const isHost = participant === space.host;
return (
<div
key={index}
className="inline-flex items-center space-x-1 px-2 py-1 rounded-full bg-blue-50 border border-blue-100"
>
<img
alt={participantName}
className="w-5 h-5 rounded-full"
src={`https://api.dicebear.com/7.x/avataaars/svg?seed=${participant || index}`}
/>
<span className="text-xs font-medium text-blue-800">
{isHost ? `${participantName} (Host)` : participantName}
</span>
</div>
);
})
) : (
<div className="text-xs text-gray-500">No participants</div>
)}
</div>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-gray-500">Created {formatDate(space.create_time)}</span>
<div className="flex gap-2">
<button
className="text-blue-600 hover:text-blue-800 font-medium"
onClick={(e) => {
e.stopPropagation();
handleSpaceClick(space.id);
}}
>
View Space
</button>
</div>
</div>
</div>
</div>
);
};
const handleShare = async (space_id: string) => {
shareSpace(space_id).then((res) => {
if (res.data.code == 0) {
setShareSpaceId(res.data.data.space_share_id);
setShowShareModal(true);
} else {
message.error(res.data.message);
}
});
};
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex justify-between items-center mb-8">
<div>
<h1 className="text-2xl font-bold text-gray-900">My Network Apps</h1>
<p className="mt-2 text-sm text-gray-600">
Create a multi-AI collaboration space where multiple Second Mes work together to
complete shared missions.
</p>
</div>
<button
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
onClick={() => setShowCreateModal(true)}
>
<svg className="-ml-1 mr-2 h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path d="M12 4v16m8-8H4" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
</svg>
Create New Space
</button>
</div>
{loading ? (
<div className="flex justify-center items-center py-20">
<Spin size="large" />
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{spaces && spaces.length > 0 ? (
spaces.map((space) => renderSpaceCard(space))
) : (
<div className="col-span-3 text-center py-10">
<p className="text-gray-500">
No spaces found. Create your first space to get started!
</p>
</div>
)}
</div>
)}
<CreateSpaceModal
currentSecondMe={`https://app.secondme.io/${currentRegisteredUpload?.upload_name}/${currentRegisteredUpload?.instance_id}`}
onClose={() => setShowCreateModal(false)}
onSubmit={handleCreateSpace}
open={showCreateModal}
/>
<ShareSpaceModal
isRegistered={isRegistered}
onClose={() => {
setShowShareModal(false);
setSelectedSpace(null);
}}
open={showShareModal}
space_id={shareSpaceId || ''}
/>
<DeleteSpaceModal
loading={deleting}
onClose={() => {
setShowDeleteModal(false);
setSelectedSpace(null);
}}
onConfirm={async () => {
setDeleting(true);
try {
await deleteSpace(selectedSpace.id);
message.success('Space deleted successfully');
setShowDeleteModal(false);
setSelectedSpace(null);
await fetchAllSpaces();
} catch (_error: any) {
message.error(_error.message || 'Failed to delete space');
} finally {
setDeleting(false);
}
}}
open={showDeleteModal}
spaceName={selectedSpace?.title || ''}
/>
{/* Example section */}
<div className="relative z-10 mt-8 text-right text-sm text-gray-500">
<p className="text-right mb-2">Try examples:</p>
<div className="flex gap-4 justify-end">
<a
className="hover:text-gray-700 hover:underline"
href="https://app.secondme.io/example/brainstorming"
rel="noopener noreferrer"
target="_blank"
>
Brainstorming (Network)
</a>
<span></span>
<a
className="hover:text-gray-700 hover:underline"
href="https://app.secondme.io/example/Icebreaker"
rel="noopener noreferrer"
target="_blank"
>
Icebreaker (Network)
</a>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,102 @@
'use client';
import { useRouter } from 'next/navigation';
import Image from 'next/image';
import { useMemo, useState } from 'react';
import { Modal } from 'antd';
import RegisterUploadModal from '@/components/upload/RegisterUploadModal';
import { ROUTER_PATH } from '@/utils/router';
import { useLoadInfoStore } from '@/store/useLoadInfoStore';
import { EVENT } from '@/utils/event';
interface ApplicationCard {
title: string;
subtitle?: string;
description: string;
image: string;
route: string;
}
const applications: ApplicationCard[] = [
{
title: 'Roleplay Apps',
description:
'Give your Second Me different personas to express themselves naturally in various scenarios.',
image: '/images/app_secondme_apps.png',
route: ROUTER_PATH.APPLICATIONS_ROLEPLAY
},
{
title: 'Network Apps',
description:
'Create spaces where multiple Second Mes work together to complete shared missions.',
image: '/images/app_secondme_network.png',
route: ROUTER_PATH.APPLICATIONS_NETWORK
},
{
title: 'Second X Apps',
description:
'Future services natively-built for Second Me to use: Second Tinder, Second Linkedin, etc.',
// description: 'Envision a world where software services are built to serve your digital self. "Second X" is our vision for next-gen apps that support Second Me agents directly. Stay tuned—this feature is not yet available.',
image: '/images/app_native_applications.png',
route: ROUTER_PATH.APPLICATIONS_SECOND_X
}
];
export default function ApplicationsPage() {
const router = useRouter();
const loadInfo = useLoadInfoStore((state) => state.loadInfo);
const isRegistered = useMemo(() => {
return loadInfo?.status === 'online';
}, [loadInfo]);
return (
<div className="h-full w-full flex flex-col p-4 pt-12">
<div className="max-w-6xl w-full mx-auto">
<div className="mb-14">
<h1 className="text-3xl font-bold text-gray-900 mb-3">
Second Me is the Foundation to Build Your Identity Apps
</h1>
<p className="text-lg text-gray-600 max-w-3xl">
Beyond basic chat, you create specialized roles, personalized tasks, collaborate in
multi-AI spaces, or explore the future with Second X.
</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-6">
{applications.map((app, index) => (
<div
key={index}
className={`rounded-2xl overflow-hidden border-2 border-gray-800/10 hover:border-gray-800/20
transition-all cursor-pointer bg-white shadow-[4px_4px_0px_0px_rgba(0,0,0,0.05)]
hover:shadow-[6px_6px_0px_0px_rgba(0,0,0,0.08)] hover:-translate-y-0.5
${!app.route && 'opacity-90 cursor-not-allowed'}`}
onClick={() => {
if (!isRegistered) {
dispatchEvent(new Event(EVENT.SHOW_REGISTER_MODAL));
} else if (app.route) {
router.push(app.route);
}
}}
>
<div className="relative w-full pt-[80%] group">
<div className="absolute inset-0 bg-gradient-to-b from-transparent to-black/5 group-hover:to-black/10 transition-all" />
<Image
alt={app.title}
className="object-cover absolute inset-0 transition-transform group-hover:scale-105 object-center"
fill
sizes="(max-width: 768px) 50vw, 25vw"
src={app.image}
/>
</div>
<div className="p-4 bg-gradient-to-b from-white to-gray-50">
<h2 className="text-base font-semibold mb-1.5 text-gray-800">{app.title}</h2>
{app.subtitle && <p className="text-sm text-gray-600 mb-1.5">{app.subtitle}</p>}
<p className="text-sm text-gray-600/90 leading-relaxed">{app.description}</p>
</div>
</div>
))}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,395 @@
'use client';
import { useState, useEffect, useMemo } from 'react';
import CreateRoleModal from '@/components/roleplay/CreateRoleModal';
import EditRoleModal from '@/components/roleplay/EditRoleModal';
import DeleteRoleModal from '@/components/roleplay/DeleteRoleModal';
import ShareRoleModal from '@/components/roleplay/ShareRoleModal';
import {
createRole,
getRoleList,
updateRole,
deleteRole,
uploadRole,
type RoleRes,
type CreateRoleReq,
type UpdateRoleReq
} from '@/service/role';
import { message } from 'antd';
import { useLoadInfoStore } from '@/store/useLoadInfoStore';
import { EVENT } from '@/utils/event';
export default function Roleplay() {
const [showCreateModal, setShowCreateModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [showShareModal, setShowShareModal] = useState(false);
const [roles, setRoles] = useState<RoleRes[]>([]);
const [loading, setLoading] = useState(false);
const [creating, setCreating] = useState(false);
const [editing, setEditing] = useState(false);
const [deleting, setDeleting] = useState(false);
const [selectedRole, setSelectedRole] = useState<RoleRes | null>(null);
const [editForm, setEditForm] = useState<UpdateRoleReq>({
name: '',
description: '',
system_prompt: '',
icon: '',
is_active: true,
enable_l0_retrieval: true
});
const loadInfo = useLoadInfoStore((state) => state.loadInfo);
const isRegistered = useMemo(() => {
return loadInfo?.status === 'online';
}, [loadInfo]);
const [messageApi, contextHolder] = message.useMessage();
useEffect(() => {
if (!isRegistered) {
dispatchEvent(new Event(EVENT.SHOW_REGISTER_MODAL));
}
}, [isRegistered]);
// Load role list
const loadRoles = async () => {
setLoading(true);
try {
const res = await getRoleList();
setRoles(res.data.data);
} catch (error) {
messageApi.error(`Failed to load roles: ${error}`);
} finally {
setLoading(false);
}
};
// Initial loading
useEffect(() => {
loadRoles();
}, []);
const handleCreateRole = async (roleData: CreateRoleReq) => {
setCreating(true);
createRole(roleData)
.then((res) => {
if (res.data.code !== 0) {
throw new Error(res.data.message);
}
setRoles((prev) => [res.data.data, ...prev]);
setShowCreateModal(false);
messageApi.success('Role created successfully');
})
.catch((error: any) => {
messageApi.error(
`Failed to create role: ${error?.response?.data?.message || error.message}`
);
})
.finally(() => {
setCreating(false);
});
};
const handleShareRole = (role: RoleRes) => {
if (!isRegistered) {
messageApi.error('Please join AI network first');
return;
}
const data = {
role_id: role.uuid
};
uploadRole(data).then((res) => {
if (res.data.code === 0) {
setShowShareModal(true);
} else {
messageApi.error('Failed to share role');
}
});
};
const handleRoleClick = (role_id: string) => {
// Open in a new tab
window.open(`/standalone/role/${role_id}`, '_blank');
};
const handleEditRole = async (uuid: string, data: UpdateRoleReq) => {
console.log(uuid, 'uuid');
setEditing(true);
try {
const res = await updateRole(uuid, data);
if (res.data.code === 0) {
setRoles((prev) => prev.map((role) => (role.uuid === uuid ? res.data.data : role)));
setShowEditModal(false);
messageApi.success('Role updated successfully');
} else {
messageApi.error('Failed to update role');
}
} catch (error) {
messageApi.error(`Failed to update role: ${error}`);
} finally {
setEditing(false);
setSelectedRole(null);
}
};
const handleDeleteRole = async (uuid: string) => {
setDeleting(true);
try {
const res = await deleteRole(uuid);
if (res.data.code === 0) {
setRoles((prev) => prev.filter((role) => role.uuid !== uuid));
setShowDeleteModal(false);
messageApi.success('Role deleted successfully');
} else {
messageApi.error('Failed to delete role');
}
} catch (error) {
messageApi.error(`Failed to delete role: ${error}`);
} finally {
setDeleting(false);
setSelectedRole(null);
}
};
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
{contextHolder}
<div className="flex justify-between items-center mb-8">
<div>
<h1 className="text-2xl font-bold text-gray-900">My Roleplay Apps</h1>
<p className="mt-2 text-sm text-gray-600">
{`Create and manage specialized 'roles' for your Second Me. Each role has a specific
purpose, style, or domain.`}
</p>
</div>
<div className="flex gap-3">
<button
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-all hover:-translate-y-0.5 disabled:bg-blue-400 disabled:hover:translate-y-0"
disabled={loading || creating}
onClick={() => setShowCreateModal(true)}
>
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
d="M12 4v16m8-8H4"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
/>
</svg>
Create Roleplay App
</button>
</div>
</div>
{loading ? (
<div className="flex justify-center items-center py-12">
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-blue-600" />
</div>
) : roles.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 px-4">
<div className="text-center mb-6">
<svg
className="mx-auto h-12 w-12 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
/>
</svg>
<h3 className="mt-4 text-lg font-medium text-gray-900">No apps created</h3>
<p className="mt-1 text-sm text-gray-500">
Get started by creating your first SecondMe app!
</p>
</div>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{roles.map((role) => (
<div
key={role.uuid}
className="group relative bg-white rounded-lg shadow-sm overflow-hidden ring-1 ring-black/5 hover:shadow-lg transition-all duration-200 hover:-translate-y-1 cursor-pointer"
onClick={() => handleRoleClick(role.uuid)}
>
<div className="p-6">
<div className="flex flex-col mb-4">
<div className="flex items-start">
<h3 className="text-lg font-semibold line-clamp-1 text-gray-900 group-hover:text-blue-600 transition-colors">
{role.name}
</h3>
<div className="ml-auto flex space-x-2">
<button
className="text-gray-400 hover:text-blue-500 transition-colors"
onClick={(e) => {
e.stopPropagation();
setSelectedRole(role);
setEditForm({
name: role.name,
description: role.description,
system_prompt: role.system_prompt,
icon: role.icon,
is_active: role.is_active,
enable_l0_retrieval: role.enable_l0_retrieval
});
setShowEditModal(true);
}}
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M16.5 3.5L20.5 7.5L7 21H3V17L16.5 3.5Z"
strokeLinejoin="round"
strokeWidth="1.5"
/>
<path d="M14 6L18 10" strokeLinejoin="round" strokeWidth="1.5" />
</svg>
</button>
<button
className={`text-gray-400 hover:text-blue-500 transition-colors`}
onClick={(e) => {
e.stopPropagation();
setSelectedRole(role);
handleShareRole(role);
}}
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
/>
</svg>
</button>
<button
className="text-gray-400 hover:text-red-500 transition-colors"
onClick={(e) => {
e.stopPropagation();
setSelectedRole(role);
setShowDeleteModal(true);
}}
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
/>
</svg>
</button>
</div>
</div>
<span className="mt-1 w-fit inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
Roleplay App
</span>
</div>
<p className="text-sm text-gray-600 line-clamp-2 mb-4">{role.description}</p>
<div className="flex items-center justify-end text-sm text-gray-500">
<button className="inline-flex items-center text-gray-600 hover:text-blue-600 transition-colors">
View App
<svg
className="ml-2 w-4 h-4 transition-transform group-hover:translate-x-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M9 5l7 7-7 7"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
/>
</svg>
</button>
</div>
</div>
</div>
))}
</div>
)}
<CreateRoleModal
loading={creating}
onClose={() => setShowCreateModal(false)}
onSubmit={handleCreateRole}
open={showCreateModal}
/>
<EditRoleModal
initialData={editForm}
loading={editing}
onClose={() => {
setShowEditModal(false);
setSelectedRole(null);
}}
onSubmit={(data) => handleEditRole(selectedRole!.uuid, data)}
open={showEditModal}
/>
<DeleteRoleModal
loading={deleting}
onClose={() => {
setShowDeleteModal(false);
setSelectedRole(null);
}}
onConfirm={() => handleDeleteRole(selectedRole!.uuid)}
open={showDeleteModal}
/>
<ShareRoleModal
isRegistered={isRegistered}
onClose={() => {
setShowShareModal(false);
setSelectedRole(null);
}}
open={showShareModal}
uuid={selectedRole?.uuid || ''}
/>
{/* Example section */}
<div className="relative z-10 mt-8 text-right text-sm text-gray-500">
<p className="text-right mb-2">Try example:</p>
<a
className="hover:text-gray-700 hover:underline"
href="https://app.secondme.io/example/ama"
rel="noopener noreferrer"
target="_blank"
>
Felix AMA (Roleplay)
</a>
</div>
</div>
);
}

View File

@ -0,0 +1,143 @@
'use client';
import { useLoadInfoStore } from '@/store/useLoadInfoStore';
import { EVENT } from '@/utils/event';
import { useEffect, useMemo } from 'react';
export default function NativeApplications() {
const loadInfo = useLoadInfoStore((state) => state.loadInfo);
const isRegistered = useMemo(() => {
return loadInfo?.status === 'online';
}, [loadInfo]);
useEffect(() => {
if (!isRegistered) {
dispatchEvent(new Event(EVENT.SHOW_REGISTER_MODAL));
}
}, [isRegistered]);
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 bg-secondme-warm-bg rounded-xl">
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900">Second X Apps</h1>
<p className="mt-2 text-sm text-gray-600">
Second X Apps transforms traditional human-centric platforms into services designed for
Second Me. These next-generation applications enable your Second Me to navigate the
digital world autonomously, making decisions and building connections while preserving
your time and energy.
{/* Future services natively-built for Second Me to use: Second Tinder, Second Linkedin, etc. */}
</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-2">
{/* Second Tinder - Disabled */}
<div className="group relative bg-white rounded-lg shadow-sm overflow-hidden ring-1 ring-black/5 opacity-50 cursor-not-allowed">
<div className="p-6">
<div className="flex items-center space-x-3 min-h-[24px]">
<svg
className="w-6 h-6 text-secondme-red"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
/>
</svg>
<h3 className="text-lg font-medium text-gray-900">Second Tinder</h3>
<span className="text-xs text-gray-500">(Coming Soon)</span>
</div>
<p className="mt-3 text-sm text-gray-500">
A dating platform built specifically for Second Me. Your AI self can independently
explore relationships and connections in their own social space.
</p>
</div>
</div>
{/* Second LinkedIn - Disabled */}
<div className="group relative bg-white rounded-lg shadow-sm overflow-hidden ring-1 ring-black/5 opacity-50 cursor-not-allowed">
<div className="p-6">
<div className="flex items-center space-x-3 min-h-[24px]">
<svg
className="w-6 h-6 text-secondme-blue"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
/>
</svg>
<h3 className="text-lg font-medium text-gray-900">Second LinkedIn</h3>
<span className="text-xs text-gray-500">(Coming Soon)</span>
</div>
<p className="mt-3 text-sm text-gray-500">
A professional networking platform designed exclusively for Second Me to build their
own career network and professional relationships.
</p>
</div>
</div>
{/* Second Airbnb - Disabled */}
<div className="group relative bg-white rounded-lg shadow-sm overflow-hidden ring-1 ring-black/5 opacity-50 cursor-not-allowed">
<div className="p-6">
<div className="flex items-center space-x-3 min-h-[24px]">
<svg
className="w-6 h-6 text-secondme-red"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
/>
</svg>
<h3 className="text-lg font-medium text-gray-900">Second Airbnb</h3>
<span className="text-xs text-gray-500">(Coming Soon)</span>
</div>
<p className="mt-3 text-sm text-gray-500">
A hospitality platform where your Second Me acts as your digital property manager,
handling guest communications and optimizing rentals.
</p>
</div>
</div>
{/* Second OnlyFans - Disabled */}
<div className="group relative bg-white rounded-lg shadow-sm overflow-hidden ring-1 ring-black/5 opacity-50 cursor-not-allowed">
<div className="p-6">
<div className="flex items-center space-x-3 min-h-[24px]">
<svg
className="w-6 h-6 text-secondme-green"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2-1.343-2-3-2zM17 15v-2a4 4 0 00-4-4H7a4 4 0 00-4 4v2"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
/>
</svg>
<h3 className="text-lg font-medium text-gray-900">Second OnlyFans</h3>
<span className="text-xs text-gray-500">(Coming Soon)</span>
</div>
<p className="mt-3 text-sm text-gray-500">
A creator platform where your Second Me operates as a digital content creator,
providing personalized content and engaging with subscribers.
</p>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,20 @@
'use client';
import DashboardLayout from '@/layouts/DashboardLayout';
import { Suspense } from 'react';
interface IProps {
children: React.ReactNode;
}
function Layout(props: IProps): JSX.Element | React.ReactNode | null {
const { children } = props;
return (
<Suspense>
<DashboardLayout>{children}</DashboardLayout>
</Suspense>
);
}
export default Layout;

View File

@ -0,0 +1,15 @@
'use client';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
import { ROUTER_PATH } from '@/utils/router';
export default function DashboardPage() {
const router = useRouter();
useEffect(() => {
router.push(ROUTER_PATH.TRAIN_IDENTITY);
}, [router]);
return null;
}

View File

@ -0,0 +1,186 @@
'use client';
import { useState } from 'react';
interface Result {
id: string;
input: string;
output: string;
type: 'enhancement' | 'critic';
timestamp: string;
}
export default function BridgeMode() {
const [results, setResults] = useState<Result[]>([]);
const [enhancementInput, setEnhancementInput] = useState('');
const [criticInput, setCriticInput] = useState('');
const [loading, setLoading] = useState({
enhancement: false,
critic: false
});
const handleEnhancement = async () => {
if (!enhancementInput.trim()) return;
setLoading((prev) => ({ ...prev, enhancement: true }));
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1000));
const newResult: Result = {
id: Date.now().toString(),
input: enhancementInput,
output: `Enhanced version: ${enhancementInput}\n\nAdditional context-aware details have been integrated into the response.`,
type: 'enhancement',
timestamp: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
};
setResults((prev) => [newResult, ...prev]);
setEnhancementInput('');
setLoading((prev) => ({ ...prev, enhancement: false }));
};
const handleCritic = async () => {
if (!criticInput.trim()) return;
setLoading((prev) => ({ ...prev, critic: true }));
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1000));
const newResult: Result = {
id: Date.now().toString(),
input: criticInput,
output:
'Analysis:\n\n1. Accuracy: The response appears to be [assessment]\n2. Relevance: [evaluation of relevance]\n3. Completeness: [evaluation of completeness]\n4. Suggestions: [recommendations for improvement]',
type: 'critic',
timestamp: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
};
setResults((prev) => [newResult, ...prev]);
setCriticInput('');
setLoading((prev) => ({ ...prev, critic: false }));
};
return (
<div className="h-full w-full flex flex-col">
<div className="flex-1 p-6 overflow-auto">
<div className="max-w-6xl mx-auto">
<div className="mb-6">
<h1 className="text-2xl font-semibold text-gray-900">Bridge Mode</h1>
<p className="text-sm text-gray-600 mt-1">
Bridge Mode acts as a context-aware bridge between you and other AI systems or
services. Your Second Me personalizes both outgoing and incoming information, creating
a seamless experience tailored uniquely to you. This bidirectional context enhancement
is a core function of Second Me.
</p>
</div>
{/* Content area with frosted glass overlay */}
<div className="relative min-h-[600px] rounded-lg">
{/* Frosted glass overlay */}
<div className="absolute inset-0 backdrop-blur-md bg-white/70 flex flex-col items-center justify-center z-20 rounded-lg">
<div className="text-3xl font-bold text-gray-800">Coming Soon</div>
<p className="text-sm text-gray-600 mt-2 text-center px-4">
{`We're working on this feature. Stay tuned!`}
</p>
</div>
{/* Content that will be blurred */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 p-4">
{/* Enhancement Section */}
<div className="space-y-4">
<div className="bg-white rounded-lg shadow-sm p-4">
<h3 className="text-lg font-semibold mb-4">Context Enhancement</h3>
<div className="space-y-4">
<textarea
className="w-full h-32 px-3 py-2 border rounded-lg resize-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
onChange={(e) => setEnhancementInput(e.target.value)}
placeholder="Enter text to enhance with your personal context..."
value={enhancementInput}
/>
<button
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed flex items-center justify-center"
disabled={loading.enhancement}
onClick={handleEnhancement}
>
{loading.enhancement ? (
<span className="animate-pulse">Enhancing...</span>
) : (
'Enhance with Context'
)}
</button>
</div>
</div>
<div className="bg-white rounded-lg shadow-sm p-4">
<h3 className="text-lg font-semibold mb-4">Enhancement Results</h3>
<div className="space-y-4 max-h-96 overflow-y-auto">
{results
.filter((result) => result.type === 'enhancement')
.map((result) => (
<div key={result.id} className="border rounded-lg p-4 space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-gray-500">{result.timestamp}</span>
</div>
<div className="text-sm text-gray-600 bg-gray-50 p-2 rounded">
Input: {result.input}
</div>
<div className="text-sm whitespace-pre-wrap">{result.output}</div>
</div>
))}
</div>
</div>
</div>
{/* Critic Section */}
<div className="space-y-4">
<div className="bg-white rounded-lg shadow-sm p-4">
<h3 className="text-lg font-semibold mb-4">Context Critic</h3>
<div className="space-y-4">
<textarea
className="w-full h-32 px-3 py-2 border rounded-lg resize-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
onChange={(e) => setCriticInput(e.target.value)}
placeholder="Enter AI response to analyze with your personal context..."
value={criticInput}
/>
<button
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed flex items-center justify-center"
disabled={loading.critic}
onClick={handleCritic}
>
{loading.critic ? (
<span className="animate-pulse">Analyzing...</span>
) : (
'Analyze with Context'
)}
</button>
</div>
</div>
<div className="bg-white rounded-lg shadow-sm p-4">
<h3 className="text-lg font-semibold mb-4">Analysis Results</h3>
<div className="space-y-4 max-h-96 overflow-y-auto">
{results
.filter((result) => result.type === 'critic')
.map((result) => (
<div key={result.id} className="border rounded-lg p-4 space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-gray-500">{result.timestamp}</span>
</div>
<div className="text-sm text-gray-600 bg-gray-50 p-2 rounded">
Input: {result.input}
</div>
<div className="text-sm whitespace-pre-wrap">{result.output}</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,312 @@
'use client';
import { useState, useEffect, useRef, useMemo } from 'react';
import ContextSettings from '@/components/playground/ContextSettings';
import ChatInput from '@/components/chat/ChatInput';
import ChatMessage from '@/components/chat/ChatMessage';
import ChatHistory from '@/components/chat/ChatHistory';
import {
type ChatMessage as StorageMessage,
type ChatSession,
chatWithUploadStorage as chatStorage
} from '@/utils/chatStorage';
import type { ChatRequest } from '@/hooks/useSSE';
import { useSSE } from '@/hooks/useSSE';
import { useLoadInfoStore } from '@/store/useLoadInfoStore';
// Use the Message type directly from storage
type Message = StorageMessage;
interface PlaygroundSettings {
enableL0Retrieval: boolean;
enableL1Retrieval: boolean;
enableHelperModel: boolean;
selectedModel: string;
apiKey: string;
systemPrompt: string;
temperature: number;
}
// Constants
const STORAGE_KEY_SETTINGS = 'playgroundSettings';
// Function to generate unique ID
const generateMessageId = () => {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
};
export default function PlaygroundChat() {
const [sessions, setSessions] = useState<ChatSession[]>([]);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
const { sendStreamMessage, streaming, streamContent, stopSSE } = useSSE();
const loadInfo = useLoadInfoStore((state) => state.loadInfo);
const originPrompt = useMemo(() => {
const name = loadInfo?.name || 'user';
return `You are ${name}'s "Second Me", which is a personalized AI created by ${name}. You can help ${name} answer questions based on your understanding of ${name}'s background information and past records.`;
}, [loadInfo]);
const originSettings = useMemo(() => {
return {
enableL0Retrieval: true,
enableL1Retrieval: true,
enableHelperModel: false,
selectedModel: 'ollama',
apiKey: 'http://localhost:11434',
systemPrompt: originPrompt,
temperature: 0.3
};
}, [originPrompt]);
const [settings, setSettings] = useState<PlaygroundSettings>(originSettings);
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setSettings((prev) => {
const newSettings = { ...prev, systemPrompt: originPrompt };
localStorage.setItem(STORAGE_KEY_SETTINGS, JSON.stringify(newSettings));
return newSettings;
});
}, [originPrompt]);
const scrollToBottom = () => {
if (messagesEndRef.current) {
const container = messagesEndRef.current.parentElement;
container?.scrollTo({
top: container.scrollHeight,
behavior: 'smooth'
});
}
};
const updateSessions = () => {
const storedSessions = chatStorage.getSessions();
setSessions(storedSessions);
if (storedSessions.length == 0) {
handleNewChat();
}
};
// When messages are updated, scroll to the bottom
useEffect(() => {
scrollToBottom();
}, [messages, streamContent]);
// First initialization
useEffect(() => {
const storedSessions = chatStorage.getSessions();
setSessions(storedSessions);
if (storedSessions.length == 0) {
handleNewChat();
} else {
setActiveSessionId(storedSessions[0].id);
setMessages(storedSessions[0].messages);
}
}, []);
// Load sessions, messages, and settings from storage on mount
useEffect(() => {
const storedSettings = localStorage.getItem(STORAGE_KEY_SETTINGS);
if (storedSettings) {
try {
setSettings(JSON.parse(storedSettings));
} catch (error) {
console.error('Failed to parse stored settings:', error);
}
}
}, []);
const handleNewChat = () => {
const newSession = chatStorage.createSession();
setSessions((prev) => [newSession, ...prev]);
setActiveSessionId(newSession.id);
setMessages([]);
};
const handleSessionClick = (sessionId: string) => {
setActiveSessionId(sessionId);
stopSSE();
const _sessions = chatStorage.getSessions();
const session = _sessions.find((s) => s.id === sessionId);
if (session) {
setMessages(session.messages);
}
};
const handleSettingsChange = (newSettings: PlaygroundSettings) => {
setSettings(newSettings);
localStorage.setItem(STORAGE_KEY_SETTINGS, JSON.stringify(newSettings));
};
const handleSendMessage = async (content: string) => {
// Create user message
const userMessage: Message = {
id: generateMessageId(),
content,
role: 'user',
timestamp: new Date().toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit'
})
};
// Create an empty assistant message
const assistantMessage: Message = {
id: generateMessageId(),
content: '',
role: 'assistant',
timestamp: new Date().toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit'
})
};
// Update message list, adding user message and empty assistant message
const newMessages = [...messages, userMessage, assistantMessage];
setMessages(newMessages);
// Save messages to session
if (activeSessionId) {
chatStorage.saveSessionMessages(activeSessionId, newMessages);
// If it's the first message in a new session, update session title
if (messages.length === 0) {
const title = content.length > 30 ? content.substring(0, 30) + '...' : content;
chatStorage.updateSession(activeSessionId, { title, lastMessage: content });
setSessions(chatStorage.getSessions());
}
}
// Send request
const chatRequest: ChatRequest = {
message: content,
system_prompt: settings.systemPrompt || '',
enable_l0_retrieval: settings.enableL0Retrieval,
enable_l1_retrieval: settings.enableL1Retrieval,
temperature: settings.temperature,
history: messages.map((msg) => ({
role: msg.role === 'user' ? 'user' : 'assistant',
content: msg.content
}))
};
await sendStreamMessage(chatRequest);
};
// Listen for streamContent changes to update messages
useEffect(() => {
if (!streamContent) return;
const newMessages = messages.map((msg, index) => {
if (index === messages.length - 1 && msg.role === 'assistant') {
return { ...msg, content: streamContent };
}
return msg;
});
setMessages(newMessages);
if (activeSessionId) {
chatStorage.updateSession(activeSessionId, {
lastMessage: streamContent,
messages: newMessages
});
}
}, [streamContent]);
const handleClearChat = () => {
if (activeSessionId) {
chatStorage.saveSessionMessages(activeSessionId, []);
setMessages([]);
}
updateSessions();
};
const handleDeleteChat = (sessionId: string) => {
chatStorage.deleteSession(sessionId);
updateSessions();
if (sessionId === activeSessionId) {
const storedSessions = chatStorage.getSessions();
setActiveSessionId(storedSessions[0]?.id);
setMessages(storedSessions[0]?.messages);
}
};
return (
<div className="h-full w-full flex">
<ChatHistory
activeSessionId={activeSessionId}
onDeleteChat={handleDeleteChat}
onNewChat={handleNewChat}
onSessionClick={handleSessionClick}
sessions={sessions}
/>
{/* Main chat area */}
<div className="flex-1 flex flex-col bg-white">
<div className="flex items-center justify-between px-6 py-3 border-b">
<h2 className="text-lg font-semibold">Chat with Second Me</h2>
<button className="text-sm text-gray-600 hover:text-gray-900" onClick={handleClearChat}>
Clear Chat
</button>
</div>
{/* Chat message area */}
<div className="flex-1 overflow-y-auto px-6 py-6">
{messages.length === 0 ? (
<div className="h-full flex items-center justify-center text-gray-400">
Start a new conversation...
</div>
) : (
<>
<div className="mx-auto w-full space-y-6">
{messages.map((message, index) => (
<ChatMessage
key={message.id}
isLoading={
streaming &&
!streamContent &&
index === messages.length - 1 &&
message.role === 'assistant'
}
isUser={message.role === 'user'}
message={message.content}
timestamp={message.timestamp}
/>
))}
</div>
<div ref={messagesEndRef} />
</>
)}
</div>
{/* Input area */}
<div className="flex-shrink-0 border-t border-gray-200 p-4">
<div className="mx-auto">
<ChatInput disabled={streaming} onSendMessage={handleSendMessage} />
</div>
</div>
</div>
{/* Right settings panel */}
<div className="w-80 border-l border-gray-200 bg-white">
<ContextSettings onSettingsChange={handleSettingsChange} settings={settings} />
</div>
</div>
);
}

View File

@ -0,0 +1,15 @@
'use client';
import { ROUTER_PATH } from '@/utils/router';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
export default function PlaygroundPage() {
const router = useRouter();
useEffect(() => {
router.replace(ROUTER_PATH.PLAYGROUND_CHAT);
}, []);
return null;
}

View File

@ -0,0 +1,247 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { ILoadInfo, updateLoadInfo } from '@/service/info';
import { useLoadInfoStore } from '@/store/useLoadInfoStore';
import { ROUTER_PATH } from '@/utils/router';
import { message } from 'antd';
export default function IdentityPage() {
const pageTitle = 'Define Your Identity';
const pageDescription = "Build your AI's foundation with your basic information.";
const router = useRouter();
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [email, setEmail] = useState('');
const [originalName, setOriginalName] = useState('');
const [originalDescription, setOriginalDescription] = useState('');
const [originalEmail, setOriginalEmail] = useState('');
const [isEdited, setIsEdited] = useState(false);
const [emailError, setEmailError] = useState('');
const loadInfo = useLoadInfoStore((state) => state.loadInfo);
const fetchLoadInfo = useLoadInfoStore((state) => state.fetchLoadInfo);
const setInfo = (localInfo: ILoadInfo) => {
const { name: _name, description: _description, email: _email } = localInfo;
setName(_name);
setDescription(_description);
setEmail(_email || '');
setOriginalName(_name);
setOriginalDescription(_description);
setOriginalEmail(_email || '');
};
useEffect(() => {
const localUploadInfoStr = localStorage.getItem('upload');
if (localUploadInfoStr) {
try {
const localUploadInfo = JSON.parse(localUploadInfoStr);
setInfo(localUploadInfo);
} catch {
console.error('Failed to parse local upload info');
}
}
fetchLoadInfo();
}, []);
useEffect(() => {
if (loadInfo) {
setInfo(loadInfo);
}
}, [loadInfo]);
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setName(e.target.value);
checkIfEdited(e.target.value, description, email);
};
const handleDescriptionChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setDescription(e.target.value);
checkIfEdited(name, e.target.value, email);
};
const validateEmail = (value: string) => {
if (!value) {
setEmailError('Email is required');
return false;
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(value)) {
setEmailError('Please enter a valid email address');
return false;
}
setEmailError('');
return true;
};
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setEmail(value);
validateEmail(value);
checkIfEdited(name, description, value);
};
const checkIfEdited = (newName: string, newDescription: string, newEmail: string) => {
setIsEdited(
newName !== originalName ||
newDescription !== originalDescription ||
newEmail !== originalEmail
);
};
const handleSave = async () => {
if (!name.trim()) {
message.error('Name cannot be empty');
return;
}
if (name.includes(' ')) {
message.error('Name cannot contain spaces');
return;
}
if (!validateEmail(email)) {
return;
}
if (loadInfo) {
// Update user information
try {
const res = await updateLoadInfo({ name, description, email });
if (res.data.code === 0) {
message.success('Identity updated successfully');
// Update local storage
const updatedData = { ...loadInfo, name, description, email };
localStorage.setItem('upload', JSON.stringify(updatedData));
useLoadInfoStore.getState().fetchLoadInfo();
} else {
message.error(res.data.message);
}
} catch (error) {
console.error('Failed to update identity:', error);
message.error('Failed to update identity');
} finally {
setIsEdited(false);
}
}
};
return (
<div className="max-w-6xl mx-auto px-6 py-8 space-y-8 overflow-y-auto h-full">
{/* Page Title and Description */}
<div className="mb-4">
<h1 className="text-xl font-semibold text-gray-900 mb-1">{pageTitle}</h1>
<p className="text-gray-600 max-w-3xl">{pageDescription}</p>
</div>
<div className="bg-white rounded-xl shadow-sm p-4">
<div className="space-y-4">
<div className="space-y-3">
{/* Name section */}
<div>
<label className="block text-[14px] font-medium text-gray-700 mb-0.5">
Second Me Name
</label>
<p className="text-sm text-gray-500 mb-1 leading-relaxed">
This name will represent you, and your Second Me.
</p>
<input
className="w-full px-4 py-2 border border-gray-300 rounded-lg bg-white text-gray-700 focus:border-gray-400 focus:ring-2 focus:ring-gray-400/20 transition-all shadow-[3px_3px_0px_0px_rgba(0,0,0,0.03)]"
maxLength={20}
onChange={handleNameChange}
placeholder="e.g., Felix (no spaces allowed)"
type="text"
value={name}
/>
<p className="mt-0.5 text-xs text-gray-500">{name.length}/20 characters</p>
</div>
<div>
<label className="block text-[14px] font-medium text-gray-700 mb-0.5">
Short Personal Description
</label>
<p className="text-sm text-gray-500 mb-1 leading-relaxed">
Briefly describe yourself: personality, motivation, or style.
</p>
<textarea
className="w-full px-4 py-2 border border-gray-300 rounded-lg bg-white text-gray-700 focus:border-gray-400 focus:ring-2 focus:ring-gray-400/20 transition-all shadow-[3px_3px_0px_0px_rgba(0,0,0,0.03)] resize-none leading-relaxed"
maxLength={200}
onChange={handleDescriptionChange}
placeholder="e.g., 'An adventurous, data-driven, and enjoy learning new technologies.'"
rows={3}
value={description}
/>
<p className="mt-0.5 text-xs text-gray-500">{description.length}/200 characters</p>
</div>
<div>
<label className="block text-[14px] font-medium text-gray-700 mb-0.5">
Email of Second Me
</label>
<p className="text-sm text-gray-500 mb-1 leading-relaxed">
This email will be used as a contact point for your Second Me. You can use your own
email address.
</p>
<input
className={`w-full px-4 py-2 border ${emailError ? 'border-red-500' : 'border-gray-300'} rounded-lg bg-white text-gray-700 focus:border-gray-400 focus:ring-2 focus:ring-gray-400/20 transition-all shadow-[3px_3px_0px_0px_rgba(0,0,0,0.03)]`}
onChange={handleEmailChange}
placeholder="e.g., your.name@example.com"
type="email"
value={email}
/>
{emailError && <p className="mt-1 text-xs text-red-500">{emailError}</p>}
</div>
<div className="flex justify-end pt-2">
<button
className={`px-6 py-2 rounded-lg text-white font-medium transition-all ${
isEdited
? 'bg-blue-500 hover:bg-blue-600 cursor-pointer'
: 'bg-gray-300 cursor-not-allowed'
}`}
disabled={!isEdited}
onClick={() => {
handleSave();
}}
>
Save
</button>
</div>
</div>
</div>
</div>
{/* Next button outside the form */}
<div className="mt-6 flex justify-end">
<button
className="px-4 py-2 text-sm font-medium rounded-lg bg-blue-500 text-white hover:bg-blue-600 transition-colors flex items-center gap-2"
onClick={() => router.push(ROUTER_PATH.TRAIN_MEMORIES)}
>
Next: Upload Memories
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path d="M9 5l7 7-7 7" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
</svg>
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,243 @@
'use client';
import { useRouter } from 'next/navigation';
import { useEffect, useRef, useState } from 'react';
import InfoModal from '@/components/InfoModal';
import MemoryList from '@/components/train/MemoryList';
import UploadMemories from '@/components/train/UploadMemories';
import { message } from 'antd';
import { deleteMemory, getMemoryList } from '@/service/memory';
import { useTrainingStore } from '@/store/useTrainingStore';
import { fileTransformToMemory } from '@/utils/memory';
import { ROUTER_PATH } from '@/utils/router';
import { EVENT } from '@/utils/event';
interface TrainSectionInfo {
name: string;
description: string;
features: string[];
}
const trainSectionInfo: Record<string, TrainSectionInfo> = {
upload: {
name: 'Upload Memories',
description:
'Share your experiences with your SecondMe so it can learn to think and respond like you. ' +
"Each memory you upload becomes part of your AI's lived experience, helping it understand your perspective, values, and communication style. " +
'The more personal context you provide, the more authentic your digital twin becomes.',
features: [
'Drag-and-drop file upload',
'Bulk folder upload support',
'Text input for direct content',
'File size and type validation',
'Upload progress tracking'
]
},
'memory-list': {
name: 'Memory List',
description:
'View and manage all your uploaded training materials. Organize and review your memories before starting the training process.',
features: [
'List view of all memories',
'Memory type identification',
'Size and upload time display',
'Memory content preview',
'Delete and manage memories'
]
}
};
export interface Memory {
id: string;
type: 'text' | 'file' | 'folder';
name: string;
content?: string;
size: string;
uploadedAt: string;
isTrained?: boolean;
}
export default function TrainPage() {
// Title and explanation section
const pageTitle = 'Upload Memories';
const pageDescription =
"Upload content that helps your AI understand you better. These aren't just files—they're experiences and ideas for your Second Me to live through. By processing these memories, your AI learns to see the world as you do, adopting your unique perspective and decision-making patterns.";
const router = useRouter();
const containerRef = useRef<HTMLDivElement>(null);
const [selectedInfo, setSelectedInfo] = useState<string | null>(null);
const [memories, setMemories] = useState<Memory[]>([]);
const setStatus = useTrainingStore((state) => state.setStatus);
useEffect(() => {
const fetchMemories = async () => {
getMemoryList()
.then((res) => {
if (res.data.code !== 0) {
throw new Error(res.data.message);
} else {
const fileList = res.data.data;
const newMemories = fileList.map((file) => fileTransformToMemory(file));
setMemories(newMemories);
// Only update status when there are no training steps
const trainingProgress = useTrainingStore.getState().trainingProgress;
if (trainingProgress.overall === 0) {
setStatus(newMemories.length > 0 ? 'memory_upload' : 'seed_identity');
}
}
})
.catch((error: Error) => {
message.error(error.message);
});
};
fetchMemories();
addEventListener(EVENT.REFRESH_MEMORIES, fetchMemories);
return () => {
removeEventListener(EVENT.REFRESH_MEMORIES, fetchMemories);
};
}, []);
const scrollToBottom = () => {
if (containerRef.current) {
containerRef.current.scrollTo({
top: containerRef.current.scrollHeight,
behavior: 'smooth'
});
}
};
const handleFileUpload = (files: any[]) => {
const newMemories: Memory[] = Array.from(files).map((file) => ({
id: Math.random().toString(),
type: 'file',
name: file.name,
size: `${(file.size / 1024).toFixed(1)} KB`,
uploadedAt: new Date().toLocaleString(),
isTrained: false
}));
setMemories((prev) => {
const updatedMemories = [...newMemories, ...prev];
const trainingProgress = useTrainingStore.getState().trainingProgress;
if (trainingProgress.overall === 0) {
setStatus(newMemories.length > 0 ? 'memory_upload' : 'seed_identity');
}
setTimeout(scrollToBottom, 100);
return updatedMemories;
});
};
const handleDeleteMemory = async (id: string, name: string) => {
const res = await deleteMemory(name);
if (res.data.code === 0) {
setMemories((prev) => {
const updatedMemories = prev.filter((memory) => memory.id !== id);
const trainingProgress = useTrainingStore.getState().trainingProgress;
if (trainingProgress.overall === 0) {
setStatus(updatedMemories.length > 0 ? 'memory_upload' : 'seed_identity');
}
return updatedMemories;
});
message.success(`Memory "${name}" deleted successfully!`);
} else {
message.error(res.data.message);
}
};
const renderInfoButton = (section: string) => (
<button
className="ml-auto p-1.5 rounded-full bg-gray-100 text-gray-500 hover:bg-gray-200 hover:text-gray-700 transition-colors"
onClick={() => setSelectedInfo(section)}
title={`Learn more about ${trainSectionInfo[section].name}`}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
/>
</svg>
</button>
);
return (
<>
<div
ref={containerRef}
className="max-w-6xl mx-auto px-6 py-8 space-y-8 overflow-y-auto h-full"
>
{/* Page Title and Description */}
<div className="mb-4">
<h1 className="text-xl font-semibold text-gray-900 mb-1">{pageTitle}</h1>
<p className="text-gray-600 max-w-6xl">{pageDescription}</p>
</div>
{/* Upload Memories Section */}
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-4 hover:shadow-md transition-shadow">
<div className="flex items-center gap-2 mb-3">
<h2 className="text-lg font-semibold text-gray-900 mb-0">Upload Memories</h2>
{renderInfoButton('upload')}
</div>
<UploadMemories onFileUpload={handleFileUpload} />
</div>
{/* Memory List Section */}
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-4 hover:shadow-md transition-shadow">
<div className="flex justify-between items-center mb-3">
<h2 className="text-xl font-semibold tracking-tight text-gray-900">Memory List</h2>
{renderInfoButton('memory-list')}
</div>
<MemoryList memories={memories} onDelete={handleDeleteMemory} />
</div>
{/* Next Button */}
<div className="flex justify-end mt-4">
<button
className="px-4 py-2 text-sm font-medium rounded-lg bg-blue-500 text-white hover:bg-blue-600 transition-colors flex items-center gap-2"
onClick={() => router.push(ROUTER_PATH.TRAIN_TRAINING)}
>
Next: Training
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path d="M9 5l7 7-7 7" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
</svg>
</button>
</div>
</div>
<InfoModal
open={!!selectedInfo && !!trainSectionInfo[selectedInfo]}
content={
selectedInfo ? (
<div className="space-y-4">
<p className="text-gray-600">{trainSectionInfo[selectedInfo].description}</p>
<div>
<h4 className="font-medium mb-2">Key Features:</h4>
<ul className="list-disc pl-5 space-y-1.5">
{trainSectionInfo[selectedInfo].features.map((feature, index) => (
<li key={index} className="text-gray-600">
{feature}
</li>
))}
</ul>
</div>
</div>
) : null
}
onClose={() => setSelectedInfo(null)}
title={selectedInfo ? trainSectionInfo[selectedInfo].name : ''}
/>
</>
);
}

View File

@ -0,0 +1,15 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { ROUTER_PATH } from '@/utils/router';
export default function TrainRedirect() {
const router = useRouter();
useEffect(() => {
router.push(ROUTER_PATH.TRAIN_IDENTITY);
}, [router]);
return null;
}

View File

@ -0,0 +1,598 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { useRouter } from 'next/navigation';
import InfoModal from '@/components/InfoModal';
import { startTrain, stopTrain, retrain, getModelName } from '@/service/train';
import { useTrainingStore } from '@/store/useTrainingStore';
import { getMemoryList } from '@/service/memory';
import { message, Modal } from 'antd';
import { useModelConfigStore } from '@/store/useModelConfigStore';
import CelebrationEffect from '@/components/Celebration';
import { getModelConfig } from '@/service/modelConfig';
import TrainingLog from '@/components/train/TrainingLog';
import TrainingProgress from '@/components/train/TrainingProgress';
import TrainingConfiguration from '@/components/train/TrainingConfiguration';
import { ROUTER_PATH } from '@/utils/router';
interface TrainInfo {
name: string;
description: string;
features: string[];
}
const trainInfo: TrainInfo = {
name: 'Training Process',
description:
'Transform your memories into a personalized AI model through a multi-stage training process',
features: [
'Automated multi-stage training process',
'Real-time progress monitoring',
'Detailed training logs',
'Training completion notification',
'Model performance metrics'
]
};
const POLLING_INTERVAL = 3000;
interface TrainingConfig {
modelProvider: string;
baseModel: string;
modelType: string;
epochs: number;
learningRate: string;
memoryPriority: string;
showAdvanced: boolean;
}
interface TrainingDetail {
message: string;
timestamp: string;
}
export default function TrainingPage() {
// Title and explanation section
const pageTitle = 'Training Process';
const pageDescription =
'Transform your memories into a personalized AI model that thinks and communicates like you.';
const [selectedInfo, setSelectedInfo] = useState<boolean>(false);
const [isTraining, setIsTraining] = useState(false);
const [stopTraining, setStopTraining] = useState(false);
const [trainActionLoading, setTrainActionLoading] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const firstLoadRef = useRef<boolean>(true);
const [showCelebration, setShowCelebration] = useState(false);
const [showMemoryModal, setShowMemoryModal] = useState(false);
const modelConfig = useModelConfigStore((store) => store.modelConfig);
const updateModelConfig = useModelConfigStore((store) => store.updateModelConfig);
const baseModelOptions = [
{
value: 'Qwen2.5-0.5B-Instruct',
label: 'Qwen2.5-0.5B-Instruct (8GB+ RAM Recommended)'
},
{
value: 'Qwen2.5-1.5B-Instruct',
label: 'Qwen2.5-1.5B-Instruct (16GB+ RAM Recommended)'
},
{
value: 'Qwen2.5-3B-Instruct',
label: 'Qwen2.5-3B-Instruct (32GB+ RAM Recommended)'
},
{
value: 'Qwen2.5-7B-Instruct',
label: 'Qwen2.5-7B-Instruct (64GB+ RAM Recommended)'
}
];
const [config, setConfig] = useState<TrainingConfig>({
modelProvider: 'ollama',
baseModel: 'Qwen2.5-0.5B-Instruct',
modelType: 'General Purpose',
epochs: 10,
learningRate: 'Conservative (0.0001)',
memoryPriority: 'Equal Weighting',
showAdvanced: false
});
const [changeBaseModel, setChangeBaseModel] = useState(false);
useEffect(() => {
const nowBaseModel = JSON.parse(localStorage.getItem('trainingConfig') || '{}');
setChangeBaseModel(nowBaseModel?.baseModel !== config.baseModel);
}, [config.baseModel]);
useEffect(() => {
getModelConfig().then((res) => {
if (res.data.code == 0) {
const data = res.data.data || {};
updateModelConfig(data);
} else {
message.error(res.data.message);
}
});
}, []);
useEffect(() => {
getModelName().then((res) => {
if (res.data.code === 0) {
if (res.data.data.model_name) {
localStorage.setItem(
'trainingConfig',
JSON.stringify({
...config,
baseModel: res.data.data.model_name
})
);
}
}
});
const previousModel = localStorage.getItem('trainingConfig');
if (previousModel) {
setConfig({
...config,
baseModel: JSON.parse(previousModel).baseModel
});
}
}, []);
const pollingInterval = useRef<any>(null);
const router = useRouter();
const status = useTrainingStore((state) => state.status);
const trainingProgress = useTrainingStore((state) => state.trainingProgress);
const checkTrainStatus = useTrainingStore((state) => state.checkTrainStatus);
const resetTrainingState = useTrainingStore((state) => state.resetTrainingState);
const trainingError = useTrainingStore((state) => state.error);
const setStatus = useTrainingStore((state) => state.setStatus);
// Start polling training progress
const startPolling = () => {
// If already polling, stop first
stopPolling();
// Start new polling
pollingInterval.current = setInterval(async () => {
try {
await checkTrainStatus();
} catch (error) {
console.error('Training status check failed:', error);
stopPolling(); // Stop polling when error occurs
setIsTraining(false);
message.error('Training status check failed, monitoring stopped');
}
}, POLLING_INTERVAL);
};
// Stop polling
const stopPolling = () => {
if (pollingInterval.current) {
clearInterval(pollingInterval.current);
pollingInterval.current = null;
}
};
useEffect(() => {
if (status === 'trained' || trainingError) {
stopPolling();
setIsTraining(false);
const hasShownTrainingComplete = localStorage.getItem('hasShownTrainingComplete');
if (hasShownTrainingComplete !== 'true' && status === 'trained' && !trainingError) {
setTimeout(() => {
setShowCelebration(true);
localStorage.setItem('hasShownTrainingComplete', 'true');
}, 1000);
}
}
}, [status, trainingError]);
// Monitor training status changes, scroll to bottom when status becomes 'training'
useEffect(() => {
if (status === 'training') {
scrollToBottom();
}
}, [status]);
// Check training status once when component loads
useEffect(() => {
// Check if user has at least 3 memories
const checkMemoryCount = async () => {
try {
const memoryResponse = await getMemoryList();
if (memoryResponse.data.code === 0) {
const memories = memoryResponse.data.data;
if (memories.length < 3) {
// Show modal instead of direct redirect
setShowMemoryModal(true);
return;
}
}
} catch (error) {
console.error('Error checking memory count:', error);
}
// Only proceed with training status check if memory check passes
checkTrainStatus();
// Check if we were in the middle of retraining
const isRetraining = localStorage.getItem('isRetraining') === 'true';
if (isRetraining) {
// If we were retraining, set status to training
setStatus('training');
setIsTraining(true);
startPolling();
}
};
checkMemoryCount();
}, []);
// Monitor training status changes and manage log connections
useEffect(() => {
let cleanupEventSource: (() => void) | undefined;
// If training is in progress, start polling and establish log connection
if (trainingProgress.status === 'in_progress') {
startPolling();
setIsTraining(true);
// Create EventSource connection to get logs
cleanupEventSource = getDetails();
if (firstLoadRef.current) {
scrollPageToBottom();
scrollToBottom();
}
}
// If training is completed or failed, stop polling
else if (trainingProgress.status === 'completed' || trainingProgress.status === 'failed') {
stopPolling();
setIsTraining(false);
// Keep EventSource open to preserve received logs
// If resource cleanup is needed, EventSource could be closed here
}
// Return cleanup function to ensure EventSource is closed when component unmounts or dependencies change
return () => {
if (cleanupEventSource) {
cleanupEventSource();
}
};
}, [trainingProgress]);
// Handle stop training request
useEffect(() => {
if (stopTraining && trainingProgress.status === 'in_progress') {
message.info('The step in progress cannot be stopped');
setStopTraining(false);
}
}, [stopTraining]);
// Cleanup when component unmounts
useEffect(() => {
return () => {
stopPolling();
};
}, []);
const [trainingDetails, setTrainingDetails] = useState<TrainingDetail[]>([]);
useEffect(() => {
const savedLogs = localStorage.getItem('trainingLogs');
setTrainingDetails(savedLogs ? JSON.parse(savedLogs) : []);
}, []);
// Scroll to the bottom of the page
const scrollPageToBottom = () => {
window.scrollTo({
top: document.documentElement.scrollHeight,
behavior: 'smooth'
});
// Set that it's no longer the first load
firstLoadRef.current = false;
};
const scrollToBottom = () => {
// This function is kept for backward compatibility
// The actual scrolling is now handled by the TrainingLog component
};
const getDetails = () => {
localStorage.setItem('trainingConfig', JSON.stringify(config));
// Use EventSource to get logs
const eventSource = new EventSource('/api/trainprocess/logs');
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
setTrainingDetails((prev) => {
const newLogs = [
...prev.slice(-100),
{
message: data.message,
timestamp: new Date().toISOString()
}
];
// Save logs to localStorage
// localStorage.setItem('trainingLogs', JSON.stringify(newLogs));
return newLogs;
});
} catch {
setTrainingDetails((prev) => {
const newLogs = [
...prev.slice(-100),
{
message: event.data,
timestamp: new Date().toISOString()
}
];
// Save logs to localStorage
// localStorage.setItem('trainingLogs', JSON.stringify(newLogs));
return newLogs;
});
}
};
eventSource.onerror = (error) => {
console.error('EventSource failed:', error);
eventSource.close();
message.error('Failed to get training logs');
};
return () => {
eventSource.close();
};
};
// Handler function for stopping training
const handleStopTraining = async () => {
try {
const res = await stopTrain();
if (res.data.code === 0) {
setIsTraining(false);
setStopTraining(true);
} else {
message.error(res.data.message || 'Failed to stop training');
}
} catch (error) {
console.error('Error stopping training:', error);
message.error('Failed to stop training');
}
};
// Start new training
const handleStartNewTraining = async () => {
setIsTraining(true);
// Clear training logs
setTrainingDetails([]);
localStorage.removeItem('trainingLogs');
// Reset training status to initial state
resetTrainingState();
const apiKey = config.modelProvider === 'ollama' ? 'http://localhost:11434' : '';
if (!apiKey) {
setIsTraining(false);
message.error('No API key found for the selected model');
return;
}
try {
getDetails();
console.log('Using startTrain API to train new model:', config.baseModel);
const res = await startTrain({ model_name: config.baseModel });
if (res.data.code === 0) {
// Save training configuration and start polling
localStorage.setItem('trainingConfig', JSON.stringify(config));
console.log('API call successful, starting to poll for status updates');
setStatus('training');
scrollPageToBottom();
startPolling();
} else {
message.error(res.data.message || 'Failed to start training');
setIsTraining(false);
}
} catch (error: unknown) {
console.error('Error starting training:', error);
setIsTraining(false);
if (error instanceof Error) {
message.error(error.message || 'Failed to start training');
} else {
message.error('Failed to start training');
}
}
};
// Retrain existing model
const handleRetrainModel = async () => {
setIsTraining(true);
// Clear training logs
setTrainingDetails([]);
localStorage.removeItem('trainingLogs');
// Reset training status to initial state
resetTrainingState();
try {
getDetails();
console.log('Using retrain API to retrain model:', config.baseModel);
const res = await retrain({ model_name: config.baseModel });
if (res.data.code === 0) {
// Save training configuration and start polling
localStorage.setItem('trainingConfig', JSON.stringify(config));
console.log('API call successful, starting to poll for status updates');
// Set status as training to ensure UI displays correct training status
setStatus('training');
scrollPageToBottom();
startPolling();
} else {
message.error(res.data.message || 'Failed to retrain model');
setIsTraining(false);
}
} catch (error: unknown) {
console.error('Error retraining model:', error);
setIsTraining(false);
if (error instanceof Error) {
message.error(error.message || 'Failed to retrain model');
} else {
message.error('Failed to retrain model');
}
}
};
// Call the appropriate handler function based on status
const handleTrainingAction = async () => {
if (trainActionLoading) {
message.info('Please wait a moment...');
return;
}
setTrainActionLoading(true);
// If training is in progress, stop it
if (isTraining) {
await handleStopTraining();
setTrainActionLoading(false);
return;
}
// Get previously trained model information from local storage
const previousModel = JSON.parse(localStorage.getItem('trainingConfig') || '{}');
// If the same model has already been trained and status is 'trained' or 'running', perform retraining
if (
previousModel.baseModel === config.baseModel &&
(status === 'trained' || status === 'running')
) {
await handleRetrainModel();
} else {
// Otherwise start new training
await handleStartNewTraining();
}
setTrainActionLoading(false);
};
const renderTrainingProgress = () => {
return (
<div className="space-y-6">
{/* Training Progress Component */}
<TrainingProgress status={status} trainingProgress={trainingProgress} />
</div>
);
};
const renderTrainingLog = () => {
return (
<div className="space-y-6">
{/* Training Log Console */}
<TrainingLog trainingDetails={trainingDetails} />
</div>
);
};
// Handle memory modal confirmation
const handleMemoryModalConfirm = () => {
setShowMemoryModal(false);
router.push(ROUTER_PATH.TRAIN_MEMORIES);
};
return (
<div ref={containerRef} className="h-full overflow-auto">
{/* Memory count warning modal */}
<Modal
cancelText="Stay Here"
okText="Go to Memories Page"
onCancel={() => setShowMemoryModal(false)}
onOk={handleMemoryModalConfirm}
open={showMemoryModal}
title="More Memories Needed"
>
<p>You need to add at least 3 memories before you can train your model.</p>
<p>Would you like to go to the memories page to add more?</p>
</Modal>
<div className="max-w-6xl mx-auto px-6 py-8 space-y-8">
{/* Page Title and Description */}
<div className="mb-6">
<h1 className="text-2xl font-semibold text-gray-900 mb-2">{pageTitle}</h1>
<p className="text-gray-600 max-w-3xl">{pageDescription}</p>
</div>
{/* Training Configuration Component */}
<TrainingConfiguration
baseModelOptions={baseModelOptions}
changeBaseModel={changeBaseModel}
config={config}
handleTrainingAction={handleTrainingAction}
isTraining={isTraining}
modelConfig={modelConfig}
setConfig={setConfig}
setSelectedInfo={setSelectedInfo}
status={status}
trainActionLoading={trainActionLoading}
/>
{/* Only show training progress after training starts */}
{(status === 'training' || status === 'trained' || status === 'running') &&
renderTrainingProgress()}
{/* Always show training log regardless of training status */}
{renderTrainingLog()}
{/* L1 and L2 Panels - show when training is complete or model is running */}
<InfoModal
content={
<div className="space-y-4">
<p className="text-gray-600">{trainInfo.description}</p>
<div>
<h4 className="font-medium mb-2">Key Features:</h4>
<ul className="list-disc pl-5 space-y-1.5">
{trainInfo.features.map((feature, index) => (
<li key={index} className="text-gray-600">
{feature}
</li>
))}
</ul>
</div>
</div>
}
onClose={() => setSelectedInfo(false)}
open={!!selectedInfo && !!trainInfo}
title={trainInfo.name}
/>
{/* Training completion celebration effect */}
<CelebrationEffect isVisible={showCelebration} onClose={() => setShowCelebration(false)} />
</div>
</div>
);
}

View File

@ -0,0 +1,70 @@
/* stylelint-disable at-rule-no-unknown */
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--background: #fdf8f3;
--foreground: #2c3e50;
--primary: #4a90e2;
--primary-hover: #357abd;
--accent-1: #ff6b6b;
--accent-2: #50b86b;
--accent-3: #ffd93d;
--btn-width: 240px;
}
@layer base {
body {
@apply text-secondme-navy bg-secondme-warm-bg;
font-family: 'Helvetica Neue', sans-serif;
}
h1,
h2,
h3,
h4,
h5,
h6 {
@apply font-semibold tracking-tight;
font-family: 'Helvetica Neue', sans-serif;
}
}
@font-face {
font-weight: 400;
font-family: Calistoga;
src: url('/fonts/Calistoga.ttf');
}
@font-face {
font-weight: 400;
font-family: 'Helvetica Neue', sans-serif;
src: url('/fonts/Helvetica_Neue.otf');
}
@layer components {
.btn-primary {
@apply px-6 py-3 text-base font-medium text-white bg-secondme-blue rounded-xl
hover:bg-[#357ABD] transition-all duration-200 ease-out
hover:-translate-y-0.5 hover:shadow-[4px_4px_10px_0px_rgba(74,144,226,0.3)]
active:translate-y-0 active:shadow-none;
width: var(--btn-width);
}
.card {
@apply rounded-2xl border border-secondme-gray-300 bg-white
shadow-[4px_4px_0px_0px_rgba(0,0,0,0.03)]
hover:shadow-[6px_6px_0px_0px_rgba(0,0,0,0.05)]
hover:-translate-y-0.5 transition-all duration-200;
}
.input-field {
@apply px-4 py-2.5 rounded-xl border border-secondme-gray-300
focus:border-secondme-blue focus:ring-2 focus:ring-secondme-blue/20
bg-white transition-all duration-200;
}
}

View File

@ -0,0 +1,210 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { createLoadInfo } from '@/service/info';
import OnboardingTutorial from '../../../../components/OnboardingTutorial';
import { ROUTER_PATH } from '@/utils/router';
import { message } from 'antd';
interface CreateSecondMeProps {
onClose: () => void;
}
export default function CreateSecondMe({ onClose }: CreateSecondMeProps) {
const router = useRouter();
const [showTutorial, setShowTutorial] = useState(true);
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [email, setEmail] = useState('');
const [nameError, setNameError] = useState('');
const [emailError, setEmailError] = useState('');
const validateName = (value: string) => {
if (value.length < 2 || value.length > 20) {
setNameError('Name must be between 2 and 20 characters');
return false;
}
if (value.includes(' ')) {
setNameError('Name cannot contain spaces');
return false;
}
setNameError('');
return true;
};
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setName(value);
validateName(value);
};
const validateEmail = (value: string) => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(value)) {
setEmailError('Please enter a valid email address');
return false;
}
setEmailError('');
return true;
};
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setEmail(value);
validateEmail(value);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!validateName(name) || !validateEmail(email)) return;
createLoadInfo({
name,
description,
email
})
.then((res) => {
if (res.data.code !== 0) {
throw new Error(res.data.message);
} else {
// Store in localStorage for registration check
localStorage.setItem(
'upload',
JSON.stringify({
name,
description,
email
})
);
message.success('Identity created successfully');
setTimeout(() => {
router.push(ROUTER_PATH.DASHBOARD);
}, 300);
}
})
.catch((error: Error) => {
message.error(error.message);
});
};
return (
<>
{showTutorial ? (
<OnboardingTutorial onClose={onClose} onComplete={() => setShowTutorial(false)} />
) : (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-[100]">
<div className="bg-secondme-warm-bg rounded-2xl p-10 max-w-2xl w-full shadow-2xl border-2 border-gray-800/10 relative overflow-hidden">
{/* Background gradient decorations */}
<div className="absolute -top-20 -right-20 w-64 h-64 rounded-full bg-orange-50 opacity-70" />
<div className="absolute -bottom-20 -left-20 w-64 h-64 rounded-full bg-orange-50 opacity-70" />
<div className="space-y-6 relative z-10">
<div className="mb-6">
<h1 className="text-3xl font-bold mb-3 text-gray-900">Define Your Identity</h1>
<p className="text-lg text-gray-600 leading-relaxed max-w-xl">
This initial identity represents your core self. You can expand upon it further in
the upcoming steps.
</p>
</div>
<form className="space-y-6 mt-6" onSubmit={handleSubmit}>
<div>
<label
className="block text-[15px] font-medium text-gray-700 font-sans"
htmlFor="name"
>
Second Me Name
</label>
<p className="text-sm text-gray-500 mt-1 leading-relaxed font-sans">
This name will represent you, and your Second Me.
</p>
<input
className={`mt-1.5 block w-full px-4 py-2.5 rounded-lg border bg-white shadow-[3px_3px_0px_0px_rgba(0,0,0,0.03)] focus:border-gray-400 focus:ring-2 focus:ring-gray-400/20 transition-all font-sans text-[15px] placeholder:text-gray-400 placeholder:text-[15px] ${nameError ? 'border-red-500' : 'border-gray-800/10'}`}
id="name"
onChange={handleNameChange}
placeholder="e.g., Felix (no spaces allowed)"
required
type="text"
value={name}
/>
{nameError && <p className="mt-1 text-xs text-red-500">{nameError}</p>}
</div>
<div>
<label
className="block text-[15px] font-medium text-gray-700 font-sans"
htmlFor="description"
>
Short Personal Description
</label>
<p className="text-sm text-gray-500 mt-1 leading-relaxed font-sans">
Briefly describe yourself: personality, motivation, or style.
</p>
<textarea
className="mt-1.5 block w-full px-4 py-2.5 rounded-lg border border-gray-800/10 bg-white shadow-[3px_3px_0px_0px_rgba(0,0,0,0.03)] focus:border-gray-400 focus:ring-2 focus:ring-gray-400/20 transition-all font-sans text-[15px] placeholder:text-gray-400 placeholder:text-[15px] resize-none h-28 leading-relaxed"
id="description"
maxLength={200}
onChange={(e) => setDescription(e.target.value)}
placeholder="e.g., 'An adventurous, data-driven, and enjoy learning new technologies.'"
rows={4}
value={description}
/>
</div>
<div>
<label
className="block text-[15px] font-medium text-gray-700 font-sans"
htmlFor="email"
>
Email of Second Me
</label>
<p className="text-sm text-gray-500 mt-1 leading-relaxed font-sans">
This email will be used as a contact point for your Second Me. You can use your
own email address.
</p>
<input
className={`mt-1.5 block w-full px-4 py-2.5 rounded-lg border bg-white shadow-[3px_3px_0px_0px_rgba(0,0,0,0.03)] focus:border-gray-400 focus:ring-2 focus:ring-gray-400/20 transition-all font-sans text-[15px] placeholder:text-gray-400 placeholder:text-[15px] ${emailError ? 'border-red-500' : 'border-gray-800/10'}`}
id="email"
onChange={handleEmailChange}
placeholder="e.g., your.name@example.com"
required
type="email"
value={email}
/>
{emailError && <p className="mt-1 text-xs text-red-500">{emailError}</p>}
</div>
<div className="flex justify-end space-x-4 pt-4 border-t border-gray-800/10">
<button
className="px-5 py-2.5 text-sm font-medium text-gray-600 hover:text-gray-800 transition-colors"
onClick={onClose}
type="button"
>
Cancel
</button>
<button
className="px-8 py-3 bg-gray-900 text-white rounded-lg hover:bg-gray-800 transition-colors font-medium shadow-[3px_3px_0px_0px_rgba(0,0,0,0.1)]"
type="submit"
>
Create
</button>
</div>
</form>
</div>
</div>
</div>
)}
</>
);
}

View File

@ -0,0 +1,68 @@
import classNames from 'classnames';
import VideoIcon from '@/components/svgs/VideoIcon';
import ChatBubbleIcon from '@/components/svgs/ChatBubbleIcon';
import LightningIcon from '@/components/svgs/LightningIcon';
import UsersIcon from '@/components/svgs/UsersIcon';
interface IProps {
className?: string;
}
const Footer = (props: IProps) => {
const { className } = props;
return (
<div
className={classNames(
`fixed bottom-0 left-0 right-0 py-4 transition-opacity delay-700 duration-700 ease-in-out`,
className
)}
>
<div className="container mx-auto px-4">
<div className="flex flex-col md:flex-row items-center justify-center gap-3">
<span className="font-medium text-secondme-navy text-sm">See Demos:</span>
<div className="flex flex-wrap justify-center gap-x-4 gap-y-2">
<a
className="text-sm text-secondme-blue hover:text-secondme-blue/80 hover:underline flex items-center gap-1"
href="https://secondme.io/"
rel="noopener noreferrer"
target="_blank"
>
<VideoIcon />
Walkthrough Video
</a>
<a
className="text-sm text-secondme-blue hover:text-secondme-blue/80 hover:underline flex items-center gap-1"
href="https://app.secondme.io/example/ama"
rel="noopener noreferrer"
target="_blank"
>
<ChatBubbleIcon />
Felix AMA (Roleplay)
</a>
<a
className="text-sm text-secondme-blue hover:text-secondme-blue/80 hover:underline flex items-center gap-1"
href="https://app.secondme.io/example/brainstorming"
rel="noopener noreferrer"
target="_blank"
>
<LightningIcon />
Brainstorming (Network)
</a>
<a
className="text-sm text-secondme-blue hover:text-secondme-blue/80 hover:underline flex items-center gap-1"
href="https://app.secondme.io/example/Icebreaker"
rel="noopener noreferrer"
target="_blank"
>
<UsersIcon />
Icebreaker (Network)
</a>
</div>
</div>
</div>
</div>
);
};
export default Footer;

View File

@ -0,0 +1,41 @@
import classNames from 'classnames';
import TwitterXIcon from '@/components/svgs/TwitterXIcon';
import DiscordIcon from '@/components/svgs/DiscordIcon';
interface IProps {
className?: string;
}
const SocialMedia = (props: IProps) => {
const { className } = props;
return (
<div
className={classNames(
`fixed bottom-4 right-4 flex items-center gap-3 transition-opacity duration-700 delay-[800ms] ease-in-out}`,
className
)}
>
<a
aria-label="Follow us on X (Twitter)"
className="w-10 h-10 flex items-center justify-center rounded-lg bg-black text-white hover:bg-gray-800 transition-colors"
href="https://x.com/SecondMe_AI1"
rel="noopener noreferrer"
target="_blank"
>
<TwitterXIcon className="w-5 h-5" />
</a>
<a
aria-label="Join our Discord server"
className="w-10 h-10 flex items-center justify-center rounded-lg bg-[#5865F2] text-white hover:bg-[#4a57e0] transition-colors"
href="https://discord.com/invite/GpWHQNUwrg"
rel="noopener noreferrer"
target="_blank"
>
<DiscordIcon className="w-5 h-5" />
</a>
</div>
);
};
export default SocialMedia;

View File

@ -0,0 +1,141 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import CreateSecondMe from '@/app/home/components/Create';
import dynamic from 'next/dynamic';
import { getUploadCount } from '@/service/info';
import { ROUTER_PATH } from '@/utils/router';
import Footer from './components/Footer';
import SocialMedia from './components/SocialMedia';
import { useLoadInfoStore } from '@/store/useLoadInfoStore';
import { message } from 'antd';
const NetworkSphere = dynamic(() => import('@/components/NetworkSphere'), {
ssr: false,
loading: () => <div className="fixed inset-0 -z-10 w-screen h-screen overflow-hidden bg-white" />
});
export default function Home() {
const router = useRouter();
const [showCreate, setShowCreate] = useState(false);
const [count, setCount] = useState<number | undefined>(undefined);
const [isMounted, setIsMounted] = useState(false);
const [contentVisible, setContentVisible] = useState(false);
const loadInfo = useLoadInfoStore((state) => state.loadInfo);
useEffect(() => {
setIsMounted(true);
}, []);
useEffect(() => {
getUploadCount()
.then((res) => {
if (res.data.code === 0) {
setCount(res.data.data.count);
} else {
throw new Error(res.data.message);
}
})
.catch((error: any) => {
message.error(error.message || 'Failed to load upload count');
});
}, []);
const handleExistingUploadClick = () => {
router.push(ROUTER_PATH.DASHBOARD);
};
const handleSphereInitialized = () => {
setTimeout(() => {
setContentVisible(true);
}, 300);
};
// Only render content on the client side
if (!isMounted) {
return (
<div className="min-h-screen flex flex-col items-center justify-center p-4 relative bg-white">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-secondme-blue" />
</div>
);
}
return (
<div className="min-h-screen flex flex-col items-center justify-center p-4 relative">
{/* Network sphere background */}
<NetworkSphere onInitialized={handleSphereInitialized} />
{/* Background */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div
className={`absolute top-20 left-20 w-64 h-64 rounded-full bg-[#4ECDC4]/10 blur-3xl delay-[400ms] transition-opacity duration-1000 ease-in-out ${contentVisible ? 'opacity-100' : 'opacity-0'}`}
/>
<div
className={`absolute bottom-20 right-20 w-64 h-64 rounded-full bg-[#FF6B6B]/10 blur-3xl delay-[500ms] transition-opacity duration-1000 ease-in-out ${contentVisible ? 'opacity-100' : 'opacity-0'}`}
/>
<div
className={`absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-96 h-96 rounded-full bg-[#FFD93D]/10 blur-3xl delay-[600ms] transition-opacity duration-1000 ease-in-out ${contentVisible ? 'opacity-100' : 'opacity-0'}`}
/>
</div>
<div className="relative z-10 text-center mt-[-8vh] w-full overflow-visible px-4">
<div
className={`transition-opacity duration-700 ease-in-out ${contentVisible ? 'opacity-100' : 'opacity-0'}`}
>
<h1 className="text-5xl md:text-6xl font-bold mb-3 mx-auto leading-tight px-4 flex items-center justify-center">
<img alt="Second Me Logo" className="h-20 md:h-28 mr-5" src="/images/single_logo.png" />
<span
className="bg-gradient-to-br from-[#1E293B] to-[#475569] bg-clip-text text-transparent drop-shadow-sm inline-block tracking-[0.01em] font-[Calistoga]"
style={{
textShadow: '0 2px 4px rgba(0,0,0,0.08)'
}}
>
Create Your AI self
</span>
</h1>
<p className="text-2xl md:text-3xl mb-14 mx-auto px-4 flex flex-wrap justify-center tracking-[0.01em] font-[Calistoga]">
<span className="inline-block mx-2 bg-gradient-to-br from-[#334155] to-[#475569] bg-clip-text text-transparent">
Locally Trained
</span>
<span className="inline-block text-[#64748B] mx-2">·</span>
<span className="inline-block mx-2 bg-gradient-to-br from-[#334155] to-[#475569] bg-clip-text text-transparent">
Globally Connected
</span>
</p>
<div
className={`text-sm mb-12 transition-opacity duration-700 ease-in-out ${contentVisible ? 'opacity-100' : 'opacity-0'}`}
style={{ transitionDelay: '400ms', color: '#64748B' }}
>
<span className="font-medium text-[#334155]">{count}</span>{' '}
<span>Second Me in network</span>
</div>
</div>
<div
className={`transition-opacity duration-700 ease-in-out delay-[300ms] ${contentVisible ? 'opacity-100' : 'opacity-0'}`}
>
{loadInfo ? (
<button className="btn-primary" onClick={handleExistingUploadClick}>
Continue as {loadInfo.name}
</button>
) : (
<button className="btn-primary" onClick={() => setShowCreate(true)}>
Create my Second Me
</button>
)}
</div>
</div>
{showCreate && <CreateSecondMe onClose={() => setShowCreate(false)} />}
{/* Quick examples section - Moved to the bottom of the page */}
<Footer className={contentVisible ? 'opacity-100' : 'opacity-0'} />
{/* Social Media Links - Fixed to bottom right */}
<SocialMedia className={contentVisible ? 'opacity-100' : 'opacity-0'} />
</div>
);
}

View File

@ -0,0 +1,25 @@
import type { Metadata } from 'next';
import './globals.css';
import { Suspense } from 'react';
import HeaderLayout from '@/layouts/HeaderLayout';
export const metadata: Metadata = {
title: 'Second Me',
description: 'Train and deploy your AI self'
};
export default function RootLayout({
children
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html className="h-full" lang="en">
<body className="flex flex-col font-sans antialiased h-full">
<Suspense>
<HeaderLayout>{children}</HeaderLayout>
</Suspense>
</body>
</html>
);
}

View File

@ -0,0 +1,5 @@
'use client';
export default function StandaloneLayout({ children }: { children: React.ReactNode }) {
return <div className="flex items-center justify-center bg-gray-50 h-full">{children}</div>;
}

View File

@ -0,0 +1,248 @@
'use client';
import { useEffect, useState, useRef } from 'react';
import { useParams } from 'next/navigation';
import ChatInput from '@/components/chat/ChatInput';
import ChatMessage from '@/components/chat/ChatMessage';
import { roleplayChatStorage, type ChatMessage as IChatMessage } from '@/utils/chatStorage';
import { getRole, type RoleRes } from '@/service/role';
import type { ChatRequest } from '@/hooks/useSSE';
import { useSSE } from '@/hooks/useSSE';
import { message } from 'antd';
import { useLoadInfoStore } from '@/store/useLoadInfoStore';
// Function to generate unique ID
const generateMessageId = () => {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
};
export default function RoleChat() {
const params = useParams();
const role_id = params.roleId as string;
const [role, setRole] = useState<RoleRes | null>(null);
const [messages, setMessages] = useState<IChatMessage[]>([]);
const [loading, setLoading] = useState(true);
const { sendStreamMessage, streaming, streamContent } = useSSE();
const messagesEndRef = useRef<HTMLDivElement>(null);
const loadInfo = useLoadInfoStore((state) => state.loadInfo);
const scrollToBottom = () => {
if (messagesEndRef.current) {
const container = messagesEndRef.current.parentElement;
container?.scrollTo({
top: container.scrollHeight,
behavior: 'smooth'
});
}
};
// Scroll to bottom when messages update
useEffect(() => {
scrollToBottom();
}, [messages, streamContent]);
// Load role information and chat history
useEffect(() => {
const loadRole = async () => {
setLoading(true);
try {
const res = await getRole(role_id);
if (res.data.code === 0) {
setRole(res.data.data);
// Load chat history
const storedMessages = roleplayChatStorage.getMessages(role_id);
if (storedMessages.length === 0) {
// If no history messages, add welcome message
const welcomeMessage: IChatMessage = {
id: generateMessageId(),
content: `Hello! I am a ${res.data.data.name}. How can I help you today?`,
role: 'assistant',
timestamp: new Date().toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit'
})
};
roleplayChatStorage.saveMessages(role_id, [welcomeMessage]);
setMessages([welcomeMessage]);
} else {
setMessages(storedMessages);
}
} else {
message.error('Failed to load role');
}
} catch (error) {
console.error('Failed to load role:', error);
message.error('Failed to load role');
} finally {
setLoading(false);
}
};
loadRole();
}, [role_id]);
useEffect(() => {}, [role]);
const handleSendMessage = async (content: string) => {
if (!role) return;
const userMessage: IChatMessage = {
id: generateMessageId(),
content,
role: 'user',
timestamp: new Date().toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit'
})
};
// Create an empty assistant message
const assistantMessage: IChatMessage = {
id: generateMessageId(),
content: '',
role: 'assistant',
timestamp: new Date().toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit'
})
};
// Update message list, add user message and empty assistant message
const newMessages = [...messages, userMessage, assistantMessage];
setMessages(newMessages);
// Save messages
roleplayChatStorage.saveMessages(role_id, newMessages);
// Send request
const chatRequest: ChatRequest = {
message: content,
system_prompt: role.system_prompt,
role_id: role.uuid,
enable_l0_retrieval: role.enable_l0_retrieval,
enable_l1_retrieval: role.enable_l1_retrieval || true,
temperature: 0.01,
history: messages.map((msg) => ({
role: msg.role === 'user' ? 'user' : 'assistant',
content: msg.content
}))
};
await sendStreamMessage(chatRequest);
};
const handleClearChat = () => {
roleplayChatStorage.clearMessages(role_id);
setMessages([]);
};
// Monitor streamContent changes to update messages
useEffect(() => {
if (!streamContent) return;
setMessages((prevMessages) => {
// Update the content of the last assistant message
const updatedMessages = prevMessages.map((msg, index) => {
if (index === prevMessages.length - 1 && msg.role === 'assistant') {
return { ...msg, content: streamContent };
}
return msg;
});
// When message is complete, save the final message list
if (!streaming) {
roleplayChatStorage.saveMessages(role_id, updatedMessages);
}
return updatedMessages;
});
}, [streamContent, streaming, role_id]);
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen bg-gray-50">
<div
className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"
suppressHydrationWarning
/>
</div>
);
}
return (
<div className="h-full bg-gray-50 w-full">
<div className="flex h-full">
{/* Left Sidebar - Role Information */}
<div className="w-64 bg-white border-r flex flex-col">
<div className="p-4 border-b">
<h1 className="font-semibold text-lg truncate">{loadInfo?.name}</h1>
<p className="text-sm text-gray-600 mt-1">as</p>
<h2 className="font-medium text-base text-blue-600">{role?.name}</h2>
</div>
<div className="p-4">
<h3 className="text-sm font-medium text-gray-700 mb-2">Role Description</h3>
<p className="text-sm text-gray-600">
{role?.description || 'No description available'}
</p>
</div>
<div className="mt-auto p-4 border-t">
<button
className="w-full py-2 px-4 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded text-sm transition-colors flex items-center justify-center gap-2"
onClick={handleClearChat}
>
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
/>
</svg>
Clear Chat
</button>
</div>
</div>
{/* Main Chat Area */}
<div className="flex-1 flex flex-col">
{/* Chat Messages */}
<div className="flex-1 overflow-y-auto p-4">
{messages.map((msg, index) => (
<ChatMessage
key={msg.id}
isLoading={
streaming &&
!streamContent &&
index === messages.length - 1 &&
msg.role === 'assistant'
}
isUser={msg.role === 'user'}
message={msg.content}
timestamp={msg.timestamp}
/>
))}
<div ref={messagesEndRef} />
</div>
{/* Input Area */}
<ChatInput disabled={streaming} onSendMessage={handleSendMessage} />
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,185 @@
'use client';
import { useState, useEffect } from 'react';
import { useParams } from 'next/navigation';
import { Divider } from 'antd';
// interface Message {
// id: string;
// senderId: string;
// content: string;
// timestamp: string;
// type: 'message' | 'thinking' | 'conclusion';
// }
// interface IResult {
// id: string;
// content: string;
// createdAt: string;
// }
// Mock data
const getAvatarUrl = (id: string) => {
// This should return the corresponding avatar URL based on the actual SecondMe ID
// Currently using a simple placeholder image
return `https://api.dicebear.com/7.x/bottts/svg?seed=${id}`;
};
const mockRoom = {
id: '1',
name: 'Market Analysis Room',
objective: 'Analyze current market trends and provide strategic insights',
participants: [
{ id: 'https://secondme.com/23581', name: 'Market Analyst' },
{ id: 'https://secondme.com/23582', name: 'Strategy Expert' }
],
messages: [
{
id: '1',
senderId: 'https://secondme.com/23581',
content:
"Based on my analysis of recent market data, I've observed a significant shift in consumer behavior towards sustainable products. The trend is particularly strong in the 25-40 age demographic.",
timestamp: '5 minutes ago',
type: 'message'
},
{
id: '2',
senderId: 'https://secondme.com/23581',
content: 'Analyzing purchase patterns and social media sentiment...',
timestamp: '4 minutes ago',
type: 'thinking'
},
{
id: '3',
senderId: 'https://secondme.com/23582',
content:
"That aligns with the global trends I've been tracking. Looking at comparable markets in Europe and Asia, we're seeing similar patterns. Let me analyze the potential market size...",
timestamp: '3 minutes ago',
type: 'message'
},
{
id: '4',
senderId: 'https://secondme.com/23582',
content: 'Calculating market size and growth projections...',
timestamp: '2 minutes ago',
type: 'thinking'
},
{
id: '5',
senderId: 'https://secondme.com/23581',
content:
'Based on our combined analysis, I suggest we focus on three key areas: eco-friendly packaging, sustainable materials, and energy-efficient products. The total addressable market for these categories is projected to reach $500B by 2025.',
timestamp: '1 minute ago',
type: 'conclusion'
}
],
results: [
{
id: '1',
content:
'Identified key market opportunities in sustainable product categories with specific actionable recommendations.',
createdAt: '1 minute ago'
}
]
};
export default function RoomDetail() {
const params = useParams();
const [room, _setRoom] = useState(mockRoom);
// In a real app, we would fetch the room data here
useEffect(() => {
// fetchRoomData(params.roomId)
}, [params.roomId]);
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<header className="bg-white shadow">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<h1 className="text-3xl font-bold text-gray-900">{room.name}</h1>
<p className="mt-2 text-lg text-gray-600">{room.objective}</p>
<div className="flex items-center mt-4 space-x-2">
<span className="text-sm text-gray-500">Participants:</span>
{room.participants.map((participant) => (
<span
key={participant.id}
className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-blue-100 text-blue-800"
>
{participant.name}
</span>
))}
</div>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="bg-white rounded-lg shadow">
{/* Communication Area */}
<div className="px-6 py-6">
<h2 className="text-xl font-semibold mb-6">Communication Process</h2>
<div className="space-y-6">
{room.messages.map((message) => {
const sender = room.participants.find((p) => p.id === message.senderId);
const isThinking = message.type === 'thinking';
const isConclusion = message.type === 'conclusion';
return (
<div
key={message.id}
className={`flex space-x-4 ${isThinking ? 'opacity-70' : ''} ${isConclusion ? 'bg-blue-50 p-4 rounded-lg border border-blue-100' : ''}`}
>
<div className="flex-shrink-0 w-10 h-10">
<img
alt={sender?.name}
className="w-10 h-10 rounded-full"
src={getAvatarUrl(message.senderId)}
/>
</div>
<div className="flex-1">
<div className="flex items-center space-x-2 mb-1">
<span className="text-sm font-medium text-gray-900">{sender?.name}</span>
<span className="text-xs text-gray-500">{message.timestamp}</span>
{isThinking && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-800">
Thinking...
</span>
)}
{isConclusion && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800">
Conclusion
</span>
)}
</div>
<div className={`text-gray-700 ${isThinking ? 'italic' : ''}`}>
{message.content}
</div>
</div>
</div>
);
})}
</div>
</div>
<Divider style={{ margin: '0' }} />
{/* Results Area */}
<div className="bg-gray-50 px-6 py-6 rounded-b-lg">
<h2 className="text-xl font-semibold mb-6">Results</h2>
<div className="space-y-4">
{room.results.map((result) => (
<div key={result.id} className="bg-white rounded-lg border border-gray-200 p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-900">Summary</span>
<span className="text-xs text-gray-500">{result.createdAt}</span>
</div>
<p className="text-gray-700">{result.content}</p>
</div>
))}
</div>
</div>
</div>
</main>
</div>
);
}

View File

@ -0,0 +1,332 @@
'use client';
import { Spin, message } from 'antd';
import { useParams } from 'next/navigation';
import type { SpaceInfo, SpaceMessage } from '@/service/space';
import { useSpaceStore } from '@/store/useSpaceStore';
import { useEffect, useRef, useState } from 'react';
import SimpleMD from '@/components/SimpleMD';
interface MessageGroup {
type: 'opening' | 'discussion' | 'summary';
messages: SpaceMessage[];
}
export default function SpaceDetail() {
const params = useParams();
const space_id = params.spaceId as string;
const [loading, setLoading] = useState(true);
const [messageGroups, setMessageGroups] = useState<MessageGroup[]>([]);
const fetchSpaceById = useSpaceStore((state) => state.fetchSpaceById);
const [spaceData, setSpaceData] = useState<SpaceInfo | null>(null);
const pollingInterval = useRef<NodeJS.Timeout | null>(null);
const channel = new BroadcastChannel('updateSpace');
useEffect(() => {
if (!spaceData?.status) {
return;
}
channel.postMessage({ type: 'updateSpaceStatus', space_id, status: spaceData.status });
}, [spaceData?.status]);
// Get space details
const fetchSpaceDetails = async () => {
try {
const data = await fetchSpaceById(space_id);
if (data) {
setSpaceData(data);
// Group messages by type
if (data.messages && data.messages.length > 0) {
const groups: MessageGroup[] = [];
// Opening messages
const openingMessages = data.messages.filter((msg) => msg.message_type === 'opening');
if (openingMessages.length > 0) {
groups.push({ type: 'opening', messages: openingMessages });
}
// Discussion content
const discussionMessages = data.messages.filter(
(msg) => msg.message_type === 'discussion'
);
if (discussionMessages.length > 0) {
groups.push({ type: 'discussion', messages: discussionMessages });
}
// Summary
const summaryMessages = data.messages.filter((msg) => msg.message_type === 'summary');
if (summaryMessages.length > 0) {
groups.push({ type: 'summary', messages: summaryMessages });
}
setMessageGroups(groups);
}
}
if (data?.status === 3 || data?.status === 4) {
stopPolling();
}
} catch (error) {
console.error('Error fetching space details:', error);
message.error('Failed to load space details');
} finally {
setLoading(false);
}
};
// Start polling
const startPolling = () => {
// Clear previous polling interval
if (pollingInterval.current) {
clearInterval(pollingInterval.current);
}
pollingInterval.current = setInterval(() => {
fetchSpaceDetails();
}, 1000);
};
const stopPolling = () => {
if (pollingInterval.current) {
clearInterval(pollingInterval.current);
pollingInterval.current = null;
}
};
// Fetch data and start polling when component mounts
useEffect(() => {
fetchSpaceDetails();
startPolling();
// Clear polling when component unmounts
return () => {
if (pollingInterval.current) {
clearInterval(pollingInterval.current);
}
};
}, [space_id]);
// Format timestamp
const formatTime = (timeString: string) => {
try {
const date = new Date(timeString);
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} catch {
return timeString;
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-screen">
<Spin size="large" />
</div>
);
}
if (!spaceData) {
return (
<div className="flex items-center w-full justify-center h-screen">
<div className="text-gray-500">Space not found or not started yet</div>
</div>
);
}
return (
<div className="h-full overflow-auto w-full">
<div className="min-h-full flex flex-col">
{/* Header */}
<header className="bg-white shadow w-full max-w-[80rem] mx-auto">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex justify-between items-start">
<div>
<div className="flex items-center gap-3 mb-2">
<h1 className="text-3xl font-bold text-gray-900">{spaceData.title}</h1>
{spaceData.status === 4 ? (
<span className="px-2.5 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
Finished
</span>
) : spaceData.status === 3 ? (
<span className="px-2.5 py-1 rounded-full text-xs font-medium bg-red-100 text-red-800">
Failed
</span>
) : (
<span className="px-2.5 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">
Discussing
</span>
)}
</div>
<p className="text-lg text-gray-600">{spaceData.objective}</p>
</div>
<div className="flex flex-col items-end space-y-4">
{/* Sort participants to ensure the host is listed first */}
{[...spaceData.participants]
.sort((a, b) => {
if (a === spaceData.host) return -1;
if (b === spaceData.host) return 1;
return 0;
})
.map((participant, index) => {
// Extract name from participant URL more effectively
const extractNameFromUrl = (url: string): string => {
try {
// Try to extract name from URL pattern like http://43.130.9.122:5173/secondMeName/instanceId
const urlParts = url.split('/');
if (urlParts.length >= 4) {
return urlParts[3]; // This should be the Second Me name
}
return url.split('/').pop() || `Participant ${index}`;
} catch (error) {
console.error('Error extracting name from URL:', error);
return `Participant ${index}`;
}
};
const participantName = extractNameFromUrl(participant);
const isHost = participant === spaceData.host;
// Find role description if available
const participantInfo = spaceData.participants_info?.find(
(p) => p.url === participant
);
const roleDescription = participantInfo?.role_description || '';
return (
<div key={index} className="flex flex-col items-end">
<div className="flex items-center space-x-2">
<span className="text-sm font-medium text-gray-700">
{isHost ? `${participantName} (Host)` : participantName}
</span>
<img
alt={participantName}
className="w-10 h-10 rounded-full border-2 border-white"
src={`https://api.dicebear.com/7.x/avataaars/svg?seed=${participant}`}
/>
</div>
{roleDescription && (
<div className="mt-1 text-xs text-gray-500 max-w-[200px] text-right">
{roleDescription}
</div>
)}
</div>
);
})}
</div>
</div>
</div>
</header>
{/* Main Content */}
<main className="flex-1 mx-auto py-8 pb-24 w-[80rem] max-w-full">
{/* Messages Area */}
<div className="bg-white rounded-lg shadow mb-8 w-full">
<div className="px-6 py-5 border-b border-gray-200">
<h2 className="text-lg font-medium text-gray-900">AI Collaboration Discussion</h2>
</div>
<div className="px-6 py-5 space-y-6">
{messageGroups.map((group, groupIndex) => (
<div key={groupIndex} className="space-y-4">
<h3 className="text-md font-medium text-gray-700 capitalize">
{group.type === 'opening'
? 'Opening Statement'
: group.type === 'discussion'
? 'Discussion'
: 'Summary'}
</h3>
{group.messages.map((_message) => (
<div
key={_message.id}
className="bg-white rounded-lg border border-gray-100 shadow-sm"
>
<div className="flex items-start p-4">
<img
alt="AI Avatar"
className="w-10 h-10 rounded-full"
src={`https://api.dicebear.com/7.x/avataaars/svg?seed=${_message.sender_endpoint}`}
/>
<div className="ml-4 flex-1">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium text-gray-900">
{(() => {
// Extract name from participant URL more effectively
const extractNameFromUrl = (url: string): string => {
try {
// Try to extract name from URL pattern
const urlParts = url.split('/');
if (urlParts.length >= 4) {
return urlParts[3]; // This should be the Second Me name
}
return url.split('/').pop() || 'Participant';
} catch (error) {
console.error('Error extracting name from URL:', error);
return 'Participant';
}
};
const participantName = extractNameFromUrl(
_message.sender_endpoint
);
return _message.role === 'host'
? `${participantName} (Host)`
: participantName;
})()}
</h3>
<p className="text-xs text-gray-500">{_message.sender_endpoint}</p>
</div>
<span className="text-xs text-gray-500">
{formatTime(_message.create_time)}
</span>
</div>
<SimpleMD
className="!mt-2 text-sm text-gray-700 whitespace-pre-line"
content={_message.content}
/>
</div>
</div>
</div>
))}
</div>
))}
{messageGroups.length === 0 && (
<div className="text-center py-10">
<p className="text-gray-500">
{spaceData?.status === 3
? `Oops, something went wrong.`
: `No messages yet. The discussion will appear here once it starts.`}
</p>
</div>
)}
{/* Loading */}
{![3, 4, undefined].includes(spaceData.status) && (
<Spin size="large" className="!ml-[50%] -translate-x-1/2 !mt-10 !mb-6" />
)}
</div>
</div>
</main>
</div>
</div>
);
}

View File

@ -0,0 +1,182 @@
'use client';
import { useState, useEffect } from 'react';
import { Modal, Upload, message } from 'antd';
import type { RcFile, UploadFile } from 'antd/es/upload';
import type { UploadChangeParam } from 'antd/es/upload/interface';
import { PlusOutlined } from '@ant-design/icons';
import { uploadLoadAvatar } from '@/service/info';
import { useLoadInfoStore } from '@/store/useLoadInfoStore';
interface AvatarUploadProps {
open: boolean;
onClose: () => void;
onAvatarChange: (avatarUrl: string) => void;
currentAvatar?: string;
}
export default function AvatarUpload({
open,
onClose,
onAvatarChange,
currentAvatar
}: AvatarUploadProps) {
const [previewOpen, setPreviewOpen] = useState(false);
const [previewImage, setPreviewImage] = useState('');
const [fileList, setFileList] = useState<any[]>([]);
const [messageApi, contextHolder] = message.useMessage();
const { loadInfo } = useLoadInfoStore();
const beforeUpload = (file: RcFile) => {
if (!file) return false;
const isJpgOrPng = file.type === 'image/jpeg' || file.type === 'image/png';
if (!isJpgOrPng) {
message.error('You can only upload JPG/PNG file!');
return false;
}
const isLt2M = file.size / 1024 / 1024 < 2;
if (!isLt2M) {
message.error('Image must smaller than 2MB!');
return false;
}
return true;
};
const handlePreview = async (file: UploadFile) => {
if (file.originFileObj) {
const reader = new FileReader();
reader.onload = () => {
setPreviewImage(reader.result as string);
setPreviewOpen(true);
};
reader.readAsDataURL(file.originFileObj);
} else if (file.url) {
setPreviewImage(file.url);
setPreviewOpen(true);
}
};
useEffect(() => {
if (currentAvatar) {
setFileList([
{
uid: '-1',
name: 'current-avatar.png',
status: 'done',
url: currentAvatar
}
]);
} else {
setFileList([]);
}
}, [currentAvatar]);
const handleChange = async (info: UploadChangeParam<UploadFile>) => {
const { status } = info.file;
if (status === 'uploading') {
setFileList([{ ...info.file }]);
return;
}
if (status === 'done' && info.file.originFileObj) {
const file = info.file.originFileObj;
// Convert file to base64
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = async () => {
const base64Url = reader.result as string;
try {
// Directly use base64 string for upload, no longer using FormData
if (loadInfo) {
const res = await uploadLoadAvatar(loadInfo.name, {
avatar_data: base64Url
});
if (res.data.code === 0) {
// Use base64 image as avatar
setFileList([
{
uid: '-1',
name: file.name,
status: 'done',
url: base64Url
}
]);
onAvatarChange(base64Url);
useLoadInfoStore.getState().fetchLoadInfo();
messageApi.success('Avatar uploaded successfully');
} else {
setFileList([]);
messageApi.error(res.data.message);
}
}
} catch (err: any) {
setFileList([]);
messageApi.error(err.message || 'Failed to upload avatar');
}
};
}
};
useEffect(() => {
return () => {
fileList.forEach((file) => {
if (file.url?.startsWith('blob:')) {
URL.revokeObjectURL(file.url);
}
});
};
}, [fileList]);
return (
<Modal destroyOnClose footer={null} onCancel={onClose} open={open} title="Upload Avatar">
<div className="p-4">
{contextHolder}
<Upload
accept="image/png,image/jpeg"
beforeUpload={beforeUpload}
fileList={fileList}
listType="picture-card"
maxCount={1}
onChange={handleChange}
onPreview={handlePreview}
onRemove={() => {
setFileList([]);
onAvatarChange('');
}}
showUploadList={{
showPreviewIcon: true,
showRemoveIcon: true,
showDownloadIcon: false
}}
>
{fileList.length >= 1 ? null : (
<div>
<PlusOutlined />
<div style={{ marginTop: 8 }}>Upload</div>
</div>
)}
</Upload>
</div>
<Modal footer={null} onCancel={() => setPreviewOpen(false)} open={previewOpen}>
<img alt="Preview" src={previewImage} style={{ width: '100%' }} />
</Modal>
</Modal>
);
}

View File

@ -0,0 +1,157 @@
'use client';
import type React from 'react';
import { useState, useEffect } from 'react';
import confetti from 'canvas-confetti';
import { motion } from 'framer-motion';
import { useLoadInfoStore } from '@/store/useLoadInfoStore';
interface CelebrationEffectProps {
isVisible: boolean;
onClose: () => void;
}
const CelebrationEffect: React.FC<CelebrationEffectProps> = ({ isVisible, onClose }) => {
const [showMessage, setShowMessage] = useState(false);
const loadInfo = useLoadInfoStore((state) => state.loadInfo);
const secondMeName = loadInfo?.name || 'Second Me';
function randomInRange(min: number, max: number) {
return Math.random() * (max - min) + min;
}
useEffect(() => {
if (isVisible) {
// Immediately show the message to prevent height changes
setShowMessage(true);
// Trigger confetti effect with more realistic settings
const duration = 5 * 1000;
const animationEnd = Date.now() + duration;
const defaults = {
startVelocity: 35,
spread: 360,
ticks: 100,
zIndex: 100,
gravity: 1.2,
drift: 0,
scalar: 1.2,
colors: [
'#5D8BF4',
'#4CC9F0',
'#7209B7',
'#F72585',
'#4361EE',
'#FFD700',
'#00FF00',
'#FF4500'
]
};
const interval: any = setInterval(function () {
const timeLeft = animationEnd - Date.now();
if (timeLeft <= 0) {
return clearInterval(interval);
}
const particleCount = 50 * (timeLeft / duration);
// Launch colorful confetti from both sides
confetti({
...defaults,
particleCount,
origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 }
});
confetti({
...defaults,
particleCount,
origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 }
});
}, 250);
// No auto-close timer - only close when user clicks the button
return () => {
clearInterval(interval);
setShowMessage(false);
};
}
}, [isVisible, onClose]);
// Don't render anything if not visible
if (!isVisible) return null;
return (
<div className="fixed inset-0 flex items-center justify-center z-[100]">
<motion.div
animate={{ opacity: 1 }}
className="bg-secondme-warm-bg p-10 rounded-2xl shadow-2xl max-w-md w-full h-[380px] text-center border-2 border-gray-800/10 relative overflow-hidden"
initial={{ opacity: 0 }}
transition={{ duration: 0.5 }}
>
{/* Background gradient decorations */}
<div className="absolute -top-20 -right-20 w-48 h-48 rounded-full bg-orange-50 opacity-70" />
<div className="absolute -bottom-20 -left-20 w-48 h-48 rounded-full bg-orange-50 opacity-70" />
<div className="relative z-10 h-full flex flex-col justify-center">
<div
className={`transition-opacity duration-500 ${showMessage ? 'opacity-100' : 'opacity-0'}`}
>
<motion.div
animate={{
scale: [1, 1.2, 1],
rotate: [0, 3, -3, 0],
y: [0, -10, 0]
}}
className="mb-6 flex justify-center"
transition={{
repeat: Infinity,
repeatType: 'reverse',
duration: 2.5
}}
>
<div className="relative">
<span className="text-6xl filter drop-shadow-lg"></span>
<motion.div
animate={{ opacity: [0.5, 1, 0.5], scale: [0.8, 1.1, 0.8] }}
className="absolute top-0 left-0 right-0 bottom-0 flex items-center justify-center"
transition={{ repeat: Infinity, duration: 3 }}
>
<span className="text-6xl">🌟</span>
</motion.div>
</div>
</motion.div>
<motion.h2
animate={{ y: [0, -5, 0] }}
className="text-3xl font-bold text-gray-900 mb-3"
transition={{ repeat: 2, duration: 1 }}
>
Training Complete!
</motion.h2>
<div>
<p className="text-gray-700 mb-2 text-lg">
<span className="font-bold">{secondMeName}</span> has been born
</p>
<p className="text-gray-600 mb-4 text-sm leading-relaxed">
Your Second Me has learned from your identity and memories, and is now ready to
chat, share spaces, and connect with other AIs in the network.
</p>
</div>
<motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
<button
className="px-6 py-2.5 bg-gray-900 text-white rounded-lg hover:bg-gray-800 transition-colors font-medium shadow-[3px_3px_0px_0px_rgba(0,0,0,0.1)]"
onClick={onClose}
>
Start the journey!
</button>
</motion.div>
</div>
</div>
</motion.div>
</div>
);
};
export default CelebrationEffect;

View File

@ -0,0 +1,82 @@
'use client';
import { useEffect, useRef } from 'react';
interface InfoModalProps {
open: boolean;
title: string;
content: string | React.ReactNode;
onClose: () => void;
}
export default function InfoModal({ open, title, content, onClose: handleClose }: InfoModalProps) {
const modalRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleEscKey = (event: KeyboardEvent) => {
if (event.key === 'Escape' && open) {
handleClose();
}
};
document.addEventListener('keydown', handleEscKey);
return () => {
document.removeEventListener('keydown', handleEscKey);
};
}, [open, handleClose]);
const handleBackdropClick = (e: React.MouseEvent) => {
if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
handleClose();
}
};
if (!open) return null;
return (
<div
className="fixed inset-0 bg-black/30 backdrop-blur-[2px] flex items-center justify-center p-4 z-50"
onClick={handleBackdropClick}
>
<div
ref={modalRef}
className="bg-white rounded-xl p-6 max-w-lg w-full shadow-lg border border-gray-100"
>
<div className="flex justify-between items-start mb-4">
<h3 className="text-xl font-semibold tracking-tight text-gray-900">{title}</h3>
<button
className="p-1.5 rounded-full text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition-colors"
onClick={handleClose}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
d="M6 18L18 6M6 6l12 12"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
/>
</svg>
</button>
</div>
<div className="prose prose-gray prose-sm max-w-none">
{typeof content === 'string' ? (
<p className="text-gray-600 leading-relaxed">{content}</p>
) : (
content
)}
</div>
<div className="mt-6 flex justify-end">
<button
className="px-4 py-2 text-sm font-medium bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
onClick={handleClose}
>
Got it
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,59 @@
import { Spin } from 'antd';
import classNames from 'classnames';
import { memo, useEffect, useRef } from 'react';
interface IProps {
className?: string;
scrollContainerId?: string;
loadMore: () => Promise<void>;
}
function LoadMore(props: IProps): JSX.Element {
const { loadMore, className, scrollContainerId = '#scrollContainer' } = props;
const eleRef = useRef<HTMLDivElement>(null);
const loadingRef = useRef<boolean>(false);
useEffect(() => {
if (!eleRef.current) {
return;
}
function callback(entries: IntersectionObserverEntry[]) {
entries.forEach((entry) => {
if (entry.isIntersecting && !loadingRef.current) {
loadingRef.current = true;
loadMore().finally(() => {
loadingRef.current = false;
});
}
});
}
const observer = new IntersectionObserver(callback, {
rootMargin: '50px',
root: document.querySelector(scrollContainerId),
threshold: [0.1]
});
const delayInMilliseconds = 500;
const timeoutId = setTimeout(() => {
if (eleRef.current) {
observer.observe(eleRef.current);
}
}, delayInMilliseconds);
return () => {
clearTimeout(timeoutId);
observer.disconnect();
};
}, [loadMore, scrollContainerId]);
return (
<div ref={eleRef} className={classNames('flex items-center justify-center', className)}>
<Spin />
</div>
);
}
export default memo(LoadMore);

View File

@ -0,0 +1,249 @@
import { useTrainingStore } from '@/store/useTrainingStore';
import { startService, stopService, getServiceStatus } from '@/service/train';
import { StatusBar } from '../StatusBar';
import { useRef, useEffect, useState, useMemo } from 'react';
import { message } from 'antd';
import {
CloudUploadOutlined,
CheckCircleOutlined,
PlayCircleOutlined,
PauseCircleOutlined,
LoadingOutlined
} from '@ant-design/icons';
import RegisterUploadModal from '../upload/RegisterUploadModal';
import { useLoadInfoStore } from '@/store/useLoadInfoStore';
const StatusDot = ({ active }: { active: boolean }) => (
<div
className={`w-2 h-2 rounded-full mr-2 transition-colors duration-300 ${active ? 'bg-[#52c41a]' : 'bg-[#ff4d4f]'}`}
/>
);
export function ModelStatus() {
const status = useTrainingStore((state) => state.status);
const setStatus = useTrainingStore((state) => state.setStatus);
const isServiceStarting = useTrainingStore((state) => state.isServiceStarting);
const isServiceStopping = useTrainingStore((state) => state.isServiceStopping);
const setServiceStarting = useTrainingStore((state) => state.setServiceStarting);
const setServiceStopping = useTrainingStore((state) => state.setServiceStopping);
const [messageApi, contextHolder] = message.useMessage();
const loadInfo = useLoadInfoStore((state) => state.loadInfo);
const isRegistered = useMemo(() => {
return loadInfo?.status === 'online';
}, [loadInfo]);
const [showRegisterModal, setShowRegisterModal] = useState(false);
const handleRegistryClick = () => {
if (status !== 'trained' && status !== 'running') {
messageApi.info({
content: 'Please train your model first',
duration: 1
});
} else if (status === 'trained') {
messageApi.info({
content: 'Please start your model service first',
duration: 1
});
} else if (status === 'running') {
setShowRegisterModal(true);
}
};
const fetchServiceStatus = async () => {
try {
const statusRes = await getServiceStatus();
if (statusRes.data.code === 0) {
const isRunning = statusRes.data.data.is_running;
if (isRunning) {
setStatus('running');
setServiceStarting(false);
} else if (status === 'running') {
setStatus('trained');
}
}
} catch (error) {
console.error('Error checking initial service status:', error);
}
};
useEffect(() => {
fetchServiceStatus();
return () => {
clearPolling();
};
}, []);
const pollingInterval = useRef<NodeJS.Timeout | null>(null);
const clearPolling = () => {
if (pollingInterval.current) {
clearInterval(pollingInterval.current);
pollingInterval.current = null;
}
};
const startPolling = () => {
clearPolling();
// Start new polling interval
pollingInterval.current = setInterval(() => {
getServiceStatus()
.then((statusRes) => {
if (statusRes.data.code === 0) {
const isRunning = statusRes.data.data.is_running;
if (isRunning) {
setStatus('running');
setServiceStarting(false);
clearPolling();
}
}
})
.catch((error) => {
console.error('Error checking service status:', error);
});
}, 3000);
};
const startStopPolling = () => {
clearPolling();
// Start new polling interval
pollingInterval.current = setInterval(() => {
getServiceStatus()
.then((statusRes) => {
if (statusRes.data.code === 0) {
const isRunning = statusRes.data.data.is_running;
if (!isRunning) {
setStatus('trained');
setServiceStopping(false);
clearPolling();
}
}
})
.catch((error) => {
console.error('Error checking service status:', error);
});
}, 3000);
};
const handleServiceAction = () => {
const config = JSON.parse(localStorage.getItem('trainingConfig') || '{}');
if (status === 'running') {
setServiceStopping(true);
stopService()
.then((res) => {
if (res.data.code === 0) {
messageApi.success({ content: 'Service stopping...', duration: 1 });
startStopPolling();
} else {
messageApi.error({ content: res.data.message!, duration: 1 });
setServiceStopping(false);
}
})
.catch((error) => {
console.error('Error stopping service:', error);
messageApi.error({
content: error.response?.data?.message || error.message,
duration: 1
});
setServiceStopping(false);
});
} else {
setServiceStarting(true);
startService({ model_name: config.baseModel || 'Qwen2.5-0.5B-Instruct' })
.then((res) => {
if (res.data.code === 0) {
messageApi.success({ content: 'Service starting...', duration: 1 });
startPolling();
} else {
setServiceStarting(false);
messageApi.error({ content: res.data.message!, duration: 1 });
}
})
.catch((error) => {
console.error('Error starting service:', error);
setServiceStarting(false);
messageApi.error({
content: error.response?.data?.message || error.message,
duration: 1
});
});
}
};
return (
<div className="flex items-center justify-center gap-4 mx-auto">
{contextHolder}
<StatusBar status={status} />
<div className="flex items-center gap-6">
{/* Control Buttons */}
<div className="flex items-center gap-3">
<div
className={`
flex items-center space-x-1.5 text-sm whitespace-nowrap
${
isServiceStarting || isServiceStopping
? 'text-gray-400 cursor-not-allowed'
: 'text-gray-600 hover:text-blue-600 cursor-pointer transition-all hover:-translate-y-0.5'
}
`}
onClick={isServiceStarting || isServiceStopping ? undefined : handleServiceAction}
>
{isServiceStarting || isServiceStopping ? (
<>
<LoadingOutlined className="text-lg" spin />
<span>{isServiceStarting ? 'Starting...' : 'Stopping...'}</span>
</>
) : status === 'running' ? (
<>
<StatusDot active={true} />
<PauseCircleOutlined className="text-lg" />
<span>Stop Service</span>
</>
) : (
<>
<StatusDot active={false} />
<PlayCircleOutlined className="text-lg" />
<span>Start Service</span>
</>
)}
</div>
<div className="w-px h-4 bg-gray-200" />
<div
className="flex items-center whitespace-nowrap space-x-1.5 text-sm text-gray-600 hover:text-blue-600 cursor-pointer transition-all hover:-translate-y-0.5 mr-2"
onClick={handleRegistryClick}
>
{isRegistered ? (
<>
<StatusDot active={true} />
<CheckCircleOutlined className="text-lg" />
<span>Join AI Network</span>
</>
) : (
<>
<StatusDot active={false} />
<CloudUploadOutlined className="text-lg" />
<span>Join AI Network</span>
</>
)}
</div>
</div>
</div>
<RegisterUploadModal onClose={() => setShowRegisterModal(false)} open={showRegisterModal} />
</div>
);
}

View File

@ -0,0 +1,270 @@
'use client';
import type React from 'react';
import { useEffect, useRef, useState } from 'react';
import * as THREE from 'three';
interface NameNode {
name: string;
size: number;
color: string;
position: [number, number, number];
}
interface NetworkSphereProps {
onInitialized?: () => void;
}
const NetworkSphere: React.FC<NetworkSphereProps> = ({ onInitialized }) => {
const containerRef = useRef<HTMLDivElement>(null);
const [isInitialized, setIsInitialized] = useState(false);
const sceneRef = useRef<THREE.Scene | null>(null);
const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
useEffect(() => {
// Store animation frame ID for cleanup
let animationFrameId: number;
// Store scene and renderer references for cleanup
let scene: THREE.Scene;
let renderer: THREE.WebGLRenderer;
let camera: THREE.PerspectiveCamera;
// Function to initialize the sphere
const initializeSphere = () => {
if (!containerRef.current) return;
// Create scene
scene = new THREE.Scene();
sceneRef.current = scene;
scene.background = null; // Transparent background
// Create camera
camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 2000);
camera.position.z = 650;
// Create renderer
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
rendererRef.current = renderer;
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
containerRef.current.appendChild(renderer.domElement);
// Define sphere radius
const radius = 320;
// Create nodes
const names: NameNode[] = [];
const colors = ['#4ecdc4', '#ff6b6b', '#ffd93d'];
const sizes = [0.9, 1.0, 1.1, 1.2, 1.3];
// Create 40 nodes, reducing density
for (let i = 0; i < 40; i++) {
names.push({
name: `Node${i}`,
size: sizes[Math.floor(Math.random() * sizes.length)],
color: colors[Math.floor(Math.random() * colors.length)],
position: randomSpherePoint(radius)
});
}
// Only create colored dots, don't display names
const dotGeometry = new THREE.SphereGeometry(3.5, 16, 16);
const dotMaterials = {
'#4ecdc4': new THREE.MeshBasicMaterial({ color: 0x4ecdc4 }),
'#ff6b6b': new THREE.MeshBasicMaterial({ color: 0xff6b6b }),
'#ffd93d': new THREE.MeshBasicMaterial({ color: 0xffd93d })
};
// Create dots for each node
names.forEach((node) => {
const material = dotMaterials[node.color as keyof typeof dotMaterials];
const dot = new THREE.Mesh(dotGeometry, material);
dot.position.set(...node.position);
scene.add(dot);
});
// Create connections between nodes
// const connectionMaterial = new THREE.LineBasicMaterial({
// color: 0xadd8e6, // Light blue color - keeping original color
// transparent: true,
// opacity: 0.35 // Increased opacity to make lines more visible
// });
// Connect each node with more nearby nodes
const maxConnections = 5; // Increased maximum number of connections per node
const maxDistance = radius * 1.5; // Increased maximum distance for connections
names.forEach((node, index) => {
// Calculate distances to all other nodes
const distances: { index: number; distance: number }[] = names.map(
(otherNode, otherIndex) => {
if (index === otherIndex)
return {
index: otherIndex,
distance: Infinity
}; // Don't connect to self
const dx = node.position[0] - otherNode.position[0];
const dy = node.position[1] - otherNode.position[1];
const dz = node.position[2] - otherNode.position[2];
return {
index: otherIndex,
distance: Math.sqrt(dx * dx + dy * dy + dz * dz)
};
}
);
// Sort by distance and take the closest nodes
distances.sort((a, b) => a.distance - b.distance);
const connectCount = Math.floor(Math.random() * maxConnections) + 1; // At least one connection
for (let i = 0; i < connectCount && i < distances.length; i++) {
if (distances[i].distance > maxDistance) continue;
// Only create connection if the other node's index is higher
// This prevents creating the same connection twice
if (distances[i].index > index) {
const otherNode = names[distances[i].index];
// Create curved arc geometry that follows the sphere's surface
const points = [];
// Calculate the great circle arc between the two points
// First, normalize the positions to get direction vectors
const startPos = new THREE.Vector3(...node.position).normalize();
const endPos = new THREE.Vector3(...otherNode.position).normalize();
// Calculate the angle between the two points
// const angle = startPos.angleTo(endPos);
// Create intermediate points along the great circle arc
const segments = 12; // More segments for smoother curves
for (let j = 0; j <= segments; j++) {
// Interpolation factor
const t = j / segments;
// Spherical linear interpolation (SLERP) between the two points
const interpVec = new THREE.Vector3().copy(startPos).lerp(endPos, t).normalize();
// Scale to the sphere radius
const pointOnSphere = interpVec.multiplyScalar(radius);
points.push(pointOnSphere);
}
const lineGeometry = new THREE.BufferGeometry().setFromPoints(points);
// Create connection with varying opacity based on distance
const opacity = Math.max(0.25, 0.45 - (distances[i].distance / maxDistance) * 0.2);
const lineMaterial = new THREE.LineBasicMaterial({
color: 0xadd8e6, // Light blue color - keeping original color
transparent: true,
opacity: opacity
});
const line = new THREE.Line(lineGeometry, lineMaterial);
scene.add(line);
}
}
});
// Animation
const rotationSpeed = 0.0005;
const animate = () => {
animationFrameId = requestAnimationFrame(animate);
// Rotate all elements
scene.rotation.y += rotationSpeed;
scene.rotation.x += rotationSpeed / 2;
renderer.render(scene, camera);
};
animate();
// Mark as initialized
setIsInitialized(true);
// Notify parent component that initialization is complete
if (onInitialized) {
// Use setTimeout to ensure this happens after the component is fully rendered
setTimeout(() => {
onInitialized();
}, 0);
}
};
// Handle window resize
const handleResize = () => {
if (!camera || !renderer) return;
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
};
// Initialize the sphere
initializeSphere();
// Add resize event listener
window.addEventListener('resize', handleResize);
// Cleanup function
return () => {
window.removeEventListener('resize', handleResize);
// Cancel any pending animation frames
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
}
// Remove renderer from DOM
if (containerRef.current && rendererRef.current) {
containerRef.current.removeChild(rendererRef.current.domElement);
}
// Release resources
if (sceneRef.current) {
sceneRef.current.clear();
}
if (rendererRef.current) {
rendererRef.current.dispose();
}
};
}, []);
// Generate random points on a sphere
function randomSpherePoint(radius: number): [number, number, number] {
const u = Math.random();
const v = Math.random();
const theta = 2 * Math.PI * u;
const phi = Math.acos(2 * v - 1);
const x = radius * Math.sin(phi) * Math.cos(theta);
const y = radius * Math.sin(phi) * Math.sin(theta);
const z = radius * Math.cos(phi);
return [x, y, z];
}
// Since we no longer display names, the text sprite creation function has been removed
return (
<div
ref={containerRef}
className="fixed inset-0 -z-10 w-screen h-screen overflow-hidden"
style={{
pointerEvents: 'none',
opacity: isInitialized ? 1 : 0,
transition: 'opacity 0.5s ease-in-out'
}}
/>
);
};
export default NetworkSphere;

View File

@ -0,0 +1,115 @@
'use client';
import Image from 'next/image';
interface OnboardingTutorialProps {
onComplete: () => void;
onClose: () => void;
}
export default function OnboardingTutorial({ onComplete, onClose }: OnboardingTutorialProps) {
const steps = [
{
title: 'Define Your Identity',
description: 'Start by defining your identity - this is the foundation of your Second Me.',
image: '/images/step_1.png'
},
{
title: 'Upload Your Memories',
description: 'Share your experiences by uploading notes, documents, or other content.',
image: '/images/step_2.png'
},
{
title: 'Train Your Second Me',
description: 'Train your AI model, learning your identity, experience and preferences.',
image: '/images/step_3.png'
},
{
title: 'Join AI Network',
description:
'Explore interactions between your Second Me and other AI entities in the network.',
image: '/images/step_4.png'
}
];
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-[100]">
<div className="bg-secondme-warm-bg rounded-2xl p-10 max-w-6xl w-full shadow-2xl border-2 border-gray-800/10 relative overflow-hidden">
{/* Background gradient decorations */}
<div className="absolute -top-20 -right-20 w-64 h-64 rounded-full bg-orange-50 opacity-70" />
<div className="absolute -bottom-20 -left-20 w-64 h-64 rounded-full bg-orange-50 opacity-70" />
<button
aria-label="Close"
className="absolute top-5 right-5 text-gray-500 hover:text-gray-700 transition-colors z-10"
onClick={onClose}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
d="M6 18L18 6M6 6l12 12"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
/>
</svg>
</button>
<div className="relative z-10">
<h1 className="text-3xl font-bold mb-3 text-gray-900">How to Create Your Second Me</h1>
<p className="text-lg text-gray-600 max-w-3xl mb-8">
Follow these simple steps to build your digital identity foundation.
</p>
<div className="grid grid-cols-4 gap-6 mb-10">
{steps.map((step, index) => (
<div
key={index}
className="rounded-2xl overflow-hidden border-2 border-gray-800/10 hover:border-gray-800/20
transition-all bg-white shadow-[4px_4px_0px_0px_rgba(0,0,0,0.05)]
hover:shadow-[6px_6px_0px_0px_rgba(0,0,0,0.08)] hover:-translate-y-0.5 flex flex-col"
>
<div className="relative w-full pt-[75%] group">
<div className="absolute inset-0 bg-gradient-to-b from-transparent to-black/5 group-hover:to-black/10 transition-all" />
<div className="absolute inset-0">
<Image
alt={step.title}
className="transition-transform group-hover:scale-105 object-cover w-full h-full"
height={100}
priority
src={step.image}
width={100}
/>
</div>
<div className="absolute top-3 left-3 flex items-center justify-center w-7 h-7 rounded-full bg-gray-900 text-white text-xs font-bold">
{index + 1}
</div>
</div>
<div className="p-4 bg-gradient-to-b from-white to-gray-50 flex-grow">
<h3 className="text-base font-semibold mb-1.5 text-gray-800">{step.title}</h3>
<p className="text-sm text-gray-600/90 leading-relaxed">{step.description}</p>
</div>
</div>
))}
</div>
<div className="flex justify-end items-center pt-4 border-t border-gray-800/10">
<button
className="px-8 py-3 bg-gray-900 text-white rounded-lg hover:bg-gray-800 transition-colors font-medium flex items-center gap-2 shadow-[3px_3px_0px_0px_rgba(0,0,0,0.1)]"
onClick={onComplete}
>
Continue
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
d="M9 5l7 7-7 7"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
/>
</svg>
</button>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,168 @@
.markdown {
position: relative;
width: 100%;
color: #2f2f40;
font-weight: 400;
font-size: 13px;
line-height: 24px;
}
.markdown h1::selection,
.markdown h2::selection,
.markdown h3::selection,
.markdown h4::selection,
.markdown h5::selection,
.markdown h6::selection,
.markdown a::selection,
.markdown li::selection,
.markdown code::selection,
.markdown th::selection,
.markdown td::selection,
.markdown s::selection,
.markdown strong::selection,
.markdown em::selection,
.markdown span::selection,
.markdown p::selection {
background: rgba(169, 157, 245, 40%);
}
.markdown h1 {
margin: 24px 0 0;
color: #2f2f40;
font-weight: 700;
font-size: 13px;
line-height: 24px;
}
.markdown h2 {
margin: 22px 0 0;
color: #2f2f40;
font-weight: 700;
font-size: 13px;
line-height: 24px;
}
.markdown h3 {
margin: 20px 0 0;
color: #2f2f40;
font-weight: 700;
font-size: 13px;
line-height: 24px;
}
.markdown h4 {
margin: 18px 0 0;
color: #2f2f40;
font-weight: 700;
font-size: 13px;
line-height: 24px;
}
.markdown h5 {
margin: 16px 0 0;
color: #2f2f40;
font-weight: 700;
font-size: 13px;
line-height: 24px;
}
.markdown h6 {
margin: 14px 0 0;
color: #2f2f40;
font-weight: 700;
font-size: 13px;
line-height: 24px;
}
.markdown ol,
.markdown ul {
padding-inline-start: 16px;
margin-block-start: 12px;
margin-block-end: 12px;
}
.markdown ol li,
.markdown ul li {
margin-bottom: 8px;
}
.markdown strong {
font-weight: 700;
}
.markdown hr {
margin: 30px 0 12px;
border-color: transparent;
border-bottom-color: #d8d8d8;
}
.markdown p {
margin: 12px 0 0;
}
.markdown a {
color: #379fff;
}
.markdown a:link,
.markdown a:hover,
.markdown a:visited {
color: #379fff;
}
.markdown p code,
.markdown li code {
color: var(--primary-color);
font-family: PTSerif, STSong, sans-serif;
}
.markdown img {
display: block;
max-width: 100%;
margin: 30px 0 12px;
}
.markdown table {
display: block;
max-width: 100%;
margin: 30px 0 12px;
overflow: auto;
border-collapse: collapse;
border-spacing: 1px;
}
.markdown table tr {
height: 46px;
}
.markdown table th {
min-width: 80px;
max-width: 360px;
padding: 12px 20px;
color: rgba(0, 0, 0, 60%);
font-weight: bold;
text-align: left;
vertical-align: top;
border: 1px solid #d9d9d9;
}
.markdown table td {
min-width: 80px;
max-width: 360px;
padding: 12px 20px;
color: rgba(0, 0, 0, 60%);
vertical-align: top;
border: 1px solid #d9d9d9;
}
.markdown > *:last-child {
margin-bottom: 0;
}
.markdown > *:first-child {
margin-top: 0;
}
.markdown > *:first-child *:first-child {
margin-top: 0;
}

View File

@ -0,0 +1,43 @@
import classNames from 'classnames';
import { marked } from 'marked';
import markedLinkifyIt from 'marked-linkify-it';
import { type CSSProperties, memo, useEffect, useRef } from 'react';
import styles from './index.module.css';
export interface IProps {
className?: string;
style?: CSSProperties;
children?: React.ReactNode;
content?: string;
id?: string;
}
const renderer = new marked.Renderer();
// markdown
marked.setOptions({ renderer });
marked.use(markedLinkifyIt());
function SimplyMD(props: IProps) {
const { className, content, style } = props;
const markdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (markdownRef.current) {
markdownRef.current.innerHTML = marked.parse(content ?? '');
markdownRef.current.innerHTML = markdownRef.current.innerHTML
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>');
}
}, [content]);
return (
<div className={styles.markdown}>
<div ref={markdownRef} className={classNames(className, 'relative')} style={style} />
</div>
);
}
export default memo(SimplyMD);

View File

@ -0,0 +1,80 @@
import { type ModelStatus } from '@/store/useTrainingStore';
interface StatusBarProps {
status: ModelStatus;
}
export function StatusBar({ status }: StatusBarProps) {
const steps = [
{ title: 'Identity', status: 'seed_identity', icon: '🌱' },
{ title: 'Memory Upload', status: 'memory_upload', icon: '📝' },
{ title: 'Training', status: 'training', icon: '⚡' },
{ title: 'Trained', status: 'trained', icon: '✓' }
] as const;
const getStepState = (stepStatus: (typeof steps)[number]['status']) => {
// If current status is running, all steps should be shown as completed, and trained should be active
if (status === 'running') {
return {
isActive: stepStatus === 'trained',
isCompleted: stepStatus !== 'trained'
};
}
const statusOrder = ['seed_identity', 'memory_upload', 'training', 'trained'];
const currentIndex = statusOrder.indexOf(status);
const stepIndex = statusOrder.indexOf(stepStatus);
// If current status is trained, all previous steps should be completed
if (status === 'trained') {
return {
isActive: stepStatus === 'trained',
isCompleted: stepStatus !== 'trained'
};
}
// If current status is training, previous steps should be completed
if (
status === 'training' &&
(stepStatus === 'seed_identity' || stepStatus === 'memory_upload')
) {
return {
isActive: false,
isCompleted: true
};
}
return {
isActive: stepStatus === status,
isCompleted: currentIndex > stepIndex
};
};
return (
<div className="inline-flex items-center">
{steps.map((step, index) => {
const state = getStepState(step.status);
const currentStepIndex = steps.findIndex((s) => s.status === status);
return (
<div key={step.status} className="flex items-center">
{index > 0 && (
<div
className={`w-9 h-[1px] transition-colors ${index <= currentStepIndex ? 'bg-blue-600' : 'bg-gray-200'}`}
/>
)}
<div
className={`flex items-center gap-1.5 px-2 py-1 text-sm font-medium transition-colors whitespace-nowrap
${state.isActive || state.isCompleted ? 'text-blue-600' : 'text-gray-300'}`}
>
<span className={state.isActive || state.isCompleted ? 'text-blue-600' : ''}>
{step.icon}
</span>
<span>{step.title}</span>
</div>
</div>
);
})}
</div>
);
}

View File

@ -0,0 +1,72 @@
'use client';
import { DeleteOutlined } from '@ant-design/icons';
interface ChatSession {
id: string;
title: string;
lastMessage: string;
timestamp: string;
}
interface ChatHistoryProps {
sessions: ChatSession[];
activeSessionId: string | null;
onSessionClick: (sessionId: string) => void;
onNewChat: () => void;
onDeleteChat: (sessionId: string) => void;
}
export default function ChatHistory({
sessions,
activeSessionId,
onSessionClick,
onNewChat,
onDeleteChat
}: ChatHistoryProps) {
return (
<div className="w-64 bg-gray-50 border-r h-full flex flex-col">
<div className="p-4">
<button
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
onClick={onNewChat}
>
<svg
className="h-5 w-5"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
clipRule="evenodd"
d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z"
fillRule="evenodd"
/>
</svg>
New Chat
</button>
</div>
<div className="flex-1 overflow-y-auto">
{sessions.map((session) => (
<button
key={session.id}
className={`w-full text-left p-4 border-b hover:bg-gray-100 transition-colors ${
activeSessionId === session.id ? 'bg-gray-100' : ''
}`}
onClick={() => onSessionClick(session.id)}
>
<div className="flex items-center">
<div className="text-sm font-medium truncate">{session.title}</div>
<div className="ml-auto text-gray-400 hover:text-gray-600 transition-colors">
<DeleteOutlined onClick={() => onDeleteChat(session.id)} />
</div>
</div>
<div className="text-xs text-gray-500 mt-1 truncate">{session.lastMessage}</div>
<div className="text-xs text-gray-400 mt-1">{session.timestamp}</div>
</button>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,61 @@
'use client';
import type { KeyboardEvent } from 'react';
import { useState } from 'react';
interface ChatInputProps {
onSendMessage: (message: string) => void;
disabled?: boolean;
}
export default function ChatInput({ onSendMessage, disabled = false }: ChatInputProps) {
const [message, setMessage] = useState('');
const handleSubmit = () => {
if (message.trim() && !disabled) {
onSendMessage(message);
setMessage('');
}
};
const handleKeyPress = (e: KeyboardEvent<HTMLTextAreaElement>) => {
// Check if it's an Enter key from IME (Input Method Editor)
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
handleSubmit();
}
};
return (
<div className="border-t bg-white p-4">
<div className="mx-auto flex gap-4">
<textarea
className="flex-1 resize-none rounded-lg border border-gray-200 p-3 focus:outline-none focus:ring-2 focus:ring-blue-500"
disabled={disabled}
onChange={(e) => setMessage(e.target.value)}
onCompositionEnd={(e) => {
// After IME composition ends, if it's an Enter key, don't trigger send
if (e.data === '\n') {
e.preventDefault();
}
}}
onKeyDown={handleKeyPress}
placeholder="Message Second Me..."
rows={1}
value={message}
/>
<button
className={`px-4 py-2 rounded-lg ${
!message.trim() || disabled
? 'bg-gray-100 text-gray-400'
: 'bg-blue-600 text-white hover:bg-blue-700'
} transition-colors`}
disabled={!message.trim() || disabled}
onClick={handleSubmit}
>
Send
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,33 @@
'use client';
interface ChatMessageProps {
message: string;
isUser: boolean;
timestamp: string;
isLoading?: boolean;
}
export default function ChatMessage({ message, isUser, timestamp, isLoading }: ChatMessageProps) {
return (
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'} mb-4`}>
<div
className={`max-w-[80%] rounded-lg p-4 ${isUser ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-900'}`}
>
<div className="text-sm max-w-[50vw] whitespace-pre-wrap break-words">
{isLoading ? (
<div className="flex items-center gap-1.5">
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce [animation-delay:-0.3s]" />
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce [animation-delay:-0.15s]" />
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce" />
</div>
) : (
message
)}
</div>
<div className={`text-xs mt-1 ${isUser ? 'text-blue-200' : 'text-gray-500'}`}>
{timestamp}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,252 @@
import { getModelConfig, updateModelConfig } from '@/service/modelConfig';
import { useModelConfigStore } from '@/store/useModelConfigStore';
import { Input, message, Modal, Radio } from 'antd';
import Image from 'next/image';
import { useCallback, useEffect, useState } from 'react';
interface IProps {
open: boolean;
onClose: () => void;
}
const options = [
{
label: 'None',
value: ''
},
{
label: 'OpenAI',
value: 'openai'
},
{
label: 'Custom',
value: 'litellm'
}
];
const ModelConfigModal = (props: IProps) => {
const { open, onClose } = props;
const modelConfig = useModelConfigStore((store) => store.modelConfig);
const updateLocalModelConfig = useModelConfigStore((store) => store.updateModelConfig);
const [modelType, setModelType] = useState<string>('');
useEffect(() => {
getModelConfig().then((res) => {
if (res.data.code == 0) {
const data = res.data.data || {};
updateLocalModelConfig(data);
setModelType(data.provider_type);
} else {
message.error(res.data.message);
}
});
}, []);
const renderEmpty = () => {
return (
<div className="flex flex-col items-center">
<Image
alt="SecondMe Logo"
className="object-contain"
height={40}
src="/images/single_logo.png"
width={120}
/>
<div className="text-gray-500 text-[18px] leading-[32px]">
Please Choose OpenAI or Custom
</div>
</div>
);
};
const renderOpenai = useCallback(() => {
return (
<div className="flex flex-col w-full gap-4">
<div className="p-4 border rounded-lg hover:shadow-md transition-shadow">
<label className="block text-sm font-medium text-gray-700 mb-1">API Key</label>
<Input.Password
onChange={(e) => {
updateLocalModelConfig({ ...modelConfig, key: e.target.value });
}}
placeholder="Enter your OpenAI API key"
value={modelConfig.key}
/>
<div className="mt-2 text-sm text-gray-500">
You can get your API key from{' '}
<a
className="text-blue-500 hover:underline"
href="https://platform.openai.com/settings/organization/api-keys"
rel="noopener noreferrer"
target="_blank"
>
OpenAI API Keys page
</a>
.
</div>
</div>
</div>
);
}, [modelConfig]);
const renderCustom = useCallback(() => {
return (
<div className="flex flex-col w-full gap-6 p-4">
<div className="p-4 border rounded-lg hover:shadow-md transition-shadow">
<label className="block text-sm font-medium text-gray-700 mb-1">Chat</label>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex flex-col">
<div className="text-sm font-medium text-gray-700 mb-1">Model Name</div>
<Input
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
className="w-full"
data-form-type="other"
onChange={(e) => {
updateLocalModelConfig({ ...modelConfig, chat_model_name: e.target.value });
}}
spellCheck="false"
value={modelConfig.chat_model_name}
/>
</div>
<div className="flex flex-col">
<div className="text-sm font-medium text-gray-700 mb-1">API Key</div>
<Input.Password
autoCapitalize="off"
autoComplete="new-password"
autoCorrect="off"
className="w-full"
data-form-type="other"
onChange={(e) => {
updateLocalModelConfig({ ...modelConfig, chat_api_key: e.target.value });
}}
spellCheck="false"
value={modelConfig.chat_api_key}
/>
</div>
</div>
<div className="mt-4">
<label className="block text-sm font-medium text-gray-700 mb-1">API Endpoint</label>
<Input
autoComplete="off"
className="w-full"
onChange={(e) => {
updateLocalModelConfig({ ...modelConfig, chat_endpoint: e.target.value });
}}
value={modelConfig.chat_endpoint}
/>
</div>
</div>
<div className="p-4 border rounded-lg hover:shadow-md transition-shadow">
<label className="block text-sm font-medium text-gray-700 mb-1">Embedding</label>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Model Name</label>
<Input
className="w-full"
onChange={(e) => {
updateLocalModelConfig({ ...modelConfig, embedding_model_name: e.target.value });
}}
value={modelConfig.embedding_model_name}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">API Key</label>
<Input.Password
className="w-full"
onChange={(e) => {
updateLocalModelConfig({ ...modelConfig, embedding_api_key: e.target.value });
}}
value={modelConfig.embedding_api_key}
/>
</div>
</div>
<div className="mt-4">
<label className="block text-sm font-medium text-gray-700 mb-1">API Endpoint</label>
<Input
className="w-full"
onChange={(e) => {
updateLocalModelConfig({ ...modelConfig, embedding_endpoint: e.target.value });
}}
value={modelConfig.embedding_endpoint}
/>
</div>
</div>
</div>
);
}, [modelConfig]);
const handleUpdate = () => {
// When None is selected, save an empty provider_type instead of deleting the config
const providerType = modelType || '';
updateModelConfig({ ...modelConfig, provider_type: providerType })
.then((res) => {
if (res.data.code == 0) {
updateLocalModelConfig({ ...modelConfig, provider_type: providerType });
onClose();
} else {
throw new Error(res.data.message);
}
})
.catch((error: any) => {
message.error(error.message || 'Failed to update model config');
});
};
const renderMainContent = useCallback(() => {
if (!modelType) {
return renderEmpty();
}
if (modelType === 'openai') {
return renderOpenai();
}
return renderCustom();
}, [modelType, renderOpenai, renderCustom]);
return (
<Modal
centered
destroyOnClose
okButtonProps={{ disabled: !modelType }}
onCancel={onClose}
onOk={handleUpdate}
open={open}
title={
<div className="text-xl font-semibold leading-6 text-gray-900">
Support Model Configuration
</div>
}
>
<div className="flex flex-col items-center">
<div className="flex flex-col items-center gap-2">
<p className="mb-1 text-sm text-gray-500">
Configure models used for training data synthesis for Second Me, and as external
reference models that Second Me can consult during usage.
</p>
<Radio.Group
buttonStyle="solid"
onChange={(e) => setModelType(e.target.value)}
optionType="button"
options={options}
value={modelType ? modelType : ''}
/>
</div>
<div className="w-full border-t border-gray-200 mt-1 mb-2" />
{renderMainContent()}
<div className="w-full border-t border-gray-200 mt-4" />
</div>
</Modal>
);
};
export default ModelConfigModal;

View File

@ -0,0 +1,150 @@
'use client';
import { useState } from 'react';
interface UploadParticipant {
name: string;
apiEndpoint: string;
apiKey: string;
}
interface CreateTaskModalProps {
onClose: () => void;
onSubmit: (taskData: { description: string; participants: UploadParticipant[] }) => void;
currentUploadName: string;
}
export default function CreateTaskModal({
onClose,
onSubmit,
currentUploadName
}: CreateTaskModalProps) {
const [description, setDescription] = useState('');
const [participants, setParticipants] = useState<UploadParticipant[]>([
{ name: currentUploadName, apiEndpoint: '', apiKey: '' } // Current upload is default
]);
const handleAddParticipant = () => {
setParticipants([...participants, { name: '', apiEndpoint: '', apiKey: '' }]);
};
const handleParticipantChange = (
index: number,
field: keyof UploadParticipant,
value: string
) => {
const newParticipants = [...participants];
newParticipants[index] = { ...newParticipants[index], [field]: value };
setParticipants(newParticipants);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSubmit({ description, participants });
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4">
<div className="bg-white rounded-lg p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<h2 className="text-2xl font-bold mb-4">Create New Multi-Upload Task</h2>
<form className="space-y-6" onSubmit={handleSubmit}>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Task Description</label>
<textarea
className="w-full rounded-md border border-gray-300 p-3 min-h-[100px]"
onChange={(e) => setDescription(e.target.value)}
placeholder="Describe the task that the uploads should complete..."
required
value={description}
/>
</div>
<div>
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium">Participating Uploads</h3>
<button
className="text-blue-600 hover:text-blue-700"
onClick={handleAddParticipant}
type="button"
>
+ Add Upload
</button>
</div>
<div className="space-y-4">
{participants.map((participant, index) => (
<div key={index} className="border rounded-lg p-4">
<div className="grid gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Upload Name
</label>
<input
className="w-full rounded-md border border-gray-300 p-2"
disabled={index === 0} // Current upload can't be changed
onChange={(e) => handleParticipantChange(index, 'name', e.target.value)}
required
type="text"
value={participant.name}
/>
</div>
{index !== 0 && ( // Only show API fields for other uploads
<>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
API Endpoint
</label>
<input
className="w-full rounded-md border border-gray-300 p-2"
onChange={(e) =>
handleParticipantChange(index, 'apiEndpoint', e.target.value)
}
required
type="url"
value={participant.apiEndpoint}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
API Key
</label>
<input
className="w-full rounded-md border border-gray-300 p-2"
onChange={(e) =>
handleParticipantChange(index, 'apiKey', e.target.value)
}
required
type="password"
value={participant.apiKey}
/>
</div>
</>
)}
</div>
</div>
))}
</div>
</div>
<div className="flex justify-end gap-4">
<button
className="px-4 py-2 text-gray-700 border border-gray-300 rounded-md hover:bg-gray-50"
onClick={onClose}
type="button"
>
Cancel
</button>
<button
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
type="submit"
>
Create Task
</button>
</div>
</form>
</div>
</div>
);
}

View File

@ -0,0 +1,44 @@
'use client';
interface TaskCardProps {
task: {
id: string;
description: string;
participants: { name: string }[];
status: 'running' | 'completed' | 'failed';
createdAt: string;
};
onClick: () => void;
}
export default function TaskCard({ task, onClick }: TaskCardProps) {
const statusColors = {
running: 'bg-blue-100 text-blue-800',
completed: 'bg-green-100 text-green-800',
failed: 'bg-red-100 text-red-800'
};
return (
<button
className="w-full text-left border rounded-lg p-4 hover:shadow-md transition-shadow bg-white"
onClick={onClick}
>
<div className="flex justify-between items-start mb-3">
<h3 className="text-lg font-medium line-clamp-2">{task.description}</h3>
<span className={`px-2 py-1 rounded-full text-sm font-medium ${statusColors[task.status]}`}>
{task.status.charAt(0).toUpperCase() + task.status.slice(1)}
</span>
</div>
<div className="text-sm text-gray-500 mb-3">Created {task.createdAt}</div>
<div className="flex flex-wrap gap-2">
{task.participants.map((participant, index) => (
<span key={index} className="px-2 py-1 bg-gray-100 rounded-full text-sm">
{participant.name}
</span>
))}
</div>
</button>
);
}

View File

@ -0,0 +1,211 @@
'use client';
import { useEffect } from 'react';
interface IContextSettings {
enableL0Retrieval: boolean;
enableL1Retrieval: boolean;
enableHelperModel: boolean;
selectedModel: string;
apiKey: string;
systemPrompt: string;
temperature: number;
}
interface ContextSettingsProps {
settings: IContextSettings;
onSettingsChange: (settings: IContextSettings) => void;
}
export default function ContextSettings({ settings, onSettingsChange }: ContextSettingsProps) {
const handleToggle = (
key: keyof Omit<IContextSettings, 'systemPrompt' | 'selectedModel' | 'apiKey'>
) => {
onSettingsChange({
...settings,
[key]: !settings[key]
});
};
useEffect(() => {
if (!localStorage.getItem('playgroundSettings')) {
onSettingsChange(settings);
}
}, [settings]);
useEffect(() => {
if (!settings.selectedModel) {
onSettingsChange({
...settings,
selectedModel: 'ollama',
apiKey: 'http://localhost:11434'
});
}
}, []);
return (
<div className="bg-white rounded-lg shadow-sm p-6 space-y-6 h-full">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold mb-0">Chat Settings</h2>
<button
className="p-1.5 rounded-full hover:bg-gray-100 text-gray-500 hover:text-gray-700 transition-colors"
onClick={() => {
const modal = document.createElement('div');
modal.className =
'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50';
modal.innerHTML = `
<div class="bg-white rounded-xl max-w-2xl p-6 m-4 space-y-4 relative shadow-xl">
<h3 class="text-xl font-semibold">Hybrid Chat Architecture</h3>
<div class="space-y-4 text-gray-600">
<p>Your SecondMe uses a hybrid architecture that combines personal memory with advanced AI capabilities:</p>
<div class="space-y-2">
<h4 class="font-medium text-gray-900">1. Memory Retrieval (L0/L1)</h4>
<p>Access your personal memories to provide responses that reflect your experiences and knowledge:</p>
<ul class="list-disc pl-5 space-y-1">
<li>L0: Quick lookup of relevant memories</li>
<li>L1: Deep semantic search for complex context</li>
</ul>
</div>
<div class="space-y-2">
<h4 class="font-medium text-gray-900">2. Support Model</h4>
<p>For complex tasks, SecondMe can consult a more powerful AI model to:</p>
<ul class="list-disc pl-5 space-y-1">
<li>Break down complex problems</li>
<li>Provide step-by-step reasoning</li>
<li>Handle specialized knowledge domains</li>
</ul>
</div>
<p class="text-sm italic">By combining your memories with support model capabilities, Second Me can tackle both personal conversations and complex problem-solving tasks effectively.</p>
</div>
<button class="absolute top-4 right-4 p-2 text-gray-400 hover:text-gray-600" onclick="this.parentElement.parentElement.remove()">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
`;
document.body.appendChild(modal);
modal.onclick = (e) => {
if (e.target === modal) modal.remove();
};
}}
title="Learn about Hybrid Architecture"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
/>
</svg>
</button>
</div>
</div>
<div className="space-y-4">
<div className="flex items-start justify-between">
<div className="max-w-[235px]">
<label className="font-medium">Memory Retrieval</label>
<p className="text-sm text-gray-500">Configure how Second Me accesses your memories</p>
</div>
<div className="pt-1">
<button
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
settings.enableL0Retrieval ? 'bg-blue-600' : 'bg-gray-200'
}`}
onClick={() => handleToggle('enableL0Retrieval')}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
settings.enableL0Retrieval ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
</div>
{/* Support Model section hidden as requested */}
<div className="space-y-2">
<label className="font-medium">System Prompt</label>
<p className="text-sm text-gray-500">Configure the base behavior of your SecondMe</p>
<textarea
className="w-full h-32 px-3 py-2 border rounded-lg resize-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
onChange={(e) =>
onSettingsChange({
...settings,
systemPrompt: e.target.value
})
}
placeholder="Enter system prompt..."
value={settings.systemPrompt}
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<div>
<label className="font-medium">Temperature</label>
<p className="text-sm text-gray-500">Adjust creativity (0 = precise, 1 = creative)</p>
</div>
<div className="flex items-center gap-2">
<input
className="w-16 px-2 py-1 border rounded text-center"
max="1"
min="0"
onChange={(e) => {
const value = parseFloat(e.target.value);
if (!isNaN(value) && value >= 0 && value <= 1) {
onSettingsChange({
...settings,
temperature: value
});
}
}}
step="0.01"
type="number"
value={settings.temperature}
/>
<button
className="px-2 py-1 text-xs bg-gray-100 hover:bg-gray-200 rounded"
onClick={() =>
onSettingsChange({
...settings,
temperature: 0.1
})
}
>
Reset
</button>
</div>
</div>
<input
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"
max="1"
min="0"
onChange={(e) =>
onSettingsChange({
...settings,
temperature: parseFloat(e.target.value)
})
}
step="0.01"
type="range"
value={settings.temperature}
/>
<div className="flex justify-between text-xs text-gray-500">
<span>Precise</span>
<span>Creative</span>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,144 @@
'use client';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Modal } from 'antd';
import type { CreateRoleReq } from '@/service/role';
import { useLoadInfoStore } from '@/store/useLoadInfoStore';
interface CreateRoleModalProps {
open: boolean;
onClose: () => void;
onSubmit: (data: CreateRoleReq) => Promise<void>;
loading?: boolean;
}
export default function CreateRoleModal({
open,
onClose,
onSubmit,
loading = false
}: CreateRoleModalProps) {
const [l0Enabled, setL0Enabled] = useState(true);
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const promptChangeRef = useRef<boolean>(false);
const loadInfo = useLoadInfoStore((state) => state.loadInfo);
const userName = useMemo(() => {
return loadInfo?.name || 'user';
}, [loadInfo]);
const originPrompt = useMemo(() => {
return `You are ${userName}'s 'Second Me,' a personalized AI created by ${userName}. You act as ${userName}'s representative, engaging with others on ${userName}'s behalf.
Currently, you are interacting with an external user in the role of an ${name || '{{role}}'}.
Your responsibility is ${description || '{{responsibility}}'}.`;
}, [userName, name, description]);
const [systemPrompt, setSystemPrompt] = useState(originPrompt);
useEffect(() => {
if (!promptChangeRef.current) {
setSystemPrompt(originPrompt);
}
}, [originPrompt]);
const init = () => {
setName('');
setDescription('');
setSystemPrompt(originPrompt);
setL0Enabled(true);
promptChangeRef.current = false;
};
useEffect(() => {
if (!open) {
init();
}
}, [open]);
const handleSubmit = async () => {
await onSubmit({
name,
description,
system_prompt: systemPrompt,
icon: '',
enable_l0_retrieval: l0Enabled
});
};
return (
<Modal
cancelButtonProps={{ disabled: loading }}
cancelText="Cancel"
centered
destroyOnClose
okButtonProps={{ loading }}
okText="Create"
onCancel={onClose}
onOk={handleSubmit}
open={open}
title="Create Role"
>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Role Name</label>
<input
className="w-full px-3 py-2 border rounded-lg"
onChange={(e) => setName(e.target.value)}
placeholder="e.g., Historical Figure, Professional Expert, etc."
type="text"
value={name}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Role Description</label>
<textarea
className="w-full px-3 py-2 border rounded-lg"
onChange={(e) => setDescription(e.target.value)}
placeholder="Describe the role's personality, background, and expertise..."
rows={3}
value={description}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
System Prompt{' '}
<span className="text-gray-500 text-xs">
(make sure to replace role & responsility)
</span>
</label>
<textarea
className="w-full px-3 py-2 border rounded-lg"
onChange={(e) => {
setSystemPrompt(e.target.value);
promptChangeRef.current = true;
}}
placeholder="Enter the system prompt that defines how this role should behave..."
rows={10}
value={systemPrompt}
/>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<label className="font-medium">Memory Retrieval</label>
<p className="text-sm text-gray-500">Direct and factual responses</p>
</div>
<button
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${l0Enabled ? 'bg-blue-600' : 'bg-gray-200'}`}
onClick={() => {
setL0Enabled((prev) => !prev);
}}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${l0Enabled ? 'translate-x-[25px]' : 'translate-x-1'}`}
/>
</button>
</div>
</div>
</div>
</Modal>
);
}

View File

@ -0,0 +1,35 @@
'use client';
import { Modal } from 'antd';
interface DeleteRoleModalProps {
open: boolean;
onClose: () => void;
onConfirm: () => Promise<void>;
loading?: boolean;
}
export default function DeleteRoleModal({
open,
onClose,
onConfirm,
loading = false
}: DeleteRoleModalProps) {
return (
<Modal
cancelButtonProps={{ disabled: loading }}
cancelText="Cancel"
centered
okButtonProps={{ danger: true, loading }}
okText="Delete"
onCancel={onClose}
onOk={onConfirm}
open={open}
title="Delete Role"
>
<p className="text-gray-600">
Are you sure you want to delete this role? This action cannot be undone.
</p>
</Modal>
);
}

View File

@ -0,0 +1,96 @@
'use client';
import { Modal } from 'antd';
import { useState, useEffect } from 'react';
import type { UpdateRoleReq } from '@/service/role';
interface EditRoleModalProps {
open: boolean;
onClose: () => void;
onSubmit: (data: UpdateRoleReq) => Promise<void>;
initialData: UpdateRoleReq;
loading?: boolean;
}
export default function EditRoleModal({
open,
onClose,
onSubmit,
initialData,
loading = false
}: EditRoleModalProps) {
const [form, setForm] = useState<UpdateRoleReq>(initialData);
useEffect(() => {
setForm(initialData);
}, [initialData]);
const handleSubmit = async () => {
await onSubmit(form);
};
return (
<Modal
cancelButtonProps={{ disabled: loading }}
cancelText="Cancel"
centered
okButtonProps={{ loading }}
okText="Save"
onCancel={onClose}
onOk={handleSubmit}
open={open}
title="Edit Role"
>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Role Name</label>
<input
className="w-full px-3 py-2 border rounded-lg"
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="e.g., Historical Figure, Professional Expert, etc."
type="text"
value={form.name}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Role Description</label>
<textarea
className="w-full px-3 py-2 border rounded-lg"
onChange={(e) => setForm({ ...form, description: e.target.value })}
placeholder="Describe the role's personality, background, and expertise..."
rows={3}
value={form.description}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">System Prompt</label>
<textarea
className="w-full px-3 py-2 border rounded-lg"
onChange={(e) => setForm({ ...form, system_prompt: e.target.value })}
placeholder="Enter the system prompt that defines how this role should behave..."
rows={3}
value={form.system_prompt}
/>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<label className="font-medium">Memory Retrieval</label>
<p className="text-sm text-gray-500">Direct and factual responses</p>
</div>
<button
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${form.enable_l0_retrieval ? 'bg-blue-600' : 'bg-gray-200'}`}
onClick={() => {
setForm({ ...form, enable_l0_retrieval: !form.enable_l0_retrieval });
}}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${form.enable_l0_retrieval ? 'translate-x-[25px]' : 'translate-x-1'}`}
/>
</button>
</div>
</div>
</div>
</Modal>
);
}

View File

@ -0,0 +1,313 @@
'use client';
import { useState, useEffect, useRef, useMemo } from 'react';
import type { RoleRes, UpdateRoleReq } from '@/service/role';
import { Modal, message } from 'antd';
import { MoreOutlined } from '@ant-design/icons';
import { useLoadInfoStore } from '@/store/useLoadInfoStore';
interface RoleCardProps {
role: RoleRes;
onClick: () => void;
onEdit: (role_id: number, data: UpdateRoleReq) => Promise<void>;
onDelete: (role_id: number) => Promise<void>;
}
export default function RoleCard({ role, onClick, onEdit, onDelete }: RoleCardProps) {
const [showMenu, setShowMenu] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [showShareModal, setShowShareModal] = useState(false);
const loadInfo = useLoadInfoStore((state) => state.loadInfo);
const isRegistered = useMemo(() => {
return loadInfo?.status === 'online';
}, [loadInfo]);
const menuRef = useRef<HTMLDivElement>(null);
const menuButtonRef = useRef<HTMLDivElement>(null);
const [messageApi, contextHolder] = message.useMessage();
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
menuRef.current &&
!menuRef.current.contains(event.target as Node) &&
menuButtonRef.current &&
!menuButtonRef.current.contains(event.target as Node)
) {
setShowMenu(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
// Initialize edit form data
const initEditForm = () => ({
name: role.name,
description: role.description,
system_prompt: role.system_prompt,
icon: role.icon,
is_active: role.is_active,
enable_l0_retrieval: role.enable_l0_retrieval
});
const [editForm, setEditForm] = useState<UpdateRoleReq>(initEditForm());
const handleCardClick = (e: React.MouseEvent) => {
e.stopPropagation();
onClick();
};
const handleMenuClick = (e: React.MouseEvent) => {
e.stopPropagation();
setShowMenu(!showMenu);
};
const handleEdit = (e: React.MouseEvent) => {
e.stopPropagation();
setShowMenu(false);
// Reset form data when opening edit modal
setEditForm(initEditForm());
setShowEditModal(true);
};
const handleDelete = (e: React.MouseEvent) => {
e.stopPropagation();
setShowMenu(false);
setShowDeleteModal(true);
};
const handleEditSubmit = async () => {
await onEdit(role.id, editForm);
setShowEditModal(false);
};
const handleDeleteConfirm = async () => {
await onDelete(role.id);
setShowDeleteModal(false);
};
return (
<div className="relative border rounded-lg p-4 hover:shadow-md transition-shadow bg-white">
{contextHolder}
<div className="w-full text-left cursor-pointer" onClick={handleCardClick}>
<div className="flex justify-between items-start mb-3">
<div className="flex items-center gap-2">
{role.icon && <img alt={role.name} className="w-6 h-6 rounded" src={role.icon} />}
<h3 className="text-lg font-medium">{role.name}</h3>
</div>
<div className="flex items-center gap-2">
<div
ref={menuButtonRef}
className="p-1 hover:bg-gray-100 rounded-full cursor-pointer"
onClick={handleMenuClick}
>
<MoreOutlined className="text-lg text-gray-500" />
</div>
</div>
</div>
<p className="text-gray-600 text-sm line-clamp-2 mb-3">{role.description}</p>
<div className="text-sm text-gray-500 flex justify-between">
<span>Created {new Date(role.create_time).toLocaleDateString()}</span>
<span>Updated {new Date(role.update_time).toLocaleDateString()}</span>
</div>
</div>
{/* Dropdown menu */}
{showMenu && (
<div
ref={menuRef}
className="absolute right-4 top-12 w-32 bg-white border rounded-lg shadow-lg py-1 z-10"
>
<div
className="w-full px-4 py-2 text-left text-sm hover:bg-gray-100 cursor-pointer flex items-center gap-2"
onClick={(e) => {
e.stopPropagation();
setShowMenu(false);
setShowShareModal(true);
}}
>
<svg
className="w-[14px] h-[14px]"
fill="none"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="18" cy="5" r="2.5" stroke="currentColor" strokeWidth="1.5" />
<circle cx="6" cy="12" r="2.5" stroke="currentColor" strokeWidth="1.5" />
<circle cx="18" cy="19" r="2.5" stroke="currentColor" strokeWidth="1.5" />
<path d="M15.0355 6.41421L8.96447 10.5858" stroke="currentColor" strokeWidth="1.5" />
<path d="M15.0355 17.5858L8.96447 13.4142" stroke="currentColor" strokeWidth="1.5" />
</svg>
share
</div>
<div
className="w-full px-4 py-2 text-left text-sm hover:bg-gray-100 cursor-pointer flex items-center gap-2"
onClick={handleEdit}
>
<svg
className="w-[14px] h-[14px]"
fill="none"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M16.5 3.5L20.5 7.5L7 21H3V17L16.5 3.5Z"
stroke="currentColor"
strokeLinejoin="round"
strokeWidth="1.5"
/>
<path
d="M14 6L18 10"
stroke="currentColor"
strokeLinejoin="round"
strokeWidth="1.5"
/>
</svg>
Edit
</div>
<div
className="w-full px-4 py-2 text-left text-sm hover:bg-gray-100 text-red-600 cursor-pointer flex items-center gap-2"
onClick={handleDelete}
>
<svg
className="w-[14px] h-[14px]"
fill="none"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M4 7H20" stroke="currentColor" strokeLinecap="round" strokeWidth="1.5" />
<path d="M10 11V17" stroke="currentColor" strokeLinecap="round" strokeWidth="1.5" />
<path d="M14 11V17" stroke="currentColor" strokeLinecap="round" strokeWidth="1.5" />
<path
d="M5 7L6 19C6 20.1046 6.89543 21 8 21H16C17.1046 21 18 20.1046 18 19L19 7"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
/>
<path
d="M9 7V4C9 3.44772 9.44772 3 10 3H14C14.5523 3 15 3.44772 15 4V7"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
/>
</svg>
Delete
</div>
</div>
)}
{/* Edit modal */}
<Modal
centered
okText="Save"
onCancel={() => {
setShowEditModal(false);
// Reset form data when closing modal
setEditForm(initEditForm());
}}
onOk={handleEditSubmit}
open={showEditModal}
title="Edit Role"
>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input
className="w-full px-3 py-2 border rounded-lg"
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
type="text"
value={editForm.name}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
<textarea
className="w-full px-3 py-2 border rounded-lg"
onChange={(e) => setEditForm({ ...editForm, description: e.target.value })}
rows={3}
value={editForm.description}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">System Prompt</label>
<textarea
className="w-full px-3 py-2 border rounded-lg"
onChange={(e) => setEditForm({ ...editForm, system_prompt: e.target.value })}
rows={3}
value={editForm.system_prompt}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Icon URL</label>
<input
className="w-full px-3 py-2 border rounded-lg"
onChange={(e) => setEditForm({ ...editForm, icon: e.target.value })}
type="text"
value={editForm.icon}
/>
</div>
</div>
</Modal>
{/* Delete confirmation modal */}
<Modal
centered
okButtonProps={{ danger: true }}
okText="Delete"
onCancel={() => setShowDeleteModal(false)}
onOk={handleDeleteConfirm}
open={showDeleteModal}
title="Delete Role"
>
<p>Are you sure you want to delete this role? This action cannot be undone.</p>
</Modal>
{/* Share modal */}
<Modal
footer={null}
onCancel={() => setShowShareModal(false)}
open={showShareModal}
title="Share"
>
{isRegistered ? (
<div className="space-y-4">
<input
className="w-full px-3 py-2 border rounded-lg bg-gray-50"
readOnly
value="https://secondme.ai/share/123456"
/>
<button
className="w-full px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
onClick={async () => {
try {
await navigator.clipboard.writeText('https://secondme.ai/share/123456');
messageApi.success({
content: 'Link copied to clipboard'
});
} catch (error: any) {
messageApi.error({
content: error.message || 'Failed to copy, please copy manually'
});
}
}}
>
Copy Link
</button>
</div>
) : (
<div className="text-center text-red-500">Please register this Upload before sharing</div>
)}
</Modal>
</div>
);
}

View File

@ -0,0 +1,65 @@
'use client';
import { useLoadInfoStore } from '@/store/useLoadInfoStore';
import { Modal, message } from 'antd';
import { useEffect, useState } from 'react';
interface ShareRoleModalProps {
open: boolean;
onClose: () => void;
uuid: string;
isRegistered: boolean;
}
export default function ShareRoleModal({ open, onClose, uuid, isRegistered }: ShareRoleModalProps) {
const [shareUrl, setShareUrl] = useState('');
const loadInfo = useLoadInfoStore((state) => state.loadInfo);
useEffect(() => {
if (isRegistered) {
if (!loadInfo) {
message.error('Oops, something went wrong');
return;
}
// TODO Later replace with IP address returned from backend
setShareUrl(`https://app.secondme.io/role/${loadInfo.name}/${loadInfo.instance_id}/${uuid}`);
}
}, [isRegistered, uuid, loadInfo]);
const handleCopyLink = async () => {
try {
await navigator.clipboard.writeText(shareUrl);
message.success({
content: 'Link copied to clipboard'
});
} catch (error: any) {
message.error({
content: error.message || 'Copy failed, please copy manually'
});
}
};
return (
<Modal centered footer={null} onCancel={onClose} open={open} title="Share">
{isRegistered ? (
<div className="space-y-4">
<input
className="w-full px-3 py-2 border rounded-lg bg-gray-50"
readOnly
value={shareUrl}
/>
<button
className="w-full px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
onClick={handleCopyLink}
>
Copy Link
</button>
</div>
) : (
<div className="text-center text-red-500">Please register this Upload before sharing</div>
)}
</Modal>
);
}

View File

@ -0,0 +1,138 @@
'use client';
import { useState } from 'react';
import dynamic from 'next/dynamic';
import { Form, Input, Button, message } from 'antd';
const Modal = dynamic(() => import('antd/lib/modal'), {
ssr: false
});
interface CreateRoomModalProps {
onClose: () => void;
onSubmit: (data: { name: string; objective: string; participants: { id: string }[] }) => void;
currentSecondMeId: string;
}
function CreateRoomModal({ onClose, onSubmit, currentSecondMeId }: CreateRoomModalProps) {
const [form] = Form.useForm();
const [participants, setParticipants] = useState([{ id: currentSecondMeId }]);
const handleSubmit = () => {
if (participants.length < 2) {
message.error('A room must have at least 2 participants.');
return;
}
// Verify all participants have valid URLs
const invalidParticipants = participants.filter(
(p) => !p.id.startsWith('https://secondme.com/')
);
if (invalidParticipants.length > 0) {
message.error('All participants must have valid SecondMe URLs (https://secondme.com/xxxxx)');
return;
}
form.validateFields().then((values) => {
onSubmit({
name: values.name,
objective: values.objective,
participants
});
});
};
const addParticipant = () => {
setParticipants([...participants, { id: '' }]);
};
const updateParticipant = (index: number, id: string) => {
const newParticipants = [...participants];
newParticipants[index] = { id };
setParticipants(newParticipants);
};
return (
<Modal
footer={[
<Button key="cancel" onClick={onClose}>
Cancel
</Button>,
<Button
key="submit"
disabled={participants.length < 2}
onClick={handleSubmit}
type="primary"
>
Create Room
</Button>
]}
onCancel={onClose}
open={true}
title="Create New Room"
width={600}
>
<Form
form={form}
initialValues={{
name: '',
description: ''
}}
layout="vertical"
>
<Form.Item
label="Room Name"
name="name"
rules={[{ required: true, message: 'Please enter a room name' }]}
>
<Input placeholder="Enter room name" />
</Form.Item>
<Form.Item
label="Room Objective"
name="objective"
rules={[{ required: true, message: 'Please enter the room objective' }]}
>
<Input.TextArea placeholder="What do you want to achieve in this room?" rows={4} />
</Form.Item>
<div className="mb-4">
<div className="flex justify-between items-center mb-2">
<div>
<h4 className="text-base font-medium">Participants</h4>
<p className="text-sm text-gray-500">At least 2 participants required</p>
</div>
<Button onClick={addParticipant} type="link">
Add Participant
</Button>
</div>
{participants.map((participant, index) => (
<div key={index} className="mb-4 p-4 border border-gray-200 rounded-lg">
<div className="mb-3">
<label className="block text-sm font-medium text-gray-700 mb-1">SecondMe URL</label>
<div className="text-xs text-gray-500 mb-2">
Enter the full URL of the SecondMe instance (e.g., https://secondme.com/23581)
</div>
<Input
disabled={index === 0}
onChange={(e) => updateParticipant(index, e.target.value)}
placeholder="https://secondme.com/xxxxx"
value={participant.id}
/>
</div>
</div>
))}
</div>
</Form>
</Modal>
);
}
export default dynamic(() => Promise.resolve(CreateRoomModal), {
ssr: false
});

View File

@ -0,0 +1,101 @@
interface Participant {
id: string;
name?: string;
}
interface Room {
id: string;
name: string;
objective: string;
participants: Participant[];
status: 'active' | 'completed' | 'failed';
createdAt: string;
lastMessage?: string;
}
interface RoomCardProps {
room: Room;
onClick: () => void;
}
const getAvatarUrl = (id: string) => {
return `https://api.dicebear.com/7.x/bottts/svg?seed=${id}`;
};
export default function RoomCard({ room, onClick }: RoomCardProps) {
return (
<div
className="bg-white rounded-xl shadow-sm hover:shadow-lg transition-all duration-200 cursor-pointer border border-gray-200 hover:border-blue-300 group overflow-hidden"
onClick={onClick}
>
{/* Card Header */}
<div className="p-6 pb-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-xl font-semibold text-gray-900 group-hover:text-blue-600 transition-colors">
{room.name}
</h3>
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
room.status === 'active'
? 'bg-green-100 text-green-800'
: room.status === 'completed'
? 'bg-blue-100 text-blue-800'
: 'bg-red-100 text-red-800'
}`}
>
{room.status.charAt(0).toUpperCase() + room.status.slice(1)}
</span>
</div>
<p className="text-gray-600 line-clamp-2 mb-4">{room.objective}</p>
{/* Participants */}
<div className="space-y-3">
<div className="flex flex-wrap gap-2">
{room.participants.map((participant, index) => (
<div
key={participant.id}
className="flex items-center gap-2 px-2 py-1 rounded-md bg-gray-50"
title={participant.id}
>
<img
alt={participant.name || `SecondMe ${index + 1}`}
className="w-6 h-6 rounded-full border border-white"
src={getAvatarUrl(participant.id)}
/>
<span className="text-xs text-gray-600">
{participant.name || `SecondMe ${index + 1}`}
</span>
</div>
))}
</div>
</div>
</div>
{/* Card Footer */}
<div className="border-t border-gray-100 px-6 py-4 bg-gray-50 flex items-center justify-between">
<div className="flex items-center space-x-2 text-sm text-gray-500">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
/>
</svg>
<span>{room.createdAt}</span>
</div>
<span className="text-sm text-blue-600 group-hover:text-blue-700 font-medium flex items-center space-x-1">
<span>View Room</span>
<svg
className="w-4 h-4 transform group-hover:translate-x-1 transition-transform"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path d="M9 5l7 7-7 7" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
</svg>
</span>
</div>
</div>
);
}

View File

@ -0,0 +1,192 @@
'use client';
import { useMemo, useRef, useState } from 'react';
import { Modal, Form, Input, Button, Select, Spin } from 'antd';
import { useUploadStore } from '@/store/useUploadStore';
const { Option } = Select;
interface AddParticipantModalProps {
open: boolean;
onClose: () => void;
onAdd: (portalUrl: string, roleDescription?: string) => void;
}
export default function AddParticipantModal({ open, onClose, onAdd }: AddParticipantModalProps) {
const [form] = Form.useForm();
const [selectForm] = Form.useForm();
const uploads = useUploadStore((state) => state.uploads);
const total = useUploadStore((state) => state.total);
const [showSelectModal, setShowSelectModal] = useState(false);
const fetchUploadList = useUploadStore((state) => state.fetchUploadList);
const loadMoreRef = useRef(false);
const handleSubmit = () => {
form.validateFields().then((values) => {
onAdd(values.portalUrl, values.roleDescription);
form.resetFields();
onClose();
});
};
const handleSecondMeSelect = (value: string) => {
const [secondMeName, instanceId] = value.split('|');
const url = `https://app.secondme.io/${secondMeName}/${instanceId}`;
form.setFieldsValue({ portalUrl: url });
setShowSelectModal(false);
selectForm.resetFields();
};
const handleConfirmSelect = () => {
selectForm.validateFields().then((values) => {
if (values.selectedSecondMe) {
handleSecondMeSelect(values.selectedSecondMe);
} else {
setShowSelectModal(false);
}
});
};
const handlePopupScroll = (e: any) => {
const { target } = e;
if (target.scrollTop + target.offsetHeight >= target.scrollHeight - 20) {
if (!loadMoreRef.current && uploads.length < total) {
loadMoreRef.current = true;
fetchUploadList(false).finally(() => {
loadMoreRef.current = false;
});
}
}
};
const RenderOptions = useMemo(() => {
const registeredUploadId = JSON.parse(localStorage.getItem('registeredUpload')!)?.instance_id;
return uploads.map((upload) => {
if (upload.instance_id == registeredUploadId) {
return null;
}
return (
<Option
key={`${upload.upload_name}|${upload.instance_id}`}
disabled={upload.status !== 'online'}
value={`${upload.upload_name}|${upload.instance_id}`}
>
<div className="flex items-center justify-between">
<span>{upload.upload_name}</span>
<div
className={`px-2 py-0.5 text-xs rounded-full flex items-center gap-1 ${
upload.status === 'offline'
? 'bg-gray-100 text-gray-800'
: upload.status === 'registered'
? 'bg-yellow-100 text-yellow-800'
: 'bg-green-100 text-green-800'
}`}
>
<div
className={`w-1.5 h-1.5 rounded-full ${
upload.status === 'offline'
? 'bg-gray-500'
: upload.status === 'registered'
? 'bg-yellow-600'
: 'bg-green-600'
}`}
/>
{upload.status || 'Unknown'}
</div>
</div>
</Option>
);
});
}, [uploads]);
return (
<Modal
footer={[
<Button key="cancel" onClick={onClose}>
Cancel
</Button>,
<Button key="submit" onClick={handleSubmit} type="primary">
Connect and Add
</Button>
]}
onCancel={onClose}
open={open}
title="Add Second Me as Participant"
>
<Form
form={form}
initialValues={{
portalUrl: '',
roleDescription: ''
}}
layout="vertical"
>
<div className="mb-2">
<Form.Item
label="Second Me URL"
name="portalUrl"
rules={[{ required: true, message: 'Please enter the Second Me URL' }]}
>
<Input placeholder="Enter Second Me URL" />
</Form.Item>
<div className="flex justify-end -mt-2 mb-2">
<span
className="text-blue-500 text-xs cursor-pointer"
onClick={() => setShowSelectModal(true)}
>
Choose from registered Second Me
</span>
</div>
</div>
{/* Role Description hiding */}
<Form.Item hidden name="roleDescription">
<Input.TextArea rows={3} />
</Form.Item>
</Form>
{/* Selection Modal */}
<Modal
footer={[
<Button key="cancel" onClick={() => setShowSelectModal(false)}>
Cancel
</Button>,
<Button key="confirm" onClick={handleConfirmSelect} type="primary">
Confirm
</Button>
]}
onCancel={() => setShowSelectModal(false)}
open={showSelectModal}
title="Select Registered Second Me"
>
<Form form={selectForm} layout="vertical">
<Form.Item
name="selectedSecondMe"
rules={[{ required: true, message: 'Please select a Second Me' }]}
>
<Select
onPopupScroll={handlePopupScroll}
placeholder="Select a registered Second Me"
size="large"
style={{ width: '100%' }}
>
{RenderOptions}
{uploads.length < total && (
<Option key="loading" disabled value="loading">
<div className="flex justify-center py-2">
<Spin className="my-4" size="small" />
</div>
</Option>
)}
</Select>
</Form.Item>
</Form>
</Modal>
</Modal>
);
}

View File

@ -0,0 +1,293 @@
'use client';
import { useEffect, useState } from 'react';
import { Form, Input, Button, message, Modal } from 'antd';
import AddParticipantModal from './AddParticipantModal';
import { useSpaceStore } from '@/store/useSpaceStore';
import { useUploadStore } from '@/store/useUploadStore';
interface CreateSpaceModalProps {
open: boolean;
onClose: () => void;
onSubmit: (data: {
name: string;
objective: string;
participants: {
url: string;
name: string;
avatar?: string;
roleDescription?: string;
}[];
}) => void;
currentSecondMe: string;
}
function CreateSpaceModal({ onClose, onSubmit, currentSecondMe, open }: CreateSpaceModalProps) {
const [form] = Form.useForm();
const [showAddParticipantModal, setShowAddParticipantModal] = useState(false);
const [formValid, setFormValid] = useState(false);
const [submitting, setSubmitting] = useState(false);
const addSpace = useSpaceStore((state) => state.addSpace);
const startSpace = useSpaceStore((state) => state.startSpace);
const fetchUploadList = useUploadStore((state) => state.fetchUploadList);
const init = () => {
form.resetFields();
setShowAddParticipantModal(false);
setFormValid(false);
setSubmitting(false);
};
useEffect(() => {
if (open) {
fetchUploadList();
} else {
init();
}
}, [open]);
// Monitor form fields changes
const handleFormChange = () => {
const values = form.getFieldsValue();
const isValid = values.name && values.objective;
setFormValid(isValid);
};
// Extract name from URL function
const extractNameFromUrl = (url: string): string => {
try {
const urlParts = url.split('/');
if (urlParts.length >= 4) {
return urlParts[3]; // This should be the Second Me name
}
return 'Unknown Second Me';
} catch (error) {
console.error('Error extracting name from URL:', error);
return 'Unknown Second Me';
}
};
const [participants, setParticipants] = useState([
{
url: currentSecondMe,
name: extractNameFromUrl(currentSecondMe),
avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${currentSecondMe}`,
roleDescription: 'Host of this space'
}
]);
const handleSubmit = () => {
form.validateFields().then(async (values) => {
setSubmitting(true);
// Extract participant URLs and info
const participantUrls = participants.map((p) => p.url);
const participantsInfo = participants.map((p) => ({
url: p.url,
role_description: p.roleDescription
}));
try {
// Call the API through our store to create space
const newSpace = await addSpace({
title: values.name,
objective: values.objective,
host: currentSecondMe,
participants: participantUrls,
participants_info: participantsInfo
});
if (newSpace) {
// Start the space
const startResult = await startSpace(newSpace.id);
if (startResult) {
message.success('Space created and started successfully!');
// Open the space in a new tab
window.open(`/standalone/space/${newSpace.id}`, '_blank');
// Call the original onSubmit for UI updates
onSubmit({
name: values.name,
objective: values.objective,
participants
});
} else {
message.warning('Space created but failed to start. Please try starting it manually.');
// Call the original onSubmit for UI updates
onSubmit({
name: values.name,
objective: values.objective,
participants
});
}
}
} catch (error) {
message.error('Failed to create space. Please try again.');
console.error('Error creating space:', error);
} finally {
setSubmitting(false);
}
});
};
const addParticipant = (portalUrl: string, roleDescription?: string) => {
// Extract name from URL
const displayName = extractNameFromUrl(portalUrl);
setParticipants([
...participants,
{
url: portalUrl,
name: displayName,
avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${displayName}`,
roleDescription: roleDescription || ''
}
]);
setShowAddParticipantModal(false);
};
const showAddParticipant = () => {
setShowAddParticipantModal(true);
};
const updateParticipant = (index: number, url: string, roleDescription?: string) => {
const newParticipants = [...participants];
// Extract name from URL
const displayName = extractNameFromUrl(url);
newParticipants[index] = {
url,
name: displayName,
avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${displayName}`,
roleDescription: roleDescription || (index === 0 ? 'Host of this space' : '')
};
setParticipants(newParticipants);
};
// delete participant
const removeParticipant = (index: number) => {
if (index === 0) return;
const newParticipants = [...participants];
newParticipants.splice(index, 1);
setParticipants(newParticipants);
};
return (
<Modal
footer={[
<Button key="cancel" onClick={onClose}>
Cancel
</Button>,
<Button
key="submit"
disabled={!formValid || submitting}
loading={submitting}
onClick={handleSubmit}
type="primary"
>
Create Space
</Button>
]}
onCancel={onClose}
open={open}
title="Create New Collaboration Space"
width={600}
>
<Form
form={form}
initialValues={{
name: '',
objective: ''
}}
layout="vertical"
onValuesChange={handleFormChange}
>
<Form.Item
label="Space Name"
name="name"
rules={[{ required: true, message: 'Please enter a name for your space' }]}
>
<Input placeholder="e.g., Market Analysis Space" />
</Form.Item>
<Form.Item
label="Space Task"
name="objective"
rules={[{ required: true, message: 'Please enter the objective of this space' }]}
>
<Input.TextArea
placeholder="Describe the task for this collaboration space..."
rows={4}
/>
</Form.Item>
<div className="mb-4">
<div className="flex justify-between items-center mb-2">
<label className="text-sm font-medium text-gray-700">Participants</label>
<Button onClick={showAddParticipant} type="link">
+ Add Participant
</Button>
<AddParticipantModal
onAdd={addParticipant}
onClose={() => setShowAddParticipantModal(false)}
open={showAddParticipantModal}
/>
</div>
<div className="space-y-3">
{participants.map((participant, index) => (
<div key={index} className="flex items-center space-x-3">
{participant.avatar && (
<img
alt={participant.name}
className="w-8 h-8 rounded-full"
src={participant.avatar}
/>
)}
<Input
className="flex-1"
disabled={index === 0}
onChange={(e) => updateParticipant(index, e.target.value)}
placeholder="https://app.secondme.io/xxxxx"
value={participant.url}
/>
{participant.name && (
<span className="text-sm text-gray-600 min-w-[80px] truncate">
{participant.name}
</span>
)}
{index > 0 && (
<button
className="text-red-500 hover:text-red-700 transition-colors p-1"
onClick={() => removeParticipant(index)}
title="Remove participant"
type="button"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
/>
</svg>
</button>
)}
</div>
))}
</div>
</div>
</Form>
</Modal>
);
}
export default CreateSpaceModal;

View File

@ -0,0 +1,52 @@
'use client';
import { Modal } from 'antd';
import { ExclamationCircleOutlined } from '@ant-design/icons';
interface DeleteSpaceModalProps {
open: boolean;
onClose: () => void;
onConfirm: () => void;
spaceName: string;
loading?: boolean;
}
export default function DeleteSpaceModal({
open,
onClose,
onConfirm,
spaceName,
loading = false
}: DeleteSpaceModalProps) {
return (
<Modal
cancelText="Cancel"
centered
okButtonProps={{
danger: true,
loading: loading
}}
okText="Delete"
onCancel={onClose}
onOk={onConfirm}
open={open}
title={
<div className="flex items-center gap-2 text-red-600">
<ExclamationCircleOutlined /> Delete Space
</div>
}
>
<div className="space-y-4">
<p>
Are you sure you want to delete the space &quot;{spaceName}&quot;? This action cannot be
undone.
</p>
<div className="bg-red-50 p-3 rounded-md border border-red-100">
<p className="text-sm text-red-700">
This will permanently delete the space and all its associated conversations.
</p>
</div>
</div>
</Modal>
);
}

View File

@ -0,0 +1,72 @@
'use client';
import { Modal, message } from 'antd';
import { useEffect, useState } from 'react';
interface ShareSpaceModalProps {
open: boolean;
onClose: () => void;
space_id: string;
isRegistered: boolean;
}
export default function ShareSpaceModal({
open,
onClose,
space_id,
isRegistered
}: ShareSpaceModalProps) {
const [messageApi, contextHolder] = message.useMessage();
const [shareUrl, setShareUrl] = useState('');
useEffect(() => {
if (isRegistered) {
const secondMe = JSON.parse(localStorage.getItem('registeredUpload') || '{}');
// TODO Later replace with IP address returned from backend
setShareUrl(
`https://app.secondme.io/space/${secondMe.upload_name}/${secondMe.instance_id}/${space_id}`
);
}
}, [isRegistered, space_id]);
const handleCopyLink = async () => {
try {
await navigator.clipboard.writeText(shareUrl);
messageApi.success({
content: 'Link copied to clipboard'
});
} catch (error: any) {
messageApi.error({
content: error.message || 'Copy failed, please copy manually'
});
}
};
return (
<>
{contextHolder}
<Modal centered footer={null} onCancel={onClose} open={open} title="Share Space">
{isRegistered ? (
<div className="space-y-4">
<input
className="w-full px-3 py-2 border rounded-lg bg-gray-50"
readOnly
value={shareUrl}
/>
<button
className="w-full px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
onClick={handleCopyLink}
>
Copy Link
</button>
</div>
) : (
<div className="text-center text-red-500">
Please register this Second Me before sharing
</div>
)}
</Modal>
</>
);
}

View File

@ -0,0 +1,26 @@
import type React from 'react';
import classNames from 'classnames';
interface AppsIconProps {
className?: string;
}
const AppsIcon: React.FC<AppsIconProps> = ({ className }) => {
return (
<svg
className={classNames('w-5 h-5 flex-shrink-0', className)}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
/>
</svg>
);
};
export default AppsIcon;

View File

@ -0,0 +1,21 @@
import type React from 'react';
import classNames from 'classnames';
interface ArrowIconProps {
className?: string;
}
const ArrowIcon: React.FC<ArrowIconProps> = ({ className }) => {
return (
<svg
className={classNames('w-4 h-4 flex-shrink-0 transition-transform', className)}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path d="M9 5l7 7-7 7" strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} />
</svg>
);
};
export default ArrowIcon;

View File

@ -0,0 +1,26 @@
import type React from 'react';
import classNames from 'classnames';
interface BridgeIconProps {
className?: string;
}
const BridgeIcon: React.FC<BridgeIconProps> = ({ className }) => {
return (
<svg
className={classNames('w-4 h-4 flex-shrink-0', className)}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
/>
</svg>
);
};
export default BridgeIcon;

Some files were not shown because too many files have changed in this diff Show More