Initial commit
|
@ -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
|
|
@ -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
|
|
@ -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/).
|
|
@ -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
|
|
@ -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.
|
|
@ -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
|
|
@ -0,0 +1,179 @@
|
|||

|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://www.secondme.io/)
|
||||
[](https://arxiv.org/abs/2503.08102)
|
||||
[](https://discord.gg/GpWHQNUwrg)
|
||||
[](https://x.com/SecondMe_AI1)
|
||||
[](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.
|
||||
|
||||
We’re 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.
|
||||
|
||||
It’s **locally trained and hosted**—your data, your control—yet **globally connected**, scaling your intelligence across an AI network. Beyond that, it’s your AI identity interface—a bold standard linking your AI to the world, sparks collaboration among AI selves, and builds tomorrow’s 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 Tomorrow’s 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
|
|
@ -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()
|
|
@ -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);
|
|
@ -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
|
After Width: | Height: | Size: 978 KiB |
After Width: | Height: | Size: 975 KiB |
|
@ -0,0 +1,2 @@
|
|||
/*
|
||||
!/src
|
|
@ -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'
|
||||
}
|
||||
};
|
|
@ -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
|
|
@ -0,0 +1,2 @@
|
|||
/*
|
||||
!/src
|
|
@ -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
|
||||
};
|
|
@ -0,0 +1,2 @@
|
|||
/*
|
||||
!/src
|
|
@ -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$/']
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
|
@ -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.
|
|
@ -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;
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
After Width: | Height: | Size: 15 KiB |
After Width: | Height: | Size: 1.2 MiB |
After Width: | Height: | Size: 1.4 MiB |
After Width: | Height: | Size: 1.1 MiB |
After Width: | Height: | Size: 32 KiB |
After Width: | Height: | Size: 15 KiB |
After Width: | Height: | Size: 1.3 MiB |
After Width: | Height: | Size: 1.2 MiB |
After Width: | Height: | Size: 1.4 MiB |
After Width: | Height: | Size: 1.3 MiB |
|
@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
|
@ -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']
|
||||
}
|
||||
};
|
|
@ -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']
|
||||
};
|
|
@ -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'
|
||||
}
|
||||
};
|
|
@ -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']
|
||||
}
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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 : ''}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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;
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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);
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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;
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
}, [content]);
|
||||
|
||||
return (
|
||||
<div className={styles.markdown}>
|
||||
<div ref={markdownRef} className={classNames(className, 'relative')} style={style} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(SimplyMD);
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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;
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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;
|
|
@ -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 "{spaceName}"? 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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|