docs: add VITE_API_BASE_URL documentation for remote/VPS deployment

Signed-off-by: god032396-del <god032396@gmail.com>
This commit is contained in:
Developer 2026-05-28 15:15:25 +00:00 committed by god032396-del
commit 7914b452ef
96 changed files with 50082 additions and 0 deletions

23
.dockerignore Normal file
View File

@ -0,0 +1,23 @@
.git
.github
.gitignore
.cursor
.DS_Store
.env
node_modules
frontend/node_modules
backend/.venv
.venv
.python-version
__pycache__
*.pyc
.pytest_cache
.mypy_cache
.ruff_cache
frontend/dist
frontend/.vite
backend/uploads

20
.env.example Normal file
View File

@ -0,0 +1,20 @@
# LLM API配置支持 OpenAI SDK 格式的任意 LLM API
# 推荐使用阿里百炼平台qwen-plus模型https://bailian.console.aliyun.com/
# 注意消耗较大可先进行小于40轮的模拟尝试
LLM_API_KEY=your_api_key_here
LLM_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
LLM_MODEL_NAME=qwen-plus
# ===== ZEP记忆图谱配置 =====
# 每月免费额度即可支撑简单使用https://app.getzep.com/
ZEP_API_KEY=your_zep_api_key_here
# ===== 加速 LLM 配置(可选)=====
# 注意如果不使用加速配置env文件中就不要出现下面的配置项
LLM_BOOST_API_KEY=your_api_key_here
LLM_BOOST_BASE_URL=your_base_url_here
LLM_BOOST_MODEL_NAME=your_model_name_here
# ===== 前端 API 地址配置(远程/VPS 部署时需要) =====
# 默认前端请求 http://localhost:5001部署到远程服务器时需设置后端 API 的实际地址
# VITE_API_BASE_URL=http://your-server-ip:5001

49
.github/workflows/docker-image.yml vendored Normal file
View File

@ -0,0 +1,49 @@
name: Build and push Docker image
on:
push:
tags: ["*"]
workflow_dispatch:
permissions:
contents: read
packages: write
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository_owner }}/mirofish
tags: |
type=ref,event=tag
type=sha
type=raw,value=latest
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

60
.gitignore vendored Normal file
View File

@ -0,0 +1,60 @@
# OS
.DS_Store
Thumbs.db
# 环境变量(保护敏感信息)
.env
.env.local
.env.*.local
.env.development
.env.test
.env.production
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
.venv/
venv/
ENV/
.eggs/
*.egg-info/
dist/
build/
# Node.js
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# IDE
.vscode/
.idea/
*.swp
*.swo
# 测试
.pytest_cache/
.coverage
htmlcov/
# Cursor
.cursor/
.claude/
# 文档与测试程序
mydoc/
mytest/
# 日志文件
backend/logs/
*.log
# 上传文件
backend/uploads/
# Docker 数据
data/

29
Dockerfile Normal file
View File

@ -0,0 +1,29 @@
FROM python:3.11
# 安装 Node.js (满足 >=18及必要工具
RUN apt-get update \
&& apt-get install -y --no-install-recommends nodejs npm \
&& rm -rf /var/lib/apt/lists/*
# 从 uv 官方镜像复制 uv
COPY --from=ghcr.io/astral-sh/uv:0.9.26 /uv /uvx /bin/
WORKDIR /app
# 先复制依赖描述文件以利用缓存
COPY package.json package-lock.json ./
COPY frontend/package.json frontend/package-lock.json ./frontend/
COPY backend/pyproject.toml backend/uv.lock ./backend/
# 安装依赖Node + Python
RUN npm ci \
&& npm ci --prefix frontend \
&& cd backend && uv sync --frozen
# 复制项目源码
COPY . .
EXPOSE 3000 5001
# 同时启动前后端(开发模式)
CMD ["npm", "run", "dev"]

661
LICENSE Normal file
View File

@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.

203
README-ZH.md Normal file
View File

@ -0,0 +1,203 @@
<div align="center">
<img src="./static/image/MiroFish_logo_compressed.jpeg" alt="MiroFish Logo" width="75%"/>
<a href="https://trendshift.io/repositories/16144" target="_blank"><img src="https://trendshift.io/api/badge/repositories/16144" alt="666ghj%2FMiroFish | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
简洁通用的群体智能引擎,预测万物
</br>
<em>A Simple and Universal Swarm Intelligence Engine, Predicting Anything</em>
<a href="https://www.shanda.com/" target="_blank"><img src="./static/image/shanda_logo.png" alt="666ghj%2FMiroFish | Shanda" height="40"/></a>
[![GitHub Stars](https://img.shields.io/github/stars/666ghj/MiroFish?style=flat-square&color=DAA520)](https://github.com/666ghj/MiroFish/stargazers)
[![GitHub Watchers](https://img.shields.io/github/watchers/666ghj/MiroFish?style=flat-square)](https://github.com/666ghj/MiroFish/watchers)
[![GitHub Forks](https://img.shields.io/github/forks/666ghj/MiroFish?style=flat-square)](https://github.com/666ghj/MiroFish/network)
[![Docker](https://img.shields.io/badge/Docker-Build-2496ED?style=flat-square&logo=docker&logoColor=white)](https://hub.docker.com/)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/666ghj/MiroFish)
[![Discord](https://img.shields.io/badge/Discord-Join-5865F2?style=flat-square&logo=discord&logoColor=white)](http://discord.gg/ePf5aPaHnA)
[![X](https://img.shields.io/badge/X-Follow-000000?style=flat-square&logo=x&logoColor=white)](https://x.com/mirofish_ai)
[![Instagram](https://img.shields.io/badge/Instagram-Follow-E4405F?style=flat-square&logo=instagram&logoColor=white)](https://www.instagram.com/mirofish_ai/)
[English](./README.md) | [中文文档](./README-ZH.md)
</div>
## ⚡ 项目概述
**MiroFish** 是一款基于多智能体技术的新一代 AI 预测引擎。通过提取现实世界的种子信息(如突发新闻、政策草案、金融信号),自动构建出高保真的平行数字世界。在此空间内,成千上万个具备独立人格、长期记忆与行为逻辑的智能体进行自由交互与社会演化。你可透过「上帝视角」动态注入变量,精准推演未来走向——**让未来在数字沙盘中预演,助决策在百战模拟后胜出**。
> 你只需:上传种子材料(数据分析报告或者有趣的小说故事),并用自然语言描述预测需求</br>
> MiroFish 将返回:一份详尽的预测报告,以及一个可深度交互的高保真数字世界
### 我们的愿景
MiroFish 致力于打造映射现实的群体智能镜像,通过捕捉个体互动引发的群体涌现,突破传统预测的局限:
- **于宏观**:我们是决策者的预演实验室,让政策与公关在零风险中试错
- **于微观**:我们是个人用户的创意沙盘,无论是推演小说结局还是探索脑洞,皆可有趣、好玩、触手可及
从严肃预测到趣味仿真,我们让每一个如果都能看见结果,让预测万物成为可能。
## 🌐 在线体验
欢迎访问在线 Demo 演示环境,体验我们为你准备的一次关于热点舆情事件的推演预测:[mirofish-live-demo](https://666ghj.github.io/mirofish-demo/)
## 📸 系统截图
<div align="center">
<table>
<tr>
<td><img src="./static/image/Screenshot/运行截图1.png" alt="截图1" width="100%"/></td>
<td><img src="./static/image/Screenshot/运行截图2.png" alt="截图2" width="100%"/></td>
</tr>
<tr>
<td><img src="./static/image/Screenshot/运行截图3.png" alt="截图3" width="100%"/></td>
<td><img src="./static/image/Screenshot/运行截图4.png" alt="截图4" width="100%"/></td>
</tr>
<tr>
<td><img src="./static/image/Screenshot/运行截图5.png" alt="截图5" width="100%"/></td>
<td><img src="./static/image/Screenshot/运行截图6.png" alt="截图6" width="100%"/></td>
</tr>
</table>
</div>
## 🎬 演示视频
### 1. 武汉大学舆情推演预测 + MiroFish项目讲解
<div align="center">
<a href="https://www.bilibili.com/video/BV1VYBsBHEMY/" target="_blank"><img src="./static/image/武大模拟演示封面.png" alt="MiroFish Demo Video" width="75%"/></a>
点击图片查看使用微舆BettaFish生成的《武大舆情报告》进行预测的完整演示视频
</div>
### 2. 《红楼梦》失传结局推演预测
<div align="center">
<a href="https://www.bilibili.com/video/BV1cPk3BBExq" target="_blank"><img src="./static/image/红楼梦模拟推演封面.jpg" alt="MiroFish Demo Video" width="75%"/></a>
点击图片查看基于《红楼梦》前80回数十万字MiroFish深度预测失传结局
</div>
> **金融方向推演预测**、**时政要闻推演预测**等示例陆续更新中...
## 🔄 工作流程
1. **图谱构建**:现实种子提取 & 个体与群体记忆注入 & GraphRAG构建
2. **环境搭建**:实体关系抽取 & 人设生成 & 环境配置Agent注入仿真参数
3. **开始模拟**:双平台并行模拟 & 自动解析预测需求 & 动态更新时序记忆
4. **报告生成**ReportAgent拥有丰富的工具集与模拟后环境进行深度交互
5. **深度互动**:与模拟世界中的任意一位进行对话 & 与ReportAgent进行对话
## 🚀 快速开始
### 一、源码部署(推荐)
#### 前置要求
| 工具 | 版本要求 | 说明 | 安装检查 |
|------|---------|------|---------|
| **Node.js** | 18+ | 前端运行环境,包含 npm | `node -v` |
| **Python** | ≥3.11, ≤3.12 | 后端运行环境 | `python --version` |
| **uv** | 最新版 | Python 包管理器 | `uv --version` |
#### 1. 配置环境变量
```bash
# 复制示例配置文件
cp .env.example .env
# 编辑 .env 文件,填入必要的 API 密钥
```
**必需的环境变量:**
```env
# LLM API配置支持 OpenAI SDK 格式的任意 LLM API
# 推荐使用阿里百炼平台qwen-plus模型https://bailian.console.aliyun.com/
# 注意消耗较大可先进行小于40轮的模拟尝试
LLM_API_KEY=your_api_key
LLM_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
LLM_MODEL_NAME=qwen-plus
# Zep Cloud 配置
# 每月免费额度即可支撑简单使用https://app.getzep.com/
ZEP_API_KEY=your_zep_api_key
```
#### 2. 安装依赖
```bash
# 一键安装所有依赖(根目录 + 前端 + 后端)
npm run setup:all
```
或者分步安装:
```bash
# 安装 Node 依赖(根目录 + 前端)
npm run setup
# 安装 Python 依赖(后端,自动创建虚拟环境)
npm run setup:backend
```
#### 3. 启动服务
```bash
# 同时启动前后端(在项目根目录执行)
npm run dev
```
**服务地址:**
- 前端:`http://localhost:3000`
- 后端 API`http://localhost:5001`
**单独启动:**
```bash
npm run backend # 仅启动后端
npm run frontend # 仅启动前端
```
### 二、Docker 部署
```bash
# 1. 配置环境变量(同源码部署)
cp .env.example .env
# 2. 拉取镜像并启动
docker compose up -d
```
默认会读取根目录下的 `.env`,并映射端口 `3000前端/5001后端`
> 在 `docker-compose.yml` 中已通过注释提供加速镜像地址,可按需替换
## 📬 更多交流
<div align="center">
<img src="./static/image/QQ群.png" alt="QQ交流群" width="60%"/>
</div>
&nbsp;
MiroFish团队长期招募全职/实习如果你对多Agent应用感兴趣欢迎投递简历至**mirofish@shanda.com**
## 📄 致谢
**MiroFish 得到了盛大集团的战略支持和孵化!**
MiroFish 的仿真引擎由 **[OASIS](https://github.com/camel-ai/oasis)** 驱动,我们衷心感谢 CAMEL-AI 团队的开源贡献!
## 📈 项目统计
<a href="https://www.star-history.com/#666ghj/MiroFish&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=666ghj/MiroFish&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=666ghj/MiroFish&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=666ghj/MiroFish&type=date&legend=top-left" />
</picture>
</a>

207
README.md Normal file
View File

@ -0,0 +1,207 @@
<div align="center">
<img src="./static/image/MiroFish_logo_compressed.jpeg" alt="MiroFish Logo" width="75%"/>
<a href="https://trendshift.io/repositories/16144" target="_blank"><img src="https://trendshift.io/api/badge/repositories/16144" alt="666ghj%2FMiroFish | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
简洁通用的群体智能引擎,预测万物
</br>
<em>A Simple and Universal Swarm Intelligence Engine, Predicting Anything</em>
<a href="https://www.shanda.com/" target="_blank"><img src="./static/image/shanda_logo.png" alt="666ghj%2FMiroFish | Shanda" height="40"/></a>
[![GitHub Stars](https://img.shields.io/github/stars/666ghj/MiroFish?style=flat-square&color=DAA520)](https://github.com/666ghj/MiroFish/stargazers)
[![GitHub Watchers](https://img.shields.io/github/watchers/666ghj/MiroFish?style=flat-square)](https://github.com/666ghj/MiroFish/watchers)
[![GitHub Forks](https://img.shields.io/github/forks/666ghj/MiroFish?style=flat-square)](https://github.com/666ghj/MiroFish/network)
[![Docker](https://img.shields.io/badge/Docker-Build-2496ED?style=flat-square&logo=docker&logoColor=white)](https://hub.docker.com/)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/666ghj/MiroFish)
[![Discord](https://img.shields.io/badge/Discord-Join-5865F2?style=flat-square&logo=discord&logoColor=white)](http://discord.gg/ePf5aPaHnA)
[![X](https://img.shields.io/badge/X-Follow-000000?style=flat-square&logo=x&logoColor=white)](https://x.com/mirofish_ai)
[![Instagram](https://img.shields.io/badge/Instagram-Follow-E4405F?style=flat-square&logo=instagram&logoColor=white)](https://www.instagram.com/mirofish_ai/)
[English](./README.md) | [中文文档](./README-ZH.md)
</div>
## ⚡ Overview
**MiroFish** is a next-generation AI prediction engine powered by multi-agent technology. By extracting seed information from the real world (such as breaking news, policy drafts, or financial signals), it automatically constructs a high-fidelity parallel digital world. Within this space, thousands of intelligent agents with independent personalities, long-term memory, and behavioral logic freely interact and undergo social evolution. You can inject variables dynamically from a "God's-eye view" to precisely deduce future trajectories — **rehearse the future in a digital sandbox, and win decisions after countless simulations**.
> You only need to: Upload seed materials (data analysis reports or interesting novel stories) and describe your prediction requirements in natural language</br>
> MiroFish will return: A detailed prediction report and a deeply interactive high-fidelity digital world
### Our Vision
MiroFish is dedicated to creating a swarm intelligence mirror that maps reality. By capturing the collective emergence triggered by individual interactions, we break through the limitations of traditional prediction:
- **At the Macro Level**: We are a rehearsal laboratory for decision-makers, allowing policies and public relations to be tested at zero risk
- **At the Micro Level**: We are a creative sandbox for individual users — whether deducing novel endings or exploring imaginative scenarios, everything can be fun, playful, and accessible
From serious predictions to playful simulations, we let every "what if" see its outcome, making it possible to predict anything.
## 🌐 Live Demo
Welcome to visit our online demo environment and experience a prediction simulation on trending public opinion events we've prepared for you: [mirofish-live-demo](https://666ghj.github.io/mirofish-demo/)
## 📸 Screenshots
<div align="center">
<table>
<tr>
<td><img src="./static/image/Screenshot/运行截图1.png" alt="Screenshot 1" width="100%"/></td>
<td><img src="./static/image/Screenshot/运行截图2.png" alt="Screenshot 2" width="100%"/></td>
</tr>
<tr>
<td><img src="./static/image/Screenshot/运行截图3.png" alt="Screenshot 3" width="100%"/></td>
<td><img src="./static/image/Screenshot/运行截图4.png" alt="Screenshot 4" width="100%"/></td>
</tr>
<tr>
<td><img src="./static/image/Screenshot/运行截图5.png" alt="Screenshot 5" width="100%"/></td>
<td><img src="./static/image/Screenshot/运行截图6.png" alt="Screenshot 6" width="100%"/></td>
</tr>
</table>
</div>
## 🎬 Demo Videos
### 1. Wuhan University Public Opinion Simulation + MiroFish Project Introduction
<div align="center">
<a href="https://www.bilibili.com/video/BV1VYBsBHEMY/" target="_blank"><img src="./static/image/武大模拟演示封面.png" alt="MiroFish Demo Video" width="75%"/></a>
Click the image to watch the complete demo video for prediction using BettaFish-generated "Wuhan University Public Opinion Report"
</div>
### 2. Dream of the Red Chamber Lost Ending Simulation
<div align="center">
<a href="https://www.bilibili.com/video/BV1cPk3BBExq" target="_blank"><img src="./static/image/红楼梦模拟推演封面.jpg" alt="MiroFish Demo Video" width="75%"/></a>
Click the image to watch MiroFish's deep prediction of the lost ending based on hundreds of thousands of words from the first 80 chapters of "Dream of the Red Chamber"
</div>
> **Financial Prediction**, **Political News Prediction** and more examples coming soon...
## 🔄 Workflow
1. **Graph Building**: Seed extraction & Individual/collective memory injection & GraphRAG construction
2. **Environment Setup**: Entity relationship extraction & Persona generation & Agent configuration injection
3. **Simulation**: Dual-platform parallel simulation & Auto-parse prediction requirements & Dynamic temporal memory updates
4. **Report Generation**: ReportAgent with rich toolset for deep interaction with post-simulation environment
5. **Deep Interaction**: Chat with any agent in the simulated world & Interact with ReportAgent
## 🚀 Quick Start
### Option 1: Source Code Deployment (Recommended)
#### Prerequisites
| Tool | Version | Description | Check Installation |
|------|---------|-------------|-------------------|
| **Node.js** | 18+ | Frontend runtime, includes npm | `node -v` |
| **Python** | ≥3.11, ≤3.12 | Backend runtime | `python --version` |
| **uv** | Latest | Python package manager | `uv --version` |
#### 1. Configure Environment Variables
```bash
# Copy the example configuration file
cp .env.example .env
# Edit the .env file and fill in the required API keys
```
**Required Environment Variables:**
```env
# LLM API Configuration (supports any LLM API with OpenAI SDK format)
# Recommended: Alibaba Qwen-plus model via Bailian Platform: https://bailian.console.aliyun.com/
# High consumption, try simulations with fewer than 40 rounds first
LLM_API_KEY=your_api_key
LLM_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
LLM_MODEL_NAME=qwen-plus
# Zep Cloud Configuration
# Free monthly quota is sufficient for simple usage: https://app.getzep.com/
ZEP_API_KEY=your_zep_api_key
# Frontend API URL (required for remote/VPS deployment)
# Defaults to http://localhost:5001; set to the public backend URL when deploying to a remote server
# VITE_API_BASE_URL=http://your-server-ip:5001
```
#### 2. Install Dependencies
```bash
# One-click installation of all dependencies (root + frontend + backend)
npm run setup:all
```
Or install step by step:
```bash
# Install Node dependencies (root + frontend)
npm run setup
# Install Python dependencies (backend, auto-creates virtual environment)
npm run setup:backend
```
#### 3. Start Services
```bash
# Start both frontend and backend (run from project root)
npm run dev
```
**Service URLs:**
- Frontend: `http://localhost:3000`
- Backend API: `http://localhost:5001`
**Start Individually:**
```bash
npm run backend # Start backend only
npm run frontend # Start frontend only
```
### Option 2: Docker Deployment
```bash
# 1. Configure environment variables (same as source deployment)
cp .env.example .env
# 2. Pull image and start
docker compose up -d
```
Reads `.env` from root directory by default, maps ports `3000 (frontend) / 5001 (backend)`
> Mirror address for faster pulling is provided as comments in `docker-compose.yml`, replace if needed.
## 📬 Join the Conversation
<div align="center">
<img src="./static/image/QQ群.png" alt="QQ Group" width="60%"/>
</div>
&nbsp;
The MiroFish team is recruiting full-time/internship positions. If you're interested in multi-agent simulation and LLM applications, feel free to send your resume to: **mirofish@shanda.com**
## 📄 Acknowledgments
**MiroFish has received strategic support and incubation from Shanda Group!**
MiroFish's simulation engine is powered by **[OASIS (Open Agent Social Interaction Simulations)](https://github.com/camel-ai/oasis)**, We sincerely thank the CAMEL-AI team for their open-source contributions!
## 📈 Project Statistics
<a href="https://www.star-history.com/#666ghj/MiroFish&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=666ghj/MiroFish&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=666ghj/MiroFish&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=666ghj/MiroFish&type=date&legend=top-left" />
</picture>
</a>

80
backend/app/__init__.py Normal file
View File

@ -0,0 +1,80 @@
"""
MiroFish Backend - Flask应用工厂
"""
import os
import warnings
# 抑制 multiprocessing resource_tracker 的警告(来自第三方库如 transformers
# 需要在所有其他导入之前设置
warnings.filterwarnings("ignore", message=".*resource_tracker.*")
from flask import Flask, request
from flask_cors import CORS
from .config import Config
from .utils.logger import setup_logger, get_logger
def create_app(config_class=Config):
"""Flask应用工厂函数"""
app = Flask(__name__)
app.config.from_object(config_class)
# 设置JSON编码确保中文直接显示而不是 \uXXXX 格式)
# Flask >= 2.3 使用 app.json.ensure_ascii旧版本使用 JSON_AS_ASCII 配置
if hasattr(app, 'json') and hasattr(app.json, 'ensure_ascii'):
app.json.ensure_ascii = False
# 设置日志
logger = setup_logger('mirofish')
# 只在 reloader 子进程中打印启动信息(避免 debug 模式下打印两次)
is_reloader_process = os.environ.get('WERKZEUG_RUN_MAIN') == 'true'
debug_mode = app.config.get('DEBUG', False)
should_log_startup = not debug_mode or is_reloader_process
if should_log_startup:
logger.info("=" * 50)
logger.info("MiroFish Backend 启动中...")
logger.info("=" * 50)
# 启用CORS
CORS(app, resources={r"/api/*": {"origins": "*"}})
# 注册模拟进程清理函数(确保服务器关闭时终止所有模拟进程)
from .services.simulation_runner import SimulationRunner
SimulationRunner.register_cleanup()
if should_log_startup:
logger.info("已注册模拟进程清理函数")
# 请求日志中间件
@app.before_request
def log_request():
logger = get_logger('mirofish.request')
logger.debug(f"请求: {request.method} {request.path}")
if request.content_type and 'json' in request.content_type:
logger.debug(f"请求体: {request.get_json(silent=True)}")
@app.after_request
def log_response(response):
logger = get_logger('mirofish.request')
logger.debug(f"响应: {response.status_code}")
return response
# 注册蓝图
from .api import graph_bp, simulation_bp, report_bp
app.register_blueprint(graph_bp, url_prefix='/api/graph')
app.register_blueprint(simulation_bp, url_prefix='/api/simulation')
app.register_blueprint(report_bp, url_prefix='/api/report')
# 健康检查
@app.route('/health')
def health():
return {'status': 'ok', 'service': 'MiroFish Backend'}
if should_log_startup:
logger.info("MiroFish Backend 启动完成")
return app

View File

@ -0,0 +1,14 @@
"""
API路由模块
"""
from flask import Blueprint
graph_bp = Blueprint('graph', __name__)
simulation_bp = Blueprint('simulation', __name__)
report_bp = Blueprint('report', __name__)
from . import graph # noqa: E402, F401
from . import simulation # noqa: E402, F401
from . import report # noqa: E402, F401

622
backend/app/api/graph.py Normal file
View File

@ -0,0 +1,622 @@
"""
图谱相关API路由
采用项目上下文机制服务端持久化状态
"""
import os
import traceback
import threading
from flask import request, jsonify
from . import graph_bp
from ..config import Config
from ..services.ontology_generator import OntologyGenerator
from ..services.graph_builder import GraphBuilderService
from ..services.text_processor import TextProcessor
from ..utils.file_parser import FileParser
from ..utils.logger import get_logger
from ..utils.locale import t, get_locale, set_locale
from ..models.task import TaskManager, TaskStatus
from ..models.project import ProjectManager, ProjectStatus
# 获取日志器
logger = get_logger('mirofish.api')
def allowed_file(filename: str) -> bool:
"""检查文件扩展名是否允许"""
if not filename or '.' not in filename:
return False
ext = os.path.splitext(filename)[1].lower().lstrip('.')
return ext in Config.ALLOWED_EXTENSIONS
# ============== 项目管理接口 ==============
@graph_bp.route('/project/<project_id>', methods=['GET'])
def get_project(project_id: str):
"""
获取项目详情
"""
project = ProjectManager.get_project(project_id)
if not project:
return jsonify({
"success": False,
"error": t('api.projectNotFound', id=project_id)
}), 404
return jsonify({
"success": True,
"data": project.to_dict()
})
@graph_bp.route('/project/list', methods=['GET'])
def list_projects():
"""
列出所有项目
"""
limit = request.args.get('limit', 50, type=int)
projects = ProjectManager.list_projects(limit=limit)
return jsonify({
"success": True,
"data": [p.to_dict() for p in projects],
"count": len(projects)
})
@graph_bp.route('/project/<project_id>', methods=['DELETE'])
def delete_project(project_id: str):
"""
删除项目
"""
success = ProjectManager.delete_project(project_id)
if not success:
return jsonify({
"success": False,
"error": t('api.projectDeleteFailed', id=project_id)
}), 404
return jsonify({
"success": True,
"message": t('api.projectDeleted', id=project_id)
})
@graph_bp.route('/project/<project_id>/reset', methods=['POST'])
def reset_project(project_id: str):
"""
重置项目状态用于重新构建图谱
"""
project = ProjectManager.get_project(project_id)
if not project:
return jsonify({
"success": False,
"error": t('api.projectNotFound', id=project_id)
}), 404
# 重置到本体已生成状态
if project.ontology:
project.status = ProjectStatus.ONTOLOGY_GENERATED
else:
project.status = ProjectStatus.CREATED
project.graph_id = None
project.graph_build_task_id = None
project.error = None
ProjectManager.save_project(project)
return jsonify({
"success": True,
"message": t('api.projectReset', id=project_id),
"data": project.to_dict()
})
# ============== 接口1上传文件并生成本体 ==============
@graph_bp.route('/ontology/generate', methods=['POST'])
def generate_ontology():
"""
接口1上传文件分析生成本体定义
请求方式multipart/form-data
参数
files: 上传的文件PDF/MD/TXT可多个
simulation_requirement: 模拟需求描述必填
project_name: 项目名称可选
additional_context: 额外说明可选
返回
{
"success": true,
"data": {
"project_id": "proj_xxxx",
"ontology": {
"entity_types": [...],
"edge_types": [...],
"analysis_summary": "..."
},
"files": [...],
"total_text_length": 12345
}
}
"""
try:
logger.info("=== 开始生成本体定义 ===")
# 获取参数
simulation_requirement = request.form.get('simulation_requirement', '')
project_name = request.form.get('project_name', 'Unnamed Project')
additional_context = request.form.get('additional_context', '')
logger.debug(f"项目名称: {project_name}")
logger.debug(f"模拟需求: {simulation_requirement[:100]}...")
if not simulation_requirement:
return jsonify({
"success": False,
"error": t('api.requireSimulationRequirement')
}), 400
# 获取上传的文件
uploaded_files = request.files.getlist('files')
if not uploaded_files or all(not f.filename for f in uploaded_files):
return jsonify({
"success": False,
"error": t('api.requireFileUpload')
}), 400
# 创建项目
project = ProjectManager.create_project(name=project_name)
project.simulation_requirement = simulation_requirement
logger.info(f"创建项目: {project.project_id}")
# 保存文件并提取文本
document_texts = []
all_text = ""
for file in uploaded_files:
if file and file.filename and allowed_file(file.filename):
# 保存文件到项目目录
file_info = ProjectManager.save_file_to_project(
project.project_id,
file,
file.filename
)
project.files.append({
"filename": file_info["original_filename"],
"size": file_info["size"]
})
# 提取文本
text = FileParser.extract_text(file_info["path"])
text = TextProcessor.preprocess_text(text)
document_texts.append(text)
all_text += f"\n\n=== {file_info['original_filename']} ===\n{text}"
if not document_texts:
ProjectManager.delete_project(project.project_id)
return jsonify({
"success": False,
"error": t('api.noDocProcessed')
}), 400
# 保存提取的文本
project.total_text_length = len(all_text)
ProjectManager.save_extracted_text(project.project_id, all_text)
logger.info(f"文本提取完成,共 {len(all_text)} 字符")
# 生成本体
logger.info("调用 LLM 生成本体定义...")
generator = OntologyGenerator()
ontology = generator.generate(
document_texts=document_texts,
simulation_requirement=simulation_requirement,
additional_context=additional_context if additional_context else None
)
# 保存本体到项目
entity_count = len(ontology.get("entity_types", []))
edge_count = len(ontology.get("edge_types", []))
logger.info(f"本体生成完成: {entity_count} 个实体类型, {edge_count} 个关系类型")
project.ontology = {
"entity_types": ontology.get("entity_types", []),
"edge_types": ontology.get("edge_types", [])
}
project.analysis_summary = ontology.get("analysis_summary", "")
project.status = ProjectStatus.ONTOLOGY_GENERATED
ProjectManager.save_project(project)
logger.info(f"=== 本体生成完成 === 项目ID: {project.project_id}")
return jsonify({
"success": True,
"data": {
"project_id": project.project_id,
"project_name": project.name,
"ontology": project.ontology,
"analysis_summary": project.analysis_summary,
"files": project.files,
"total_text_length": project.total_text_length
}
})
except Exception as e:
return jsonify({
"success": False,
"error": str(e),
"traceback": traceback.format_exc()
}), 500
# ============== 接口2构建图谱 ==============
@graph_bp.route('/build', methods=['POST'])
def build_graph():
"""
接口2根据project_id构建图谱
请求JSON
{
"project_id": "proj_xxxx", // 必填来自接口1
"graph_name": "图谱名称", // 可选
"chunk_size": 500, // 可选默认500
"chunk_overlap": 50 // 可选默认50
}
返回
{
"success": true,
"data": {
"project_id": "proj_xxxx",
"task_id": "task_xxxx",
"message": "图谱构建任务已启动"
}
}
"""
try:
logger.info("=== 开始构建图谱 ===")
# 检查配置
errors = []
if not Config.ZEP_API_KEY:
errors.append(t('api.zepApiKeyMissing'))
if errors:
logger.error(f"配置错误: {errors}")
return jsonify({
"success": False,
"error": t('api.configError', details="; ".join(errors))
}), 500
# 解析请求
data = request.get_json() or {}
project_id = data.get('project_id')
logger.debug(f"请求参数: project_id={project_id}")
if not project_id:
return jsonify({
"success": False,
"error": t('api.requireProjectId')
}), 400
# 获取项目
project = ProjectManager.get_project(project_id)
if not project:
return jsonify({
"success": False,
"error": t('api.projectNotFound', id=project_id)
}), 404
# 检查项目状态
force = data.get('force', False) # 强制重新构建
if project.status == ProjectStatus.CREATED:
return jsonify({
"success": False,
"error": t('api.ontologyNotGenerated')
}), 400
if project.status == ProjectStatus.GRAPH_BUILDING and not force:
return jsonify({
"success": False,
"error": t('api.graphBuilding'),
"task_id": project.graph_build_task_id
}), 400
# 如果强制重建,重置状态
if force and project.status in [ProjectStatus.GRAPH_BUILDING, ProjectStatus.FAILED, ProjectStatus.GRAPH_COMPLETED]:
project.status = ProjectStatus.ONTOLOGY_GENERATED
project.graph_id = None
project.graph_build_task_id = None
project.error = None
# 获取配置
graph_name = data.get('graph_name', project.name or 'MiroFish Graph')
chunk_size = data.get('chunk_size', project.chunk_size or Config.DEFAULT_CHUNK_SIZE)
chunk_overlap = data.get('chunk_overlap', project.chunk_overlap or Config.DEFAULT_CHUNK_OVERLAP)
# 更新项目配置
project.chunk_size = chunk_size
project.chunk_overlap = chunk_overlap
# 获取提取的文本
text = ProjectManager.get_extracted_text(project_id)
if not text:
return jsonify({
"success": False,
"error": t('api.textNotFound')
}), 400
# 获取本体
ontology = project.ontology
if not ontology:
return jsonify({
"success": False,
"error": t('api.ontologyNotFound')
}), 400
# 创建异步任务
task_manager = TaskManager()
task_id = task_manager.create_task(f"构建图谱: {graph_name}")
logger.info(f"创建图谱构建任务: task_id={task_id}, project_id={project_id}")
# 更新项目状态
project.status = ProjectStatus.GRAPH_BUILDING
project.graph_build_task_id = task_id
ProjectManager.save_project(project)
# Capture locale before spawning background thread
current_locale = get_locale()
# 启动后台任务
def build_task():
set_locale(current_locale)
build_logger = get_logger('mirofish.build')
try:
build_logger.info(f"[{task_id}] 开始构建图谱...")
task_manager.update_task(
task_id,
status=TaskStatus.PROCESSING,
message=t('progress.initGraphService')
)
# 创建图谱构建服务
builder = GraphBuilderService(api_key=Config.ZEP_API_KEY)
# 分块
task_manager.update_task(
task_id,
message=t('progress.textChunking'),
progress=5
)
chunks = TextProcessor.split_text(
text,
chunk_size=chunk_size,
overlap=chunk_overlap
)
total_chunks = len(chunks)
# 创建图谱
task_manager.update_task(
task_id,
message=t('progress.creatingZepGraph'),
progress=10
)
graph_id = builder.create_graph(name=graph_name)
# 更新项目的graph_id
project.graph_id = graph_id
ProjectManager.save_project(project)
# 设置本体
task_manager.update_task(
task_id,
message=t('progress.settingOntology'),
progress=15
)
builder.set_ontology(graph_id, ontology)
# 添加文本progress_callback 签名是 (msg, progress_ratio)
def add_progress_callback(msg, progress_ratio):
progress = 15 + int(progress_ratio * 40) # 15% - 55%
task_manager.update_task(
task_id,
message=msg,
progress=progress
)
task_manager.update_task(
task_id,
message=t('progress.addingChunks', count=total_chunks),
progress=15
)
episode_uuids = builder.add_text_batches(
graph_id,
chunks,
batch_size=3,
progress_callback=add_progress_callback
)
# 等待Zep处理完成查询每个episode的processed状态
task_manager.update_task(
task_id,
message=t('progress.waitingZepProcess'),
progress=55
)
def wait_progress_callback(msg, progress_ratio):
progress = 55 + int(progress_ratio * 35) # 55% - 90%
task_manager.update_task(
task_id,
message=msg,
progress=progress
)
builder._wait_for_episodes(episode_uuids, wait_progress_callback)
# 获取图谱数据
task_manager.update_task(
task_id,
message=t('progress.fetchingGraphData'),
progress=95
)
graph_data = builder.get_graph_data(graph_id)
# 更新项目状态
project.status = ProjectStatus.GRAPH_COMPLETED
ProjectManager.save_project(project)
node_count = graph_data.get("node_count", 0)
edge_count = graph_data.get("edge_count", 0)
build_logger.info(f"[{task_id}] 图谱构建完成: graph_id={graph_id}, 节点={node_count}, 边={edge_count}")
# 完成
task_manager.update_task(
task_id,
status=TaskStatus.COMPLETED,
message=t('progress.graphBuildComplete'),
progress=100,
result={
"project_id": project_id,
"graph_id": graph_id,
"node_count": node_count,
"edge_count": edge_count,
"chunk_count": total_chunks
}
)
except Exception as e:
# 更新项目状态为失败
build_logger.error(f"[{task_id}] 图谱构建失败: {str(e)}")
build_logger.debug(traceback.format_exc())
project.status = ProjectStatus.FAILED
project.error = str(e)
ProjectManager.save_project(project)
task_manager.update_task(
task_id,
status=TaskStatus.FAILED,
message=t('progress.buildFailed', error=str(e)),
error=traceback.format_exc()
)
# 启动后台线程
thread = threading.Thread(target=build_task, daemon=True)
thread.start()
return jsonify({
"success": True,
"data": {
"project_id": project_id,
"task_id": task_id,
"message": t('api.graphBuildStarted', taskId=task_id)
}
})
except Exception as e:
return jsonify({
"success": False,
"error": str(e),
"traceback": traceback.format_exc()
}), 500
# ============== 任务查询接口 ==============
@graph_bp.route('/task/<task_id>', methods=['GET'])
def get_task(task_id: str):
"""
查询任务状态
"""
task = TaskManager().get_task(task_id)
if not task:
return jsonify({
"success": False,
"error": t('api.taskNotFound', id=task_id)
}), 404
return jsonify({
"success": True,
"data": task.to_dict()
})
@graph_bp.route('/tasks', methods=['GET'])
def list_tasks():
"""
列出所有任务
"""
tasks = TaskManager().list_tasks()
return jsonify({
"success": True,
"data": [t.to_dict() for t in tasks],
"count": len(tasks)
})
# ============== 图谱数据接口 ==============
@graph_bp.route('/data/<graph_id>', methods=['GET'])
def get_graph_data(graph_id: str):
"""
获取图谱数据节点和边
"""
try:
if not Config.ZEP_API_KEY:
return jsonify({
"success": False,
"error": t('api.zepApiKeyMissing')
}), 500
builder = GraphBuilderService(api_key=Config.ZEP_API_KEY)
graph_data = builder.get_graph_data(graph_id)
return jsonify({
"success": True,
"data": graph_data
})
except Exception as e:
return jsonify({
"success": False,
"error": str(e),
"traceback": traceback.format_exc()
}), 500
@graph_bp.route('/delete/<graph_id>', methods=['DELETE'])
def delete_graph(graph_id: str):
"""
删除Zep图谱
"""
try:
if not Config.ZEP_API_KEY:
return jsonify({
"success": False,
"error": t('api.zepApiKeyMissing')
}), 500
builder = GraphBuilderService(api_key=Config.ZEP_API_KEY)
builder.delete_graph(graph_id)
return jsonify({
"success": True,
"message": t('api.graphDeleted', id=graph_id)
})
except Exception as e:
return jsonify({
"success": False,
"error": str(e),
"traceback": traceback.format_exc()
}), 500

1020
backend/app/api/report.py Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

75
backend/app/config.py Normal file
View File

@ -0,0 +1,75 @@
"""
配置管理
统一从项目根目录的 .env 文件加载配置
"""
import os
from dotenv import load_dotenv
# 加载项目根目录的 .env 文件
# 路径: MiroFish/.env (相对于 backend/app/config.py)
project_root_env = os.path.join(os.path.dirname(__file__), '../../.env')
if os.path.exists(project_root_env):
load_dotenv(project_root_env, override=True)
else:
# 如果根目录没有 .env尝试加载环境变量用于生产环境
load_dotenv(override=True)
class Config:
"""Flask配置类"""
# Flask配置
SECRET_KEY = os.environ.get('SECRET_KEY', 'mirofish-secret-key')
DEBUG = os.environ.get('FLASK_DEBUG', 'True').lower() == 'true'
# JSON配置 - 禁用ASCII转义让中文直接显示而不是 \uXXXX 格式)
JSON_AS_ASCII = False
# LLM配置统一使用OpenAI格式
LLM_API_KEY = os.environ.get('LLM_API_KEY')
LLM_BASE_URL = os.environ.get('LLM_BASE_URL', 'https://api.openai.com/v1')
LLM_MODEL_NAME = os.environ.get('LLM_MODEL_NAME', 'gpt-4o-mini')
# Zep配置
ZEP_API_KEY = os.environ.get('ZEP_API_KEY')
# 文件上传配置
MAX_CONTENT_LENGTH = 50 * 1024 * 1024 # 50MB
UPLOAD_FOLDER = os.path.join(os.path.dirname(__file__), '../uploads')
ALLOWED_EXTENSIONS = {'pdf', 'md', 'txt', 'markdown'}
# 文本处理配置
DEFAULT_CHUNK_SIZE = 500 # 默认切块大小
DEFAULT_CHUNK_OVERLAP = 50 # 默认重叠大小
# OASIS模拟配置
OASIS_DEFAULT_MAX_ROUNDS = int(os.environ.get('OASIS_DEFAULT_MAX_ROUNDS', '10'))
OASIS_SIMULATION_DATA_DIR = os.path.join(os.path.dirname(__file__), '../uploads/simulations')
# OASIS平台可用动作配置
OASIS_TWITTER_ACTIONS = [
'CREATE_POST', 'LIKE_POST', 'REPOST', 'FOLLOW', 'DO_NOTHING', 'QUOTE_POST'
]
OASIS_REDDIT_ACTIONS = [
'LIKE_POST', 'DISLIKE_POST', 'CREATE_POST', 'CREATE_COMMENT',
'LIKE_COMMENT', 'DISLIKE_COMMENT', 'SEARCH_POSTS', 'SEARCH_USER',
'TREND', 'REFRESH', 'DO_NOTHING', 'FOLLOW', 'MUTE'
]
# Report Agent配置
REPORT_AGENT_MAX_TOOL_CALLS = int(os.environ.get('REPORT_AGENT_MAX_TOOL_CALLS', '5'))
REPORT_AGENT_MAX_REFLECTION_ROUNDS = int(os.environ.get('REPORT_AGENT_MAX_REFLECTION_ROUNDS', '2'))
REPORT_AGENT_TEMPERATURE = float(os.environ.get('REPORT_AGENT_TEMPERATURE', '0.5'))
@classmethod
def validate(cls) -> list[str]:
"""验证必要配置"""
errors: list[str] = []
if not cls.LLM_API_KEY:
errors.append("LLM_API_KEY 未配置")
if not cls.ZEP_API_KEY:
errors.append("ZEP_API_KEY 未配置")
return errors

View File

@ -0,0 +1,9 @@
"""
数据模型模块
"""
from .task import TaskManager, TaskStatus
from .project import Project, ProjectStatus, ProjectManager
__all__ = ['TaskManager', 'TaskStatus', 'Project', 'ProjectStatus', 'ProjectManager']

View File

@ -0,0 +1,305 @@
"""
项目上下文管理
用于在服务端持久化项目状态避免前端在接口间传递大量数据
"""
import os
import json
import uuid
import shutil
from datetime import datetime
from typing import Dict, Any, List, Optional
from enum import Enum
from dataclasses import dataclass, field, asdict
from ..config import Config
class ProjectStatus(str, Enum):
"""项目状态"""
CREATED = "created" # 刚创建,文件已上传
ONTOLOGY_GENERATED = "ontology_generated" # 本体已生成
GRAPH_BUILDING = "graph_building" # 图谱构建中
GRAPH_COMPLETED = "graph_completed" # 图谱构建完成
FAILED = "failed" # 失败
@dataclass
class Project:
"""项目数据模型"""
project_id: str
name: str
status: ProjectStatus
created_at: str
updated_at: str
# 文件信息
files: List[Dict[str, str]] = field(default_factory=list) # [{filename, path, size}]
total_text_length: int = 0
# 本体信息接口1生成后填充
ontology: Optional[Dict[str, Any]] = None
analysis_summary: Optional[str] = None
# 图谱信息接口2完成后填充
graph_id: Optional[str] = None
graph_build_task_id: Optional[str] = None
# 配置
simulation_requirement: Optional[str] = None
chunk_size: int = 500
chunk_overlap: int = 50
# 错误信息
error: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
"""转换为字典"""
return {
"project_id": self.project_id,
"name": self.name,
"status": self.status.value if isinstance(self.status, ProjectStatus) else self.status,
"created_at": self.created_at,
"updated_at": self.updated_at,
"files": self.files,
"total_text_length": self.total_text_length,
"ontology": self.ontology,
"analysis_summary": self.analysis_summary,
"graph_id": self.graph_id,
"graph_build_task_id": self.graph_build_task_id,
"simulation_requirement": self.simulation_requirement,
"chunk_size": self.chunk_size,
"chunk_overlap": self.chunk_overlap,
"error": self.error
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'Project':
"""从字典创建"""
status = data.get('status', 'created')
if isinstance(status, str):
status = ProjectStatus(status)
return cls(
project_id=data['project_id'],
name=data.get('name', 'Unnamed Project'),
status=status,
created_at=data.get('created_at', ''),
updated_at=data.get('updated_at', ''),
files=data.get('files', []),
total_text_length=data.get('total_text_length', 0),
ontology=data.get('ontology'),
analysis_summary=data.get('analysis_summary'),
graph_id=data.get('graph_id'),
graph_build_task_id=data.get('graph_build_task_id'),
simulation_requirement=data.get('simulation_requirement'),
chunk_size=data.get('chunk_size', 500),
chunk_overlap=data.get('chunk_overlap', 50),
error=data.get('error')
)
class ProjectManager:
"""项目管理器 - 负责项目的持久化存储和检索"""
# 项目存储根目录
PROJECTS_DIR = os.path.join(Config.UPLOAD_FOLDER, 'projects')
@classmethod
def _ensure_projects_dir(cls):
"""确保项目目录存在"""
os.makedirs(cls.PROJECTS_DIR, exist_ok=True)
@classmethod
def _get_project_dir(cls, project_id: str) -> str:
"""获取项目目录路径"""
return os.path.join(cls.PROJECTS_DIR, project_id)
@classmethod
def _get_project_meta_path(cls, project_id: str) -> str:
"""获取项目元数据文件路径"""
return os.path.join(cls._get_project_dir(project_id), 'project.json')
@classmethod
def _get_project_files_dir(cls, project_id: str) -> str:
"""获取项目文件存储目录"""
return os.path.join(cls._get_project_dir(project_id), 'files')
@classmethod
def _get_project_text_path(cls, project_id: str) -> str:
"""获取项目提取文本存储路径"""
return os.path.join(cls._get_project_dir(project_id), 'extracted_text.txt')
@classmethod
def create_project(cls, name: str = "Unnamed Project") -> Project:
"""
创建新项目
Args:
name: 项目名称
Returns:
新创建的Project对象
"""
cls._ensure_projects_dir()
project_id = f"proj_{uuid.uuid4().hex[:12]}"
now = datetime.now().isoformat()
project = Project(
project_id=project_id,
name=name,
status=ProjectStatus.CREATED,
created_at=now,
updated_at=now
)
# 创建项目目录结构
project_dir = cls._get_project_dir(project_id)
files_dir = cls._get_project_files_dir(project_id)
os.makedirs(project_dir, exist_ok=True)
os.makedirs(files_dir, exist_ok=True)
# 保存项目元数据
cls.save_project(project)
return project
@classmethod
def save_project(cls, project: Project) -> None:
"""保存项目元数据"""
project.updated_at = datetime.now().isoformat()
meta_path = cls._get_project_meta_path(project.project_id)
with open(meta_path, 'w', encoding='utf-8') as f:
json.dump(project.to_dict(), f, ensure_ascii=False, indent=2)
@classmethod
def get_project(cls, project_id: str) -> Optional[Project]:
"""
获取项目
Args:
project_id: 项目ID
Returns:
Project对象如果不存在返回None
"""
meta_path = cls._get_project_meta_path(project_id)
if not os.path.exists(meta_path):
return None
with open(meta_path, 'r', encoding='utf-8') as f:
data = json.load(f)
return Project.from_dict(data)
@classmethod
def list_projects(cls, limit: int = 50) -> List[Project]:
"""
列出所有项目
Args:
limit: 返回数量限制
Returns:
项目列表按创建时间倒序
"""
cls._ensure_projects_dir()
projects = []
for project_id in os.listdir(cls.PROJECTS_DIR):
project = cls.get_project(project_id)
if project:
projects.append(project)
# 按创建时间倒序排序
projects.sort(key=lambda p: p.created_at, reverse=True)
return projects[:limit]
@classmethod
def delete_project(cls, project_id: str) -> bool:
"""
删除项目及其所有文件
Args:
project_id: 项目ID
Returns:
是否删除成功
"""
project_dir = cls._get_project_dir(project_id)
if not os.path.exists(project_dir):
return False
shutil.rmtree(project_dir)
return True
@classmethod
def save_file_to_project(cls, project_id: str, file_storage, original_filename: str) -> Dict[str, str]:
"""
保存上传的文件到项目目录
Args:
project_id: 项目ID
file_storage: Flask的FileStorage对象
original_filename: 原始文件名
Returns:
文件信息字典 {filename, path, size}
"""
files_dir = cls._get_project_files_dir(project_id)
os.makedirs(files_dir, exist_ok=True)
# 生成安全的文件名
ext = os.path.splitext(original_filename)[1].lower()
safe_filename = f"{uuid.uuid4().hex[:8]}{ext}"
file_path = os.path.join(files_dir, safe_filename)
# 保存文件
file_storage.save(file_path)
# 获取文件大小
file_size = os.path.getsize(file_path)
return {
"original_filename": original_filename,
"saved_filename": safe_filename,
"path": file_path,
"size": file_size
}
@classmethod
def save_extracted_text(cls, project_id: str, text: str) -> None:
"""保存提取的文本"""
text_path = cls._get_project_text_path(project_id)
with open(text_path, 'w', encoding='utf-8') as f:
f.write(text)
@classmethod
def get_extracted_text(cls, project_id: str) -> Optional[str]:
"""获取提取的文本"""
text_path = cls._get_project_text_path(project_id)
if not os.path.exists(text_path):
return None
with open(text_path, 'r', encoding='utf-8') as f:
return f.read()
@classmethod
def get_project_files(cls, project_id: str) -> List[str]:
"""获取项目的所有文件路径"""
files_dir = cls._get_project_files_dir(project_id)
if not os.path.exists(files_dir):
return []
return [
os.path.join(files_dir, f)
for f in os.listdir(files_dir)
if os.path.isfile(os.path.join(files_dir, f))
]

186
backend/app/models/task.py Normal file
View File

@ -0,0 +1,186 @@
"""
任务状态管理
用于跟踪长时间运行的任务如图谱构建
"""
import uuid
import threading
from datetime import datetime
from enum import Enum
from typing import Dict, Any, Optional
from dataclasses import dataclass, field
from ..utils.locale import t
class TaskStatus(str, Enum):
"""任务状态枚举"""
PENDING = "pending" # 等待中
PROCESSING = "processing" # 处理中
COMPLETED = "completed" # 已完成
FAILED = "failed" # 失败
@dataclass
class Task:
"""任务数据类"""
task_id: str
task_type: str
status: TaskStatus
created_at: datetime
updated_at: datetime
progress: int = 0 # 总进度百分比 0-100
message: str = "" # 状态消息
result: Optional[Dict] = None # 任务结果
error: Optional[str] = None # 错误信息
metadata: Dict = field(default_factory=dict) # 额外元数据
progress_detail: Dict = field(default_factory=dict) # 详细进度信息
def to_dict(self) -> Dict[str, Any]:
"""转换为字典"""
return {
"task_id": self.task_id,
"task_type": self.task_type,
"status": self.status.value,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
"progress": self.progress,
"message": self.message,
"progress_detail": self.progress_detail,
"result": self.result,
"error": self.error,
"metadata": self.metadata,
}
class TaskManager:
"""
任务管理器
线程安全的任务状态管理
"""
_instance = None
_lock = threading.Lock()
def __new__(cls):
"""单例模式"""
if cls._instance is None:
with cls._lock:
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._tasks: Dict[str, Task] = {}
cls._instance._task_lock = threading.Lock()
return cls._instance
def create_task(self, task_type: str, metadata: Optional[Dict] = None) -> str:
"""
创建新任务
Args:
task_type: 任务类型
metadata: 额外元数据
Returns:
任务ID
"""
task_id = str(uuid.uuid4())
now = datetime.now()
task = Task(
task_id=task_id,
task_type=task_type,
status=TaskStatus.PENDING,
created_at=now,
updated_at=now,
metadata=metadata or {}
)
with self._task_lock:
self._tasks[task_id] = task
return task_id
def get_task(self, task_id: str) -> Optional[Task]:
"""获取任务"""
with self._task_lock:
return self._tasks.get(task_id)
def update_task(
self,
task_id: str,
status: Optional[TaskStatus] = None,
progress: Optional[int] = None,
message: Optional[str] = None,
result: Optional[Dict] = None,
error: Optional[str] = None,
progress_detail: Optional[Dict] = None
):
"""
更新任务状态
Args:
task_id: 任务ID
status: 新状态
progress: 进度
message: 消息
result: 结果
error: 错误信息
progress_detail: 详细进度信息
"""
with self._task_lock:
task = self._tasks.get(task_id)
if task:
task.updated_at = datetime.now()
if status is not None:
task.status = status
if progress is not None:
task.progress = progress
if message is not None:
task.message = message
if result is not None:
task.result = result
if error is not None:
task.error = error
if progress_detail is not None:
task.progress_detail = progress_detail
def complete_task(self, task_id: str, result: Dict):
"""标记任务完成"""
self.update_task(
task_id,
status=TaskStatus.COMPLETED,
progress=100,
message=t('progress.taskComplete'),
result=result
)
def fail_task(self, task_id: str, error: str):
"""标记任务失败"""
self.update_task(
task_id,
status=TaskStatus.FAILED,
message=t('progress.taskFailed'),
error=error
)
def list_tasks(self, task_type: Optional[str] = None) -> list:
"""列出任务"""
with self._task_lock:
tasks = list(self._tasks.values())
if task_type:
tasks = [t for t in tasks if t.task_type == task_type]
return [t.to_dict() for t in sorted(tasks, key=lambda x: x.created_at, reverse=True)]
def cleanup_old_tasks(self, max_age_hours: int = 24):
"""清理旧任务"""
from datetime import timedelta
cutoff = datetime.now() - timedelta(hours=max_age_hours)
with self._task_lock:
old_ids = [
tid for tid, task in self._tasks.items()
if task.created_at < cutoff and task.status in [TaskStatus.COMPLETED, TaskStatus.FAILED]
]
for tid in old_ids:
del self._tasks[tid]

View File

@ -0,0 +1,73 @@
"""
业务服务模块
"""
from .ontology_generator import OntologyGenerator
from .graph_builder import GraphBuilderService
from .text_processor import TextProcessor
from .zep_entity_reader import ZepEntityReader, EntityNode, FilteredEntities
from .oasis_profile_generator import OasisProfileGenerator, OasisAgentProfile
from .simulation_manager import SimulationManager, SimulationState, SimulationStatus
from .simulation_config_generator import (
SimulationConfigGenerator,
SimulationParameters,
AgentActivityConfig,
TimeSimulationConfig,
EventConfig,
PlatformConfig
)
from .simulation_runner import (
SimulationRunner,
SimulationRunState,
RunnerStatus,
AgentAction,
RoundSummary
)
from .zep_graph_memory_updater import (
ZepGraphMemoryUpdater,
ZepGraphMemoryManager,
AgentActivity
)
from .simulation_ipc import (
SimulationIPCClient,
SimulationIPCServer,
IPCCommand,
IPCResponse,
CommandType,
CommandStatus
)
__all__ = [
'OntologyGenerator',
'GraphBuilderService',
'TextProcessor',
'ZepEntityReader',
'EntityNode',
'FilteredEntities',
'OasisProfileGenerator',
'OasisAgentProfile',
'SimulationManager',
'SimulationState',
'SimulationStatus',
'SimulationConfigGenerator',
'SimulationParameters',
'AgentActivityConfig',
'TimeSimulationConfig',
'EventConfig',
'PlatformConfig',
'SimulationRunner',
'SimulationRunState',
'RunnerStatus',
'AgentAction',
'RoundSummary',
'ZepGraphMemoryUpdater',
'ZepGraphMemoryManager',
'AgentActivity',
'SimulationIPCClient',
'SimulationIPCServer',
'IPCCommand',
'IPCResponse',
'CommandType',
'CommandStatus',
]

View File

@ -0,0 +1,506 @@
"""
图谱构建服务
接口2使用Zep API构建Standalone Graph
"""
import os
import uuid
import time
import threading
from typing import Dict, Any, List, Optional, Callable
from dataclasses import dataclass
from zep_cloud.client import Zep
from zep_cloud import EpisodeData, EntityEdgeSourceTarget
from ..config import Config
from ..models.task import TaskManager, TaskStatus
from ..utils.zep_paging import fetch_all_nodes, fetch_all_edges
from .text_processor import TextProcessor
from ..utils.locale import t, get_locale, set_locale
@dataclass
class GraphInfo:
"""图谱信息"""
graph_id: str
node_count: int
edge_count: int
entity_types: List[str]
def to_dict(self) -> Dict[str, Any]:
return {
"graph_id": self.graph_id,
"node_count": self.node_count,
"edge_count": self.edge_count,
"entity_types": self.entity_types,
}
class GraphBuilderService:
"""
图谱构建服务
负责调用Zep API构建知识图谱
"""
def __init__(self, api_key: Optional[str] = None):
self.api_key = api_key or Config.ZEP_API_KEY
if not self.api_key:
raise ValueError("ZEP_API_KEY 未配置")
self.client = Zep(api_key=self.api_key)
self.task_manager = TaskManager()
def build_graph_async(
self,
text: str,
ontology: Dict[str, Any],
graph_name: str = "MiroFish Graph",
chunk_size: int = 500,
chunk_overlap: int = 50,
batch_size: int = 3
) -> str:
"""
异步构建图谱
Args:
text: 输入文本
ontology: 本体定义来自接口1的输出
graph_name: 图谱名称
chunk_size: 文本块大小
chunk_overlap: 块重叠大小
batch_size: 每批发送的块数量
Returns:
任务ID
"""
# 创建任务
task_id = self.task_manager.create_task(
task_type="graph_build",
metadata={
"graph_name": graph_name,
"chunk_size": chunk_size,
"text_length": len(text),
}
)
# Capture locale before spawning background thread
current_locale = get_locale()
# 在后台线程中执行构建
thread = threading.Thread(
target=self._build_graph_worker,
args=(task_id, text, ontology, graph_name, chunk_size, chunk_overlap, batch_size, current_locale)
)
thread.daemon = True
thread.start()
return task_id
def _build_graph_worker(
self,
task_id: str,
text: str,
ontology: Dict[str, Any],
graph_name: str,
chunk_size: int,
chunk_overlap: int,
batch_size: int,
locale: str = 'zh'
):
"""图谱构建工作线程"""
set_locale(locale)
try:
self.task_manager.update_task(
task_id,
status=TaskStatus.PROCESSING,
progress=5,
message=t('progress.startBuildingGraph')
)
# 1. 创建图谱
graph_id = self.create_graph(graph_name)
self.task_manager.update_task(
task_id,
progress=10,
message=t('progress.graphCreated', graphId=graph_id)
)
# 2. 设置本体
self.set_ontology(graph_id, ontology)
self.task_manager.update_task(
task_id,
progress=15,
message=t('progress.ontologySet')
)
# 3. 文本分块
chunks = TextProcessor.split_text(text, chunk_size, chunk_overlap)
total_chunks = len(chunks)
self.task_manager.update_task(
task_id,
progress=20,
message=t('progress.textSplit', count=total_chunks)
)
# 4. 分批发送数据
episode_uuids = self.add_text_batches(
graph_id, chunks, batch_size,
lambda msg, prog: self.task_manager.update_task(
task_id,
progress=20 + int(prog * 0.4), # 20-60%
message=msg
)
)
# 5. 等待Zep处理完成
self.task_manager.update_task(
task_id,
progress=60,
message=t('progress.waitingZepProcess')
)
self._wait_for_episodes(
episode_uuids,
lambda msg, prog: self.task_manager.update_task(
task_id,
progress=60 + int(prog * 0.3), # 60-90%
message=msg
)
)
# 6. 获取图谱信息
self.task_manager.update_task(
task_id,
progress=90,
message=t('progress.fetchingGraphInfo')
)
graph_info = self._get_graph_info(graph_id)
# 完成
self.task_manager.complete_task(task_id, {
"graph_id": graph_id,
"graph_info": graph_info.to_dict(),
"chunks_processed": total_chunks,
})
except Exception as e:
import traceback
error_msg = f"{str(e)}\n{traceback.format_exc()}"
self.task_manager.fail_task(task_id, error_msg)
def create_graph(self, name: str) -> str:
"""创建Zep图谱公开方法"""
graph_id = f"mirofish_{uuid.uuid4().hex[:16]}"
self.client.graph.create(
graph_id=graph_id,
name=name,
description="MiroFish Social Simulation Graph"
)
return graph_id
def set_ontology(self, graph_id: str, ontology: Dict[str, Any]):
"""设置图谱本体(公开方法)"""
import warnings
from typing import Optional
from pydantic import Field
from zep_cloud.external_clients.ontology import EntityModel, EntityText, EdgeModel
# 抑制 Pydantic v2 关于 Field(default=None) 的警告
# 这是 Zep SDK 要求的用法,警告来自动态类创建,可以安全忽略
warnings.filterwarnings('ignore', category=UserWarning, module='pydantic')
# Zep 保留名称,不能作为属性名
RESERVED_NAMES = {'uuid', 'name', 'group_id', 'name_embedding', 'summary', 'created_at'}
def safe_attr_name(attr_name: str) -> str:
"""将保留名称转换为安全名称"""
if attr_name.lower() in RESERVED_NAMES:
return f"entity_{attr_name}"
return attr_name
# 动态创建实体类型
entity_types = {}
for entity_def in ontology.get("entity_types", []):
name = entity_def["name"]
description = entity_def.get("description", f"A {name} entity.")
# 创建属性字典和类型注解Pydantic v2 需要)
attrs = {"__doc__": description}
annotations = {}
for attr_def in entity_def.get("attributes", []):
attr_name = safe_attr_name(attr_def["name"]) # 使用安全名称
attr_desc = attr_def.get("description", attr_name)
# Zep API 需要 Field 的 description这是必需的
attrs[attr_name] = Field(description=attr_desc, default=None)
annotations[attr_name] = Optional[EntityText] # 类型注解
attrs["__annotations__"] = annotations
# 动态创建类
entity_class = type(name, (EntityModel,), attrs)
entity_class.__doc__ = description
entity_types[name] = entity_class
# 动态创建边类型
edge_definitions = {}
for edge_def in ontology.get("edge_types", []):
name = edge_def["name"]
description = edge_def.get("description", f"A {name} relationship.")
# 创建属性字典和类型注解
attrs = {"__doc__": description}
annotations = {}
for attr_def in edge_def.get("attributes", []):
attr_name = safe_attr_name(attr_def["name"]) # 使用安全名称
attr_desc = attr_def.get("description", attr_name)
# Zep API 需要 Field 的 description这是必需的
attrs[attr_name] = Field(description=attr_desc, default=None)
annotations[attr_name] = Optional[str] # 边属性用str类型
attrs["__annotations__"] = annotations
# 动态创建类
class_name = ''.join(word.capitalize() for word in name.split('_'))
edge_class = type(class_name, (EdgeModel,), attrs)
edge_class.__doc__ = description
# 构建source_targets
source_targets = []
for st in edge_def.get("source_targets", []):
source_targets.append(
EntityEdgeSourceTarget(
source=st.get("source", "Entity"),
target=st.get("target", "Entity")
)
)
if source_targets:
edge_definitions[name] = (edge_class, source_targets)
# 调用Zep API设置本体
if entity_types or edge_definitions:
self.client.graph.set_ontology(
graph_ids=[graph_id],
entities=entity_types if entity_types else None,
edges=edge_definitions if edge_definitions else None,
)
def add_text_batches(
self,
graph_id: str,
chunks: List[str],
batch_size: int = 3,
progress_callback: Optional[Callable] = None
) -> List[str]:
"""分批添加文本到图谱,返回所有 episode 的 uuid 列表"""
episode_uuids = []
total_chunks = len(chunks)
for i in range(0, total_chunks, batch_size):
batch_chunks = chunks[i:i + batch_size]
batch_num = i // batch_size + 1
total_batches = (total_chunks + batch_size - 1) // batch_size
if progress_callback:
progress = (i + len(batch_chunks)) / total_chunks
progress_callback(
t('progress.sendingBatch', current=batch_num, total=total_batches, chunks=len(batch_chunks)),
progress
)
# 构建episode数据
episodes = [
EpisodeData(data=chunk, type="text")
for chunk in batch_chunks
]
# 发送到Zep
try:
batch_result = self.client.graph.add_batch(
graph_id=graph_id,
episodes=episodes
)
# 收集返回的 episode uuid
if batch_result and isinstance(batch_result, list):
for ep in batch_result:
ep_uuid = getattr(ep, 'uuid_', None) or getattr(ep, 'uuid', None)
if ep_uuid:
episode_uuids.append(ep_uuid)
# 避免请求过快
time.sleep(1)
except Exception as e:
if progress_callback:
progress_callback(t('progress.batchFailed', batch=batch_num, error=str(e)), 0)
raise
return episode_uuids
def _wait_for_episodes(
self,
episode_uuids: List[str],
progress_callback: Optional[Callable] = None,
timeout: int = 600
):
"""等待所有 episode 处理完成(通过查询每个 episode 的 processed 状态)"""
if not episode_uuids:
if progress_callback:
progress_callback(t('progress.noEpisodesWait'), 1.0)
return
start_time = time.time()
pending_episodes = set(episode_uuids)
completed_count = 0
total_episodes = len(episode_uuids)
if progress_callback:
progress_callback(t('progress.waitingEpisodes', count=total_episodes), 0)
while pending_episodes:
if time.time() - start_time > timeout:
if progress_callback:
progress_callback(
t('progress.episodesTimeout', completed=completed_count, total=total_episodes),
completed_count / total_episodes
)
break
# 检查每个 episode 的处理状态
for ep_uuid in list(pending_episodes):
try:
episode = self.client.graph.episode.get(uuid_=ep_uuid)
is_processed = getattr(episode, 'processed', False)
if is_processed:
pending_episodes.remove(ep_uuid)
completed_count += 1
except Exception as e:
# 忽略单个查询错误,继续
pass
elapsed = int(time.time() - start_time)
if progress_callback:
progress_callback(
t('progress.zepProcessing', completed=completed_count, total=total_episodes, pending=len(pending_episodes), elapsed=elapsed),
completed_count / total_episodes if total_episodes > 0 else 0
)
if pending_episodes:
time.sleep(3) # 每3秒检查一次
if progress_callback:
progress_callback(t('progress.processingComplete', completed=completed_count, total=total_episodes), 1.0)
def _get_graph_info(self, graph_id: str) -> GraphInfo:
"""获取图谱信息"""
# 获取节点(分页)
nodes = fetch_all_nodes(self.client, graph_id)
# 获取边(分页)
edges = fetch_all_edges(self.client, graph_id)
# 统计实体类型
entity_types = set()
for node in nodes:
if node.labels:
for label in node.labels:
if label not in ["Entity", "Node"]:
entity_types.add(label)
return GraphInfo(
graph_id=graph_id,
node_count=len(nodes),
edge_count=len(edges),
entity_types=list(entity_types)
)
def get_graph_data(self, graph_id: str) -> Dict[str, Any]:
"""
获取完整图谱数据包含详细信息
Args:
graph_id: 图谱ID
Returns:
包含nodes和edges的字典包括时间信息属性等详细数据
"""
nodes = fetch_all_nodes(self.client, graph_id)
edges = fetch_all_edges(self.client, graph_id)
# 创建节点映射用于获取节点名称
node_map = {}
for node in nodes:
node_map[node.uuid_] = node.name or ""
nodes_data = []
for node in nodes:
# 获取创建时间
created_at = getattr(node, 'created_at', None)
if created_at:
created_at = str(created_at)
nodes_data.append({
"uuid": node.uuid_,
"name": node.name,
"labels": node.labels or [],
"summary": node.summary or "",
"attributes": node.attributes or {},
"created_at": created_at,
})
edges_data = []
for edge in edges:
# 获取时间信息
created_at = getattr(edge, 'created_at', None)
valid_at = getattr(edge, 'valid_at', None)
invalid_at = getattr(edge, 'invalid_at', None)
expired_at = getattr(edge, 'expired_at', None)
# 获取 episodes
episodes = getattr(edge, 'episodes', None) or getattr(edge, 'episode_ids', None)
if episodes and not isinstance(episodes, list):
episodes = [str(episodes)]
elif episodes:
episodes = [str(e) for e in episodes]
# 获取 fact_type
fact_type = getattr(edge, 'fact_type', None) or edge.name or ""
edges_data.append({
"uuid": edge.uuid_,
"name": edge.name or "",
"fact": edge.fact or "",
"fact_type": fact_type,
"source_node_uuid": edge.source_node_uuid,
"target_node_uuid": edge.target_node_uuid,
"source_node_name": node_map.get(edge.source_node_uuid, ""),
"target_node_name": node_map.get(edge.target_node_uuid, ""),
"attributes": edge.attributes or {},
"created_at": str(created_at) if created_at else None,
"valid_at": str(valid_at) if valid_at else None,
"invalid_at": str(invalid_at) if invalid_at else None,
"expired_at": str(expired_at) if expired_at else None,
"episodes": episodes or [],
})
return {
"graph_id": graph_id,
"nodes": nodes_data,
"edges": edges_data,
"node_count": len(nodes_data),
"edge_count": len(edges_data),
}
def delete_graph(self, graph_id: str):
"""删除图谱"""
self.client.graph.delete(graph_id=graph_id)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,506 @@
"""
本体生成服务
接口1分析文本内容生成适合社会模拟的实体和关系类型定义
"""
import json
import logging
import re
from typing import Dict, Any, List, Optional
from ..utils.llm_client import LLMClient
from ..utils.locale import get_language_instruction
logger = logging.getLogger(__name__)
def _to_pascal_case(name: str) -> str:
"""将任意格式的名称转换为 PascalCase'works_for' -> 'WorksFor', 'person' -> 'Person'"""
# 按非字母数字字符分割
parts = re.split(r'[^a-zA-Z0-9]+', name)
# 再按 camelCase 边界分割(如 'camelCase' -> ['camel', 'Case']
words = []
for part in parts:
words.extend(re.sub(r'([a-z])([A-Z])', r'\1_\2', part).split('_'))
# 每个词首字母大写,过滤空串
result = ''.join(word.capitalize() for word in words if word)
return result if result else 'Unknown'
# 本体生成的系统提示词
ONTOLOGY_SYSTEM_PROMPT = """你是一个专业的知识图谱本体设计专家。你的任务是分析给定的文本内容和模拟需求,设计适合**社交媒体舆论模拟**的实体类型和关系类型。
**重要你必须输出有效的JSON格式数据不要输出任何其他内容**
## 核心任务背景
我们正在构建一个**社交媒体舆论模拟系统**在这个系统中
- 每个实体都是一个可以在社交媒体上发声互动传播信息的"账号""主体"
- 实体之间会相互影响转发评论回应
- 我们需要模拟舆论事件中各方的反应和信息传播路径
因此**实体必须是现实中真实存在的可以在社媒上发声和互动的主体**
**可以是**
- 具体的个人公众人物当事人意见领袖专家学者普通人
- 公司企业包括其官方账号
- 组织机构大学协会NGO工会等
- 政府部门监管机构
- 媒体机构报纸电视台自媒体网站
- 社交媒体平台本身
- 特定群体代表如校友会粉丝团维权群体等
**不可以是**
- 抽象概念"舆论""情绪""趋势"
- 主题/话题"学术诚信""教育改革"
- 观点/态度"支持方""反对方"
## 输出格式
请输出JSON格式包含以下结构
```json
{
"entity_types": [
{
"name": "实体类型名称英文PascalCase",
"description": "简短描述英文不超过100字符",
"attributes": [
{
"name": "属性名英文snake_case",
"type": "text",
"description": "属性描述"
}
],
"examples": ["示例实体1", "示例实体2"]
}
],
"edge_types": [
{
"name": "关系类型名称英文UPPER_SNAKE_CASE",
"description": "简短描述英文不超过100字符",
"source_targets": [
{"source": "源实体类型", "target": "目标实体类型"}
],
"attributes": []
}
],
"analysis_summary": "对文本内容的简要分析说明"
}
```
## 设计指南(极其重要!)
### 1. 实体类型设计 - 必须严格遵守
**数量要求必须正好10个实体类型**
**层次结构要求必须同时包含具体类型和兜底类型**
你的10个实体类型必须包含以下层次
A. **兜底类型必须包含放在列表最后2个**
- `Person`: 任何自然人个体的兜底类型当一个人不属于其他更具体的人物类型时归入此类
- `Organization`: 任何组织机构的兜底类型当一个组织不属于其他更具体的组织类型时归入此类
B. **具体类型8根据文本内容设计**
- 针对文本中出现的主要角色设计更具体的类型
- 例如如果文本涉及学术事件可以有 `Student`, `Professor`, `University`
- 例如如果文本涉及商业事件可以有 `Company`, `CEO`, `Employee`
**为什么需要兜底类型**
- 文本中会出现各种人物"中小学教师""路人甲""某位网友"
- 如果没有专门的类型匹配他们应该被归入 `Person`
- 同理小型组织临时团体等应该归入 `Organization`
**具体类型的设计原则**
- 从文本中识别出高频出现或关键的角色类型
- 每个具体类型应该有明确的边界避免重叠
- description 必须清晰说明这个类型和兜底类型的区别
### 2. 关系类型设计
- 数量6-10
- 关系应该反映社媒互动中的真实联系
- 确保关系的 source_targets 涵盖你定义的实体类型
### 3. 属性设计
- 每个实体类型1-3个关键属性
- **注意**属性名不能使用 `name``uuid``group_id``created_at``summary`这些是系统保留字
- 推荐使用`full_name`, `title`, `role`, `position`, `location`, `description`
## 实体类型参考
**个人类具体**
- Student: 学生
- Professor: 教授/学者
- Journalist: 记者
- Celebrity: 明星/网红
- Executive: 高管
- Official: 政府官员
- Lawyer: 律师
- Doctor: 医生
**个人类兜底**
- Person: 任何自然人不属于上述具体类型时使用
**组织类具体**
- University: 高校
- Company: 公司企业
- GovernmentAgency: 政府机构
- MediaOutlet: 媒体机构
- Hospital: 医院
- School: 中小学
- NGO: 非政府组织
**组织类兜底**
- Organization: 任何组织机构不属于上述具体类型时使用
## 关系类型参考
- WORKS_FOR: 工作于
- STUDIES_AT: 就读于
- AFFILIATED_WITH: 隶属于
- REPRESENTS: 代表
- REGULATES: 监管
- REPORTS_ON: 报道
- COMMENTS_ON: 评论
- RESPONDS_TO: 回应
- SUPPORTS: 支持
- OPPOSES: 反对
- COLLABORATES_WITH: 合作
- COMPETES_WITH: 竞争
"""
class OntologyGenerator:
"""
本体生成器
分析文本内容生成实体和关系类型定义
"""
def __init__(self, llm_client: Optional[LLMClient] = None):
self.llm_client = llm_client or LLMClient()
def generate(
self,
document_texts: List[str],
simulation_requirement: str,
additional_context: Optional[str] = None
) -> Dict[str, Any]:
"""
生成本体定义
Args:
document_texts: 文档文本列表
simulation_requirement: 模拟需求描述
additional_context: 额外上下文
Returns:
本体定义entity_types, edge_types等
"""
# 构建用户消息
user_message = self._build_user_message(
document_texts,
simulation_requirement,
additional_context
)
lang_instruction = get_language_instruction()
system_prompt = f"{ONTOLOGY_SYSTEM_PROMPT}\n\n{lang_instruction}\nIMPORTANT: Entity type names MUST be in English PascalCase (e.g., 'PersonEntity', 'MediaOrganization'). Relationship type names MUST be in English UPPER_SNAKE_CASE (e.g., 'WORKS_FOR'). Attribute names MUST be in English snake_case. Only description fields and analysis_summary should use the specified language above."
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_message}
]
# 调用LLM
result = self.llm_client.chat_json(
messages=messages,
temperature=0.3,
max_tokens=4096
)
# 验证和后处理
result = self._validate_and_process(result)
return result
# 传给 LLM 的文本最大长度5万字
MAX_TEXT_LENGTH_FOR_LLM = 50000
def _build_user_message(
self,
document_texts: List[str],
simulation_requirement: str,
additional_context: Optional[str]
) -> str:
"""构建用户消息"""
# 合并文本
combined_text = "\n\n---\n\n".join(document_texts)
original_length = len(combined_text)
# 如果文本超过5万字截断仅影响传给LLM的内容不影响图谱构建
if len(combined_text) > self.MAX_TEXT_LENGTH_FOR_LLM:
combined_text = combined_text[:self.MAX_TEXT_LENGTH_FOR_LLM]
combined_text += f"\n\n...(原文共{original_length}字,已截取前{self.MAX_TEXT_LENGTH_FOR_LLM}字用于本体分析)..."
message = f"""## 模拟需求
{simulation_requirement}
## 文档内容
{combined_text}
"""
if additional_context:
message += f"""
## 额外说明
{additional_context}
"""
message += """
请根据以上内容设计适合社会舆论模拟的实体类型和关系类型
**必须遵守的规则**
1. 必须正好输出10个实体类型
2. 最后2个必须是兜底类型Person个人兜底 Organization组织兜底
3. 前8个是根据文本内容设计的具体类型
4. 所有实体类型必须是现实中可以发声的主体不能是抽象概念
5. 属性名不能使用 nameuuidgroup_id 等保留字 full_nameorg_name 等替代
"""
return message
def _validate_and_process(self, result: Dict[str, Any]) -> Dict[str, Any]:
"""验证和后处理结果"""
# 确保必要字段存在
if "entity_types" not in result:
result["entity_types"] = []
if "edge_types" not in result:
result["edge_types"] = []
if "analysis_summary" not in result:
result["analysis_summary"] = ""
# 验证实体类型
# 记录原始名称到 PascalCase 的映射,用于后续修正 edge 的 source_targets 引用
entity_name_map = {}
for entity in result["entity_types"]:
# 强制将 entity name 转为 PascalCaseZep API 要求)
if "name" in entity:
original_name = entity["name"]
entity["name"] = _to_pascal_case(original_name)
if entity["name"] != original_name:
logger.warning(f"Entity type name '{original_name}' auto-converted to '{entity['name']}'")
entity_name_map[original_name] = entity["name"]
if "attributes" not in entity:
entity["attributes"] = []
if "examples" not in entity:
entity["examples"] = []
# 确保description不超过100字符
if len(entity.get("description", "")) > 100:
entity["description"] = entity["description"][:97] + "..."
# 验证关系类型
for edge in result["edge_types"]:
# 强制将 edge name 转为 SCREAMING_SNAKE_CASEZep API 要求)
if "name" in edge:
original_name = edge["name"]
edge["name"] = original_name.upper()
if edge["name"] != original_name:
logger.warning(f"Edge type name '{original_name}' auto-converted to '{edge['name']}'")
# 修正 source_targets 中的实体名称引用,与转换后的 PascalCase 保持一致
for st in edge.get("source_targets", []):
if st.get("source") in entity_name_map:
st["source"] = entity_name_map[st["source"]]
if st.get("target") in entity_name_map:
st["target"] = entity_name_map[st["target"]]
if "source_targets" not in edge:
edge["source_targets"] = []
if "attributes" not in edge:
edge["attributes"] = []
if len(edge.get("description", "")) > 100:
edge["description"] = edge["description"][:97] + "..."
# Zep API 限制:最多 10 个自定义实体类型,最多 10 个自定义边类型
MAX_ENTITY_TYPES = 10
MAX_EDGE_TYPES = 10
# 去重:按 name 去重,保留首次出现的
seen_names = set()
deduped = []
for entity in result["entity_types"]:
name = entity.get("name", "")
if name and name not in seen_names:
seen_names.add(name)
deduped.append(entity)
elif name in seen_names:
logger.warning(f"Duplicate entity type '{name}' removed during validation")
result["entity_types"] = deduped
# 兜底类型定义
person_fallback = {
"name": "Person",
"description": "Any individual person not fitting other specific person types.",
"attributes": [
{"name": "full_name", "type": "text", "description": "Full name of the person"},
{"name": "role", "type": "text", "description": "Role or occupation"}
],
"examples": ["ordinary citizen", "anonymous netizen"]
}
organization_fallback = {
"name": "Organization",
"description": "Any organization not fitting other specific organization types.",
"attributes": [
{"name": "org_name", "type": "text", "description": "Name of the organization"},
{"name": "org_type", "type": "text", "description": "Type of organization"}
],
"examples": ["small business", "community group"]
}
# 检查是否已有兜底类型
entity_names = {e["name"] for e in result["entity_types"]}
has_person = "Person" in entity_names
has_organization = "Organization" in entity_names
# 需要添加的兜底类型
fallbacks_to_add = []
if not has_person:
fallbacks_to_add.append(person_fallback)
if not has_organization:
fallbacks_to_add.append(organization_fallback)
if fallbacks_to_add:
current_count = len(result["entity_types"])
needed_slots = len(fallbacks_to_add)
# 如果添加后会超过 10 个,需要移除一些现有类型
if current_count + needed_slots > MAX_ENTITY_TYPES:
# 计算需要移除多少个
to_remove = current_count + needed_slots - MAX_ENTITY_TYPES
# 从末尾移除(保留前面更重要的具体类型)
result["entity_types"] = result["entity_types"][:-to_remove]
# 添加兜底类型
result["entity_types"].extend(fallbacks_to_add)
# 最终确保不超过限制(防御性编程)
if len(result["entity_types"]) > MAX_ENTITY_TYPES:
result["entity_types"] = result["entity_types"][:MAX_ENTITY_TYPES]
if len(result["edge_types"]) > MAX_EDGE_TYPES:
result["edge_types"] = result["edge_types"][:MAX_EDGE_TYPES]
return result
def generate_python_code(self, ontology: Dict[str, Any]) -> str:
"""
将本体定义转换为Python代码类似ontology.py
Args:
ontology: 本体定义
Returns:
Python代码字符串
"""
code_lines = [
'"""',
'自定义实体类型定义',
'由MiroFish自动生成用于社会舆论模拟',
'"""',
'',
'from pydantic import Field',
'from zep_cloud.external_clients.ontology import EntityModel, EntityText, EdgeModel',
'',
'',
'# ============== 实体类型定义 ==============',
'',
]
# 生成实体类型
for entity in ontology.get("entity_types", []):
name = entity["name"]
desc = entity.get("description", f"A {name} entity.")
code_lines.append(f'class {name}(EntityModel):')
code_lines.append(f' """{desc}"""')
attrs = entity.get("attributes", [])
if attrs:
for attr in attrs:
attr_name = attr["name"]
attr_desc = attr.get("description", attr_name)
code_lines.append(f' {attr_name}: EntityText = Field(')
code_lines.append(f' description="{attr_desc}",')
code_lines.append(f' default=None')
code_lines.append(f' )')
else:
code_lines.append(' pass')
code_lines.append('')
code_lines.append('')
code_lines.append('# ============== 关系类型定义 ==============')
code_lines.append('')
# 生成关系类型
for edge in ontology.get("edge_types", []):
name = edge["name"]
# 转换为PascalCase类名
class_name = ''.join(word.capitalize() for word in name.split('_'))
desc = edge.get("description", f"A {name} relationship.")
code_lines.append(f'class {class_name}(EdgeModel):')
code_lines.append(f' """{desc}"""')
attrs = edge.get("attributes", [])
if attrs:
for attr in attrs:
attr_name = attr["name"]
attr_desc = attr.get("description", attr_name)
code_lines.append(f' {attr_name}: EntityText = Field(')
code_lines.append(f' description="{attr_desc}",')
code_lines.append(f' default=None')
code_lines.append(f' )')
else:
code_lines.append(' pass')
code_lines.append('')
code_lines.append('')
# 生成类型字典
code_lines.append('# ============== 类型配置 ==============')
code_lines.append('')
code_lines.append('ENTITY_TYPES = {')
for entity in ontology.get("entity_types", []):
name = entity["name"]
code_lines.append(f' "{name}": {name},')
code_lines.append('}')
code_lines.append('')
code_lines.append('EDGE_TYPES = {')
for edge in ontology.get("edge_types", []):
name = edge["name"]
class_name = ''.join(word.capitalize() for word in name.split('_'))
code_lines.append(f' "{name}": {class_name},')
code_lines.append('}')
code_lines.append('')
# 生成边的source_targets映射
code_lines.append('EDGE_SOURCE_TARGETS = {')
for edge in ontology.get("edge_types", []):
name = edge["name"]
source_targets = edge.get("source_targets", [])
if source_targets:
st_list = ', '.join([
f'{{"source": "{st.get("source", "Entity")}", "target": "{st.get("target", "Entity")}"}}'
for st in source_targets
])
code_lines.append(f' "{name}": [{st_list}],')
code_lines.append('}')
return '\n'.join(code_lines)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,991 @@
"""
模拟配置智能生成器
使用LLM根据模拟需求文档内容图谱信息自动生成细致的模拟参数
实现全程自动化无需人工设置参数
采用分步生成策略避免一次性生成过长内容导致失败
1. 生成时间配置
2. 生成事件配置
3. 分批生成Agent配置
4. 生成平台配置
"""
import json
import math
from typing import Dict, Any, List, Optional, Callable
from dataclasses import dataclass, field, asdict
from datetime import datetime
from openai import OpenAI
from ..config import Config
from ..utils.logger import get_logger
from ..utils.locale import get_language_instruction, t
from .zep_entity_reader import EntityNode, ZepEntityReader
logger = get_logger('mirofish.simulation_config')
# 中国作息时间配置(北京时间)
CHINA_TIMEZONE_CONFIG = {
# 深夜时段(几乎无人活动)
"dead_hours": [0, 1, 2, 3, 4, 5],
# 早间时段(逐渐醒来)
"morning_hours": [6, 7, 8],
# 工作时段
"work_hours": [9, 10, 11, 12, 13, 14, 15, 16, 17, 18],
# 晚间高峰(最活跃)
"peak_hours": [19, 20, 21, 22],
# 夜间时段(活跃度下降)
"night_hours": [23],
# 活跃度系数
"activity_multipliers": {
"dead": 0.05, # 凌晨几乎无人
"morning": 0.4, # 早间逐渐活跃
"work": 0.7, # 工作时段中等
"peak": 1.5, # 晚间高峰
"night": 0.5 # 深夜下降
}
}
@dataclass
class AgentActivityConfig:
"""单个Agent的活动配置"""
agent_id: int
entity_uuid: str
entity_name: str
entity_type: str
# 活跃度配置 (0.0-1.0)
activity_level: float = 0.5 # 整体活跃度
# 发言频率(每小时预期发言次数)
posts_per_hour: float = 1.0
comments_per_hour: float = 2.0
# 活跃时间段24小时制0-23
active_hours: List[int] = field(default_factory=lambda: list(range(8, 23)))
# 响应速度(对热点事件的反应延迟,单位:模拟分钟)
response_delay_min: int = 5
response_delay_max: int = 60
# 情感倾向 (-1.0到1.0,负面到正面)
sentiment_bias: float = 0.0
# 立场(对特定话题的态度)
stance: str = "neutral" # supportive, opposing, neutral, observer
# 影响力权重决定其发言被其他Agent看到的概率
influence_weight: float = 1.0
@dataclass
class TimeSimulationConfig:
"""时间模拟配置(基于中国人作息习惯)"""
# 模拟总时长(模拟小时数)
total_simulation_hours: int = 72 # 默认模拟72小时3天
# 每轮代表的时间(模拟分钟)- 默认60分钟1小时加快时间流速
minutes_per_round: int = 60
# 每小时激活的Agent数量范围
agents_per_hour_min: int = 5
agents_per_hour_max: int = 20
# 高峰时段晚间19-22点中国人最活跃的时间
peak_hours: List[int] = field(default_factory=lambda: [19, 20, 21, 22])
peak_activity_multiplier: float = 1.5
# 低谷时段凌晨0-5点几乎无人活动
off_peak_hours: List[int] = field(default_factory=lambda: [0, 1, 2, 3, 4, 5])
off_peak_activity_multiplier: float = 0.05 # 凌晨活跃度极低
# 早间时段
morning_hours: List[int] = field(default_factory=lambda: [6, 7, 8])
morning_activity_multiplier: float = 0.4
# 工作时段
work_hours: List[int] = field(default_factory=lambda: [9, 10, 11, 12, 13, 14, 15, 16, 17, 18])
work_activity_multiplier: float = 0.7
@dataclass
class EventConfig:
"""事件配置"""
# 初始事件(模拟开始时的触发事件)
initial_posts: List[Dict[str, Any]] = field(default_factory=list)
# 定时事件(在特定时间触发的事件)
scheduled_events: List[Dict[str, Any]] = field(default_factory=list)
# 热点话题关键词
hot_topics: List[str] = field(default_factory=list)
# 舆论引导方向
narrative_direction: str = ""
@dataclass
class PlatformConfig:
"""平台特定配置"""
platform: str # twitter or reddit
# 推荐算法权重
recency_weight: float = 0.4 # 时间新鲜度
popularity_weight: float = 0.3 # 热度
relevance_weight: float = 0.3 # 相关性
# 病毒传播阈值(达到多少互动后触发扩散)
viral_threshold: int = 10
# 回声室效应强度(相似观点聚集程度)
echo_chamber_strength: float = 0.5
@dataclass
class SimulationParameters:
"""完整的模拟参数配置"""
# 基础信息
simulation_id: str
project_id: str
graph_id: str
simulation_requirement: str
# 时间配置
time_config: TimeSimulationConfig = field(default_factory=TimeSimulationConfig)
# Agent配置列表
agent_configs: List[AgentActivityConfig] = field(default_factory=list)
# 事件配置
event_config: EventConfig = field(default_factory=EventConfig)
# 平台配置
twitter_config: Optional[PlatformConfig] = None
reddit_config: Optional[PlatformConfig] = None
# LLM配置
llm_model: str = ""
llm_base_url: str = ""
# 生成元数据
generated_at: str = field(default_factory=lambda: datetime.now().isoformat())
generation_reasoning: str = "" # LLM的推理说明
def to_dict(self) -> Dict[str, Any]:
"""转换为字典"""
time_dict = asdict(self.time_config)
return {
"simulation_id": self.simulation_id,
"project_id": self.project_id,
"graph_id": self.graph_id,
"simulation_requirement": self.simulation_requirement,
"time_config": time_dict,
"agent_configs": [asdict(a) for a in self.agent_configs],
"event_config": asdict(self.event_config),
"twitter_config": asdict(self.twitter_config) if self.twitter_config else None,
"reddit_config": asdict(self.reddit_config) if self.reddit_config else None,
"llm_model": self.llm_model,
"llm_base_url": self.llm_base_url,
"generated_at": self.generated_at,
"generation_reasoning": self.generation_reasoning,
}
def to_json(self, indent: int = 2) -> str:
"""转换为JSON字符串"""
return json.dumps(self.to_dict(), ensure_ascii=False, indent=indent)
class SimulationConfigGenerator:
"""
模拟配置智能生成器
使用LLM分析模拟需求文档内容图谱实体信息
自动生成最佳的模拟参数配置
采用分步生成策略
1. 生成时间配置和事件配置轻量级
2. 分批生成Agent配置每批10-20
3. 生成平台配置
"""
# 上下文最大字符数
MAX_CONTEXT_LENGTH = 50000
# 每批生成的Agent数量
AGENTS_PER_BATCH = 15
# 各步骤的上下文截断长度(字符数)
TIME_CONFIG_CONTEXT_LENGTH = 10000 # 时间配置
EVENT_CONFIG_CONTEXT_LENGTH = 8000 # 事件配置
ENTITY_SUMMARY_LENGTH = 300 # 实体摘要
AGENT_SUMMARY_LENGTH = 300 # Agent配置中的实体摘要
ENTITIES_PER_TYPE_DISPLAY = 20 # 每类实体显示数量
def __init__(
self,
api_key: Optional[str] = None,
base_url: Optional[str] = None,
model_name: Optional[str] = None
):
self.api_key = api_key or Config.LLM_API_KEY
self.base_url = base_url or Config.LLM_BASE_URL
self.model_name = model_name or Config.LLM_MODEL_NAME
if not self.api_key:
raise ValueError("LLM_API_KEY 未配置")
self.client = OpenAI(
api_key=self.api_key,
base_url=self.base_url
)
def generate_config(
self,
simulation_id: str,
project_id: str,
graph_id: str,
simulation_requirement: str,
document_text: str,
entities: List[EntityNode],
enable_twitter: bool = True,
enable_reddit: bool = True,
progress_callback: Optional[Callable[[int, int, str], None]] = None,
) -> SimulationParameters:
"""
智能生成完整的模拟配置分步生成
Args:
simulation_id: 模拟ID
project_id: 项目ID
graph_id: 图谱ID
simulation_requirement: 模拟需求描述
document_text: 原始文档内容
entities: 过滤后的实体列表
enable_twitter: 是否启用Twitter
enable_reddit: 是否启用Reddit
progress_callback: 进度回调函数(current_step, total_steps, message)
Returns:
SimulationParameters: 完整的模拟参数
"""
logger.info(f"开始智能生成模拟配置: simulation_id={simulation_id}, 实体数={len(entities)}")
# 计算总步骤数
num_batches = math.ceil(len(entities) / self.AGENTS_PER_BATCH)
total_steps = 3 + num_batches # 时间配置 + 事件配置 + N批Agent + 平台配置
current_step = 0
def report_progress(step: int, message: str):
nonlocal current_step
current_step = step
if progress_callback:
progress_callback(step, total_steps, message)
logger.info(f"[{step}/{total_steps}] {message}")
# 1. 构建基础上下文信息
context = self._build_context(
simulation_requirement=simulation_requirement,
document_text=document_text,
entities=entities
)
reasoning_parts = []
# ========== 步骤1: 生成时间配置 ==========
report_progress(1, t('progress.generatingTimeConfig'))
num_entities = len(entities)
time_config_result = self._generate_time_config(context, num_entities)
time_config = self._parse_time_config(time_config_result, num_entities)
reasoning_parts.append(f"{t('progress.timeConfigLabel')}: {time_config_result.get('reasoning', t('common.success'))}")
# ========== 步骤2: 生成事件配置 ==========
report_progress(2, t('progress.generatingEventConfig'))
event_config_result = self._generate_event_config(context, simulation_requirement, entities)
event_config = self._parse_event_config(event_config_result)
reasoning_parts.append(f"{t('progress.eventConfigLabel')}: {event_config_result.get('reasoning', t('common.success'))}")
# ========== 步骤3-N: 分批生成Agent配置 ==========
all_agent_configs = []
for batch_idx in range(num_batches):
start_idx = batch_idx * self.AGENTS_PER_BATCH
end_idx = min(start_idx + self.AGENTS_PER_BATCH, len(entities))
batch_entities = entities[start_idx:end_idx]
report_progress(
3 + batch_idx,
t('progress.generatingAgentConfig', start=start_idx + 1, end=end_idx, total=len(entities))
)
batch_configs = self._generate_agent_configs_batch(
context=context,
entities=batch_entities,
start_idx=start_idx,
simulation_requirement=simulation_requirement
)
all_agent_configs.extend(batch_configs)
reasoning_parts.append(t('progress.agentConfigResult', count=len(all_agent_configs)))
# ========== 为初始帖子分配发布者 Agent ==========
logger.info("为初始帖子分配合适的发布者 Agent...")
event_config = self._assign_initial_post_agents(event_config, all_agent_configs)
assigned_count = len([p for p in event_config.initial_posts if p.get("poster_agent_id") is not None])
reasoning_parts.append(t('progress.postAssignResult', count=assigned_count))
# ========== 最后一步: 生成平台配置 ==========
report_progress(total_steps, t('progress.generatingPlatformConfig'))
twitter_config = None
reddit_config = None
if enable_twitter:
twitter_config = PlatformConfig(
platform="twitter",
recency_weight=0.4,
popularity_weight=0.3,
relevance_weight=0.3,
viral_threshold=10,
echo_chamber_strength=0.5
)
if enable_reddit:
reddit_config = PlatformConfig(
platform="reddit",
recency_weight=0.3,
popularity_weight=0.4,
relevance_weight=0.3,
viral_threshold=15,
echo_chamber_strength=0.6
)
# 构建最终参数
params = SimulationParameters(
simulation_id=simulation_id,
project_id=project_id,
graph_id=graph_id,
simulation_requirement=simulation_requirement,
time_config=time_config,
agent_configs=all_agent_configs,
event_config=event_config,
twitter_config=twitter_config,
reddit_config=reddit_config,
llm_model=self.model_name,
llm_base_url=self.base_url,
generation_reasoning=" | ".join(reasoning_parts)
)
logger.info(f"模拟配置生成完成: {len(params.agent_configs)} 个Agent配置")
return params
def _build_context(
self,
simulation_requirement: str,
document_text: str,
entities: List[EntityNode]
) -> str:
"""构建LLM上下文截断到最大长度"""
# 实体摘要
entity_summary = self._summarize_entities(entities)
# 构建上下文
context_parts = [
f"## 模拟需求\n{simulation_requirement}",
f"\n## 实体信息 ({len(entities)}个)\n{entity_summary}",
]
current_length = sum(len(p) for p in context_parts)
remaining_length = self.MAX_CONTEXT_LENGTH - current_length - 500 # 留500字符余量
if remaining_length > 0 and document_text:
doc_text = document_text[:remaining_length]
if len(document_text) > remaining_length:
doc_text += "\n...(文档已截断)"
context_parts.append(f"\n## 原始文档内容\n{doc_text}")
return "\n".join(context_parts)
def _summarize_entities(self, entities: List[EntityNode]) -> str:
"""生成实体摘要"""
lines = []
# 按类型分组
by_type: Dict[str, List[EntityNode]] = {}
for e in entities:
t = e.get_entity_type() or "Unknown"
if t not in by_type:
by_type[t] = []
by_type[t].append(e)
for entity_type, type_entities in by_type.items():
lines.append(f"\n### {entity_type} ({len(type_entities)}个)")
# 使用配置的显示数量和摘要长度
display_count = self.ENTITIES_PER_TYPE_DISPLAY
summary_len = self.ENTITY_SUMMARY_LENGTH
for e in type_entities[:display_count]:
summary_preview = (e.summary[:summary_len] + "...") if len(e.summary) > summary_len else e.summary
lines.append(f"- {e.name}: {summary_preview}")
if len(type_entities) > display_count:
lines.append(f" ... 还有 {len(type_entities) - display_count}")
return "\n".join(lines)
def _call_llm_with_retry(self, prompt: str, system_prompt: str) -> Dict[str, Any]:
"""带重试的LLM调用包含JSON修复逻辑"""
import re
max_attempts = 3
last_error = None
for attempt in range(max_attempts):
try:
response = self.client.chat.completions.create(
model=self.model_name,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt}
],
response_format={"type": "json_object"},
temperature=0.7 - (attempt * 0.1) # 每次重试降低温度
# 不设置max_tokens让LLM自由发挥
)
content = response.choices[0].message.content
finish_reason = response.choices[0].finish_reason
# 检查是否被截断
if finish_reason == 'length':
logger.warning(f"LLM输出被截断 (attempt {attempt+1})")
content = self._fix_truncated_json(content)
# 尝试解析JSON
try:
return json.loads(content)
except json.JSONDecodeError as e:
logger.warning(f"JSON解析失败 (attempt {attempt+1}): {str(e)[:80]}")
# 尝试修复JSON
fixed = self._try_fix_config_json(content)
if fixed:
return fixed
last_error = e
except Exception as e:
logger.warning(f"LLM调用失败 (attempt {attempt+1}): {str(e)[:80]}")
last_error = e
import time
time.sleep(2 * (attempt + 1))
raise last_error or Exception("LLM调用失败")
def _fix_truncated_json(self, content: str) -> str:
"""修复被截断的JSON"""
content = content.strip()
# 计算未闭合的括号
open_braces = content.count('{') - content.count('}')
open_brackets = content.count('[') - content.count(']')
# 检查是否有未闭合的字符串
if content and content[-1] not in '",}]':
content += '"'
# 闭合括号
content += ']' * open_brackets
content += '}' * open_braces
return content
def _try_fix_config_json(self, content: str) -> Optional[Dict[str, Any]]:
"""尝试修复配置JSON"""
import re
# 修复被截断的情况
content = self._fix_truncated_json(content)
# 提取JSON部分
json_match = re.search(r'\{[\s\S]*\}', content)
if json_match:
json_str = json_match.group()
# 移除字符串中的换行符
def fix_string(match):
s = match.group(0)
s = s.replace('\n', ' ').replace('\r', ' ')
s = re.sub(r'\s+', ' ', s)
return s
json_str = re.sub(r'"[^"\\]*(?:\\.[^"\\]*)*"', fix_string, json_str)
try:
return json.loads(json_str)
except:
# 尝试移除所有控制字符
json_str = re.sub(r'[\x00-\x1f\x7f-\x9f]', ' ', json_str)
json_str = re.sub(r'\s+', ' ', json_str)
try:
return json.loads(json_str)
except:
pass
return None
def _generate_time_config(self, context: str, num_entities: int) -> Dict[str, Any]:
"""生成时间配置"""
# 使用配置的上下文截断长度
context_truncated = context[:self.TIME_CONFIG_CONTEXT_LENGTH]
# 计算最大允许值80%的agent数
max_agents_allowed = max(1, int(num_entities * 0.9))
prompt = f"""基于以下模拟需求,生成时间模拟配置。
{context_truncated}
## 任务
请生成时间配置JSON
### 基本原则(仅供参考,需根据具体事件和参与群体灵活调整):
- 请根据模拟场景推断目标用户群体所在时区和作息习惯以下为东八区(UTC+8)的参考示例
- 凌晨0-5点几乎无人活动活跃度系数0.05
- 早上6-8点逐渐活跃活跃度系数0.4
- 工作时间9-18点中等活跃活跃度系数0.7
- 晚间19-22点是高峰期活跃度系数1.5
- 23点后活跃度下降活跃度系数0.5
- 一般规律凌晨低活跃早间渐增工作时段中等晚间高峰
- **重要**以下示例值仅供参考你需要根据事件性质参与群体特点来调整具体时段
- 例如学生群体高峰可能是21-23媒体全天活跃官方机构只在工作时间
- 例如突发热点可能导致深夜也有讨论off_peak_hours 可适当缩短
### 返回JSON格式不要markdown
示例
{{
"total_simulation_hours": 72,
"minutes_per_round": 60,
"agents_per_hour_min": 5,
"agents_per_hour_max": 50,
"peak_hours": [19, 20, 21, 22],
"off_peak_hours": [0, 1, 2, 3, 4, 5],
"morning_hours": [6, 7, 8],
"work_hours": [9, 10, 11, 12, 13, 14, 15, 16, 17, 18],
"reasoning": "针对该事件的时间配置说明"
}}
字段说明
- total_simulation_hours (int): 模拟总时长24-168小时突发事件短持续话题长
- minutes_per_round (int): 每轮时长30-120分钟建议60分钟
- agents_per_hour_min (int): 每小时最少激活Agent数取值范围: 1-{max_agents_allowed}
- agents_per_hour_max (int): 每小时最多激活Agent数取值范围: 1-{max_agents_allowed}
- peak_hours (int数组): 高峰时段根据事件参与群体调整
- off_peak_hours (int数组): 低谷时段通常深夜凌晨
- morning_hours (int数组): 早间时段
- work_hours (int数组): 工作时段
- reasoning (string): 简要说明为什么这样配置"""
system_prompt = "你是社交媒体模拟专家。返回纯JSON格式时间配置需符合模拟场景中目标用户群体的作息习惯。"
system_prompt = f"{system_prompt}\n\n{get_language_instruction()}"
try:
return self._call_llm_with_retry(prompt, system_prompt)
except Exception as e:
logger.warning(f"时间配置LLM生成失败: {e}, 使用默认配置")
return self._get_default_time_config(num_entities)
def _get_default_time_config(self, num_entities: int) -> Dict[str, Any]:
"""获取默认时间配置(中国人作息)"""
return {
"total_simulation_hours": 72,
"minutes_per_round": 60, # 每轮1小时加快时间流速
"agents_per_hour_min": max(1, num_entities // 15),
"agents_per_hour_max": max(5, num_entities // 5),
"peak_hours": [19, 20, 21, 22],
"off_peak_hours": [0, 1, 2, 3, 4, 5],
"morning_hours": [6, 7, 8],
"work_hours": [9, 10, 11, 12, 13, 14, 15, 16, 17, 18],
"reasoning": "使用默认中国人作息配置每轮1小时"
}
def _parse_time_config(self, result: Dict[str, Any], num_entities: int) -> TimeSimulationConfig:
"""解析时间配置结果并验证agents_per_hour值不超过总agent数"""
# 获取原始值
agents_per_hour_min = result.get("agents_per_hour_min", max(1, num_entities // 15))
agents_per_hour_max = result.get("agents_per_hour_max", max(5, num_entities // 5))
# 验证并修正确保不超过总agent数
if agents_per_hour_min > num_entities:
logger.warning(f"agents_per_hour_min ({agents_per_hour_min}) 超过总Agent数 ({num_entities}),已修正")
agents_per_hour_min = max(1, num_entities // 10)
if agents_per_hour_max > num_entities:
logger.warning(f"agents_per_hour_max ({agents_per_hour_max}) 超过总Agent数 ({num_entities}),已修正")
agents_per_hour_max = max(agents_per_hour_min + 1, num_entities // 2)
# 确保 min < max
if agents_per_hour_min >= agents_per_hour_max:
agents_per_hour_min = max(1, agents_per_hour_max // 2)
logger.warning(f"agents_per_hour_min >= max已修正为 {agents_per_hour_min}")
return TimeSimulationConfig(
total_simulation_hours=result.get("total_simulation_hours", 72),
minutes_per_round=result.get("minutes_per_round", 60), # 默认每轮1小时
agents_per_hour_min=agents_per_hour_min,
agents_per_hour_max=agents_per_hour_max,
peak_hours=result.get("peak_hours", [19, 20, 21, 22]),
off_peak_hours=result.get("off_peak_hours", [0, 1, 2, 3, 4, 5]),
off_peak_activity_multiplier=0.05, # 凌晨几乎无人
morning_hours=result.get("morning_hours", [6, 7, 8]),
morning_activity_multiplier=0.4,
work_hours=result.get("work_hours", list(range(9, 19))),
work_activity_multiplier=0.7,
peak_activity_multiplier=1.5
)
def _generate_event_config(
self,
context: str,
simulation_requirement: str,
entities: List[EntityNode]
) -> Dict[str, Any]:
"""生成事件配置"""
# 获取可用的实体类型列表,供 LLM 参考
entity_types_available = list(set(
e.get_entity_type() or "Unknown" for e in entities
))
# 为每种类型列出代表性实体名称
type_examples = {}
for e in entities:
etype = e.get_entity_type() or "Unknown"
if etype not in type_examples:
type_examples[etype] = []
if len(type_examples[etype]) < 3:
type_examples[etype].append(e.name)
type_info = "\n".join([
f"- {t}: {', '.join(examples)}"
for t, examples in type_examples.items()
])
# 使用配置的上下文截断长度
context_truncated = context[:self.EVENT_CONFIG_CONTEXT_LENGTH]
prompt = f"""基于以下模拟需求,生成事件配置。
模拟需求: {simulation_requirement}
{context_truncated}
## 可用实体类型及示例
{type_info}
## 任务
请生成事件配置JSON
- 提取热点话题关键词
- 描述舆论发展方向
- 设计初始帖子内容**每个帖子必须指定 poster_type发布者类型**
**重要**: poster_type 必须从上面的"可用实体类型"中选择这样初始帖子才能分配给合适的 Agent 发布
例如官方声明应由 Official/University 类型发布新闻由 MediaOutlet 发布学生观点由 Student 发布
返回JSON格式不要markdown
{{
"hot_topics": ["关键词1", "关键词2", ...],
"narrative_direction": "<舆论发展方向描述>",
"initial_posts": [
{{"content": "帖子内容", "poster_type": "实体类型(必须从可用类型中选择)"}},
...
],
"reasoning": "<简要说明>"
}}"""
system_prompt = "你是舆论分析专家。返回纯JSON格式。注意 poster_type 必须精确匹配可用实体类型。"
system_prompt = f"{system_prompt}\n\n{get_language_instruction()}\nIMPORTANT: The 'poster_type' field value MUST be in English PascalCase exactly matching the available entity types. Only 'content', 'narrative_direction', 'hot_topics' and 'reasoning' fields should use the specified language."
try:
return self._call_llm_with_retry(prompt, system_prompt)
except Exception as e:
logger.warning(f"事件配置LLM生成失败: {e}, 使用默认配置")
return {
"hot_topics": [],
"narrative_direction": "",
"initial_posts": [],
"reasoning": "使用默认配置"
}
def _parse_event_config(self, result: Dict[str, Any]) -> EventConfig:
"""解析事件配置结果"""
return EventConfig(
initial_posts=result.get("initial_posts", []),
scheduled_events=[],
hot_topics=result.get("hot_topics", []),
narrative_direction=result.get("narrative_direction", "")
)
def _assign_initial_post_agents(
self,
event_config: EventConfig,
agent_configs: List[AgentActivityConfig]
) -> EventConfig:
"""
为初始帖子分配合适的发布者 Agent
根据每个帖子的 poster_type 匹配最合适的 agent_id
"""
if not event_config.initial_posts:
return event_config
# 按实体类型建立 agent 索引
agents_by_type: Dict[str, List[AgentActivityConfig]] = {}
for agent in agent_configs:
etype = agent.entity_type.lower()
if etype not in agents_by_type:
agents_by_type[etype] = []
agents_by_type[etype].append(agent)
# 类型映射表(处理 LLM 可能输出的不同格式)
type_aliases = {
"official": ["official", "university", "governmentagency", "government"],
"university": ["university", "official"],
"mediaoutlet": ["mediaoutlet", "media"],
"student": ["student", "person"],
"professor": ["professor", "expert", "teacher"],
"alumni": ["alumni", "person"],
"organization": ["organization", "ngo", "company", "group"],
"person": ["person", "student", "alumni"],
}
# 记录每种类型已使用的 agent 索引,避免重复使用同一个 agent
used_indices: Dict[str, int] = {}
updated_posts = []
for post in event_config.initial_posts:
poster_type = post.get("poster_type", "").lower()
content = post.get("content", "")
# 尝试找到匹配的 agent
matched_agent_id = None
# 1. 直接匹配
if poster_type in agents_by_type:
agents = agents_by_type[poster_type]
idx = used_indices.get(poster_type, 0) % len(agents)
matched_agent_id = agents[idx].agent_id
used_indices[poster_type] = idx + 1
else:
# 2. 使用别名匹配
for alias_key, aliases in type_aliases.items():
if poster_type in aliases or alias_key == poster_type:
for alias in aliases:
if alias in agents_by_type:
agents = agents_by_type[alias]
idx = used_indices.get(alias, 0) % len(agents)
matched_agent_id = agents[idx].agent_id
used_indices[alias] = idx + 1
break
if matched_agent_id is not None:
break
# 3. 如果仍未找到,使用影响力最高的 agent
if matched_agent_id is None:
logger.warning(f"未找到类型 '{poster_type}' 的匹配 Agent使用影响力最高的 Agent")
if agent_configs:
# 按影响力排序,选择影响力最高的
sorted_agents = sorted(agent_configs, key=lambda a: a.influence_weight, reverse=True)
matched_agent_id = sorted_agents[0].agent_id
else:
matched_agent_id = 0
updated_posts.append({
"content": content,
"poster_type": post.get("poster_type", "Unknown"),
"poster_agent_id": matched_agent_id
})
logger.info(f"初始帖子分配: poster_type='{poster_type}' -> agent_id={matched_agent_id}")
event_config.initial_posts = updated_posts
return event_config
def _generate_agent_configs_batch(
self,
context: str,
entities: List[EntityNode],
start_idx: int,
simulation_requirement: str
) -> List[AgentActivityConfig]:
"""分批生成Agent配置"""
# 构建实体信息(使用配置的摘要长度)
entity_list = []
summary_len = self.AGENT_SUMMARY_LENGTH
for i, e in enumerate(entities):
entity_list.append({
"agent_id": start_idx + i,
"entity_name": e.name,
"entity_type": e.get_entity_type() or "Unknown",
"summary": e.summary[:summary_len] if e.summary else ""
})
prompt = f"""基于以下信息,为每个实体生成社交媒体活动配置。
模拟需求: {simulation_requirement}
## 实体列表
```json
{json.dumps(entity_list, ensure_ascii=False, indent=2)}
```
## 任务
为每个实体生成活动配置注意
- **时间符合目标用户群体作息**以下为参考东八区请根据模拟场景调整
- **官方机构**University/GovernmentAgency活跃度低(0.1-0.3)工作时间(9-17)活动响应慢(60-240分钟)影响力高(2.5-3.0)
- **媒体**MediaOutlet活跃度中(0.4-0.6)全天活动(8-23)响应快(5-30分钟)影响力高(2.0-2.5)
- **个人**Student/Person/Alumni活跃度高(0.6-0.9)主要晚间活动(18-23)响应快(1-15分钟)影响力低(0.8-1.2)
- **公众人物/专家**活跃度中(0.4-0.6)影响力中高(1.5-2.0)
返回JSON格式不要markdown
{{
"agent_configs": [
{{
"agent_id": <必须与输入一致>,
"activity_level": <0.0-1.0>,
"posts_per_hour": <发帖频率>,
"comments_per_hour": <评论频率>,
"active_hours": [<活跃小时列表考虑中国人作息>],
"response_delay_min": <最小响应延迟分钟>,
"response_delay_max": <最大响应延迟分钟>,
"sentiment_bias": <-1.0到1.0>,
"stance": "<supportive/opposing/neutral/observer>",
"influence_weight": <影响力权重>
}},
...
]
}}"""
system_prompt = "你是社交媒体行为分析专家。返回纯JSON配置需符合模拟场景中目标用户群体的作息习惯。"
system_prompt = f"{system_prompt}\n\n{get_language_instruction()}\nIMPORTANT: The 'stance' field value MUST be one of the English strings: 'supportive', 'opposing', 'neutral', 'observer'. All JSON field names and numeric values must remain unchanged. Only natural language text fields should use the specified language."
try:
result = self._call_llm_with_retry(prompt, system_prompt)
llm_configs = {cfg["agent_id"]: cfg for cfg in result.get("agent_configs", [])}
except Exception as e:
logger.warning(f"Agent配置批次LLM生成失败: {e}, 使用规则生成")
llm_configs = {}
# 构建AgentActivityConfig对象
configs = []
for i, entity in enumerate(entities):
agent_id = start_idx + i
cfg = llm_configs.get(agent_id, {})
# 如果LLM没有生成使用规则生成
if not cfg:
cfg = self._generate_agent_config_by_rule(entity)
config = AgentActivityConfig(
agent_id=agent_id,
entity_uuid=entity.uuid,
entity_name=entity.name,
entity_type=entity.get_entity_type() or "Unknown",
activity_level=cfg.get("activity_level", 0.5),
posts_per_hour=cfg.get("posts_per_hour", 0.5),
comments_per_hour=cfg.get("comments_per_hour", 1.0),
active_hours=cfg.get("active_hours", list(range(9, 23))),
response_delay_min=cfg.get("response_delay_min", 5),
response_delay_max=cfg.get("response_delay_max", 60),
sentiment_bias=cfg.get("sentiment_bias", 0.0),
stance=cfg.get("stance", "neutral"),
influence_weight=cfg.get("influence_weight", 1.0)
)
configs.append(config)
return configs
def _generate_agent_config_by_rule(self, entity: EntityNode) -> Dict[str, Any]:
"""基于规则生成单个Agent配置中国人作息"""
entity_type = (entity.get_entity_type() or "Unknown").lower()
if entity_type in ["university", "governmentagency", "ngo"]:
# 官方机构:工作时间活动,低频率,高影响力
return {
"activity_level": 0.2,
"posts_per_hour": 0.1,
"comments_per_hour": 0.05,
"active_hours": list(range(9, 18)), # 9:00-17:59
"response_delay_min": 60,
"response_delay_max": 240,
"sentiment_bias": 0.0,
"stance": "neutral",
"influence_weight": 3.0
}
elif entity_type in ["mediaoutlet"]:
# 媒体:全天活动,中等频率,高影响力
return {
"activity_level": 0.5,
"posts_per_hour": 0.8,
"comments_per_hour": 0.3,
"active_hours": list(range(7, 24)), # 7:00-23:59
"response_delay_min": 5,
"response_delay_max": 30,
"sentiment_bias": 0.0,
"stance": "observer",
"influence_weight": 2.5
}
elif entity_type in ["professor", "expert", "official"]:
# 专家/教授:工作+晚间活动,中等频率
return {
"activity_level": 0.4,
"posts_per_hour": 0.3,
"comments_per_hour": 0.5,
"active_hours": list(range(8, 22)), # 8:00-21:59
"response_delay_min": 15,
"response_delay_max": 90,
"sentiment_bias": 0.0,
"stance": "neutral",
"influence_weight": 2.0
}
elif entity_type in ["student"]:
# 学生:晚间为主,高频率
return {
"activity_level": 0.8,
"posts_per_hour": 0.6,
"comments_per_hour": 1.5,
"active_hours": [8, 9, 10, 11, 12, 13, 18, 19, 20, 21, 22, 23], # 上午+晚间
"response_delay_min": 1,
"response_delay_max": 15,
"sentiment_bias": 0.0,
"stance": "neutral",
"influence_weight": 0.8
}
elif entity_type in ["alumni"]:
# 校友:晚间为主
return {
"activity_level": 0.6,
"posts_per_hour": 0.4,
"comments_per_hour": 0.8,
"active_hours": [12, 13, 19, 20, 21, 22, 23], # 午休+晚间
"response_delay_min": 5,
"response_delay_max": 30,
"sentiment_bias": 0.0,
"stance": "neutral",
"influence_weight": 1.0
}
else:
# 普通人:晚间高峰
return {
"activity_level": 0.7,
"posts_per_hour": 0.5,
"comments_per_hour": 1.2,
"active_hours": [9, 10, 11, 12, 13, 18, 19, 20, 21, 22, 23], # 白天+晚间
"response_delay_min": 2,
"response_delay_max": 20,
"sentiment_bias": 0.0,
"stance": "neutral",
"influence_weight": 1.0
}

View File

@ -0,0 +1,394 @@
"""
模拟IPC通信模块
用于Flask后端和模拟脚本之间的进程间通信
通过文件系统实现简单的命令/响应模式
1. Flask写入命令到 commands/ 目录
2. 模拟脚本轮询命令目录执行命令并写入响应到 responses/ 目录
3. Flask轮询响应目录获取结果
"""
import os
import json
import time
import uuid
from typing import Dict, Any, Optional, List
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from ..utils.logger import get_logger
logger = get_logger('mirofish.simulation_ipc')
class CommandType(str, Enum):
"""命令类型"""
INTERVIEW = "interview" # 单个Agent采访
BATCH_INTERVIEW = "batch_interview" # 批量采访
CLOSE_ENV = "close_env" # 关闭环境
class CommandStatus(str, Enum):
"""命令状态"""
PENDING = "pending"
PROCESSING = "processing"
COMPLETED = "completed"
FAILED = "failed"
@dataclass
class IPCCommand:
"""IPC命令"""
command_id: str
command_type: CommandType
args: Dict[str, Any]
timestamp: str = field(default_factory=lambda: datetime.now().isoformat())
def to_dict(self) -> Dict[str, Any]:
return {
"command_id": self.command_id,
"command_type": self.command_type.value,
"args": self.args,
"timestamp": self.timestamp
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'IPCCommand':
return cls(
command_id=data["command_id"],
command_type=CommandType(data["command_type"]),
args=data.get("args", {}),
timestamp=data.get("timestamp", datetime.now().isoformat())
)
@dataclass
class IPCResponse:
"""IPC响应"""
command_id: str
status: CommandStatus
result: Optional[Dict[str, Any]] = None
error: Optional[str] = None
timestamp: str = field(default_factory=lambda: datetime.now().isoformat())
def to_dict(self) -> Dict[str, Any]:
return {
"command_id": self.command_id,
"status": self.status.value,
"result": self.result,
"error": self.error,
"timestamp": self.timestamp
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'IPCResponse':
return cls(
command_id=data["command_id"],
status=CommandStatus(data["status"]),
result=data.get("result"),
error=data.get("error"),
timestamp=data.get("timestamp", datetime.now().isoformat())
)
class SimulationIPCClient:
"""
模拟IPC客户端Flask端使用
用于向模拟进程发送命令并等待响应
"""
def __init__(self, simulation_dir: str):
"""
初始化IPC客户端
Args:
simulation_dir: 模拟数据目录
"""
self.simulation_dir = simulation_dir
self.commands_dir = os.path.join(simulation_dir, "ipc_commands")
self.responses_dir = os.path.join(simulation_dir, "ipc_responses")
# 确保目录存在
os.makedirs(self.commands_dir, exist_ok=True)
os.makedirs(self.responses_dir, exist_ok=True)
def send_command(
self,
command_type: CommandType,
args: Dict[str, Any],
timeout: float = 60.0,
poll_interval: float = 0.5
) -> IPCResponse:
"""
发送命令并等待响应
Args:
command_type: 命令类型
args: 命令参数
timeout: 超时时间
poll_interval: 轮询间隔
Returns:
IPCResponse
Raises:
TimeoutError: 等待响应超时
"""
command_id = str(uuid.uuid4())
command = IPCCommand(
command_id=command_id,
command_type=command_type,
args=args
)
# 写入命令文件
command_file = os.path.join(self.commands_dir, f"{command_id}.json")
with open(command_file, 'w', encoding='utf-8') as f:
json.dump(command.to_dict(), f, ensure_ascii=False, indent=2)
logger.info(f"发送IPC命令: {command_type.value}, command_id={command_id}")
# 等待响应
response_file = os.path.join(self.responses_dir, f"{command_id}.json")
start_time = time.time()
while time.time() - start_time < timeout:
if os.path.exists(response_file):
try:
with open(response_file, 'r', encoding='utf-8') as f:
response_data = json.load(f)
response = IPCResponse.from_dict(response_data)
# 清理命令和响应文件
try:
os.remove(command_file)
os.remove(response_file)
except OSError:
pass
logger.info(f"收到IPC响应: command_id={command_id}, status={response.status.value}")
return response
except (json.JSONDecodeError, KeyError) as e:
logger.warning(f"解析响应失败: {e}")
time.sleep(poll_interval)
# 超时
logger.error(f"等待IPC响应超时: command_id={command_id}")
# 清理命令文件
try:
os.remove(command_file)
except OSError:
pass
raise TimeoutError(f"等待命令响应超时 ({timeout}秒)")
def send_interview(
self,
agent_id: int,
prompt: str,
platform: str = None,
timeout: float = 60.0
) -> IPCResponse:
"""
发送单个Agent采访命令
Args:
agent_id: Agent ID
prompt: 采访问题
platform: 指定平台可选
- "twitter": 只采访Twitter平台
- "reddit": 只采访Reddit平台
- None: 双平台模拟时同时采访两个平台单平台模拟时采访该平台
timeout: 超时时间
Returns:
IPCResponseresult字段包含采访结果
"""
args = {
"agent_id": agent_id,
"prompt": prompt
}
if platform:
args["platform"] = platform
return self.send_command(
command_type=CommandType.INTERVIEW,
args=args,
timeout=timeout
)
def send_batch_interview(
self,
interviews: List[Dict[str, Any]],
platform: str = None,
timeout: float = 120.0
) -> IPCResponse:
"""
发送批量采访命令
Args:
interviews: 采访列表每个元素包含 {"agent_id": int, "prompt": str, "platform": str(可选)}
platform: 默认平台可选会被每个采访项的platform覆盖
- "twitter": 默认只采访Twitter平台
- "reddit": 默认只采访Reddit平台
- None: 双平台模拟时每个Agent同时采访两个平台
timeout: 超时时间
Returns:
IPCResponseresult字段包含所有采访结果
"""
args = {"interviews": interviews}
if platform:
args["platform"] = platform
return self.send_command(
command_type=CommandType.BATCH_INTERVIEW,
args=args,
timeout=timeout
)
def send_close_env(self, timeout: float = 30.0) -> IPCResponse:
"""
发送关闭环境命令
Args:
timeout: 超时时间
Returns:
IPCResponse
"""
return self.send_command(
command_type=CommandType.CLOSE_ENV,
args={},
timeout=timeout
)
def check_env_alive(self) -> bool:
"""
检查模拟环境是否存活
通过检查 env_status.json 文件来判断
"""
status_file = os.path.join(self.simulation_dir, "env_status.json")
if not os.path.exists(status_file):
return False
try:
with open(status_file, 'r', encoding='utf-8') as f:
status = json.load(f)
return status.get("status") == "alive"
except (json.JSONDecodeError, OSError):
return False
class SimulationIPCServer:
"""
模拟IPC服务器模拟脚本端使用
轮询命令目录执行命令并返回响应
"""
def __init__(self, simulation_dir: str):
"""
初始化IPC服务器
Args:
simulation_dir: 模拟数据目录
"""
self.simulation_dir = simulation_dir
self.commands_dir = os.path.join(simulation_dir, "ipc_commands")
self.responses_dir = os.path.join(simulation_dir, "ipc_responses")
# 确保目录存在
os.makedirs(self.commands_dir, exist_ok=True)
os.makedirs(self.responses_dir, exist_ok=True)
# 环境状态
self._running = False
def start(self):
"""标记服务器为运行状态"""
self._running = True
self._update_env_status("alive")
def stop(self):
"""标记服务器为停止状态"""
self._running = False
self._update_env_status("stopped")
def _update_env_status(self, status: str):
"""更新环境状态文件"""
status_file = os.path.join(self.simulation_dir, "env_status.json")
with open(status_file, 'w', encoding='utf-8') as f:
json.dump({
"status": status,
"timestamp": datetime.now().isoformat()
}, f, ensure_ascii=False, indent=2)
def poll_commands(self) -> Optional[IPCCommand]:
"""
轮询命令目录返回第一个待处理的命令
Returns:
IPCCommand None
"""
if not os.path.exists(self.commands_dir):
return None
# 按时间排序获取命令文件
command_files = []
for filename in os.listdir(self.commands_dir):
if filename.endswith('.json'):
filepath = os.path.join(self.commands_dir, filename)
command_files.append((filepath, os.path.getmtime(filepath)))
command_files.sort(key=lambda x: x[1])
for filepath, _ in command_files:
try:
with open(filepath, 'r', encoding='utf-8') as f:
data = json.load(f)
return IPCCommand.from_dict(data)
except (json.JSONDecodeError, KeyError, OSError) as e:
logger.warning(f"读取命令文件失败: {filepath}, {e}")
continue
return None
def send_response(self, response: IPCResponse):
"""
发送响应
Args:
response: IPC响应
"""
response_file = os.path.join(self.responses_dir, f"{response.command_id}.json")
with open(response_file, 'w', encoding='utf-8') as f:
json.dump(response.to_dict(), f, ensure_ascii=False, indent=2)
# 删除命令文件
command_file = os.path.join(self.commands_dir, f"{response.command_id}.json")
try:
os.remove(command_file)
except OSError:
pass
def send_success(self, command_id: str, result: Dict[str, Any]):
"""发送成功响应"""
self.send_response(IPCResponse(
command_id=command_id,
status=CommandStatus.COMPLETED,
result=result
))
def send_error(self, command_id: str, error: str):
"""发送错误响应"""
self.send_response(IPCResponse(
command_id=command_id,
status=CommandStatus.FAILED,
error=error
))

View File

@ -0,0 +1,529 @@
"""
OASIS模拟管理器
管理Twitter和Reddit双平台并行模拟
使用预设脚本 + LLM智能生成配置参数
"""
import os
import json
import shutil
from typing import Dict, Any, List, Optional
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from ..config import Config
from ..utils.logger import get_logger
from .zep_entity_reader import ZepEntityReader, FilteredEntities
from .oasis_profile_generator import OasisProfileGenerator, OasisAgentProfile
from .simulation_config_generator import SimulationConfigGenerator, SimulationParameters
from ..utils.locale import t
logger = get_logger('mirofish.simulation')
class SimulationStatus(str, Enum):
"""模拟状态"""
CREATED = "created"
PREPARING = "preparing"
READY = "ready"
RUNNING = "running"
PAUSED = "paused"
STOPPED = "stopped" # 模拟被手动停止
COMPLETED = "completed" # 模拟自然完成
FAILED = "failed"
class PlatformType(str, Enum):
"""平台类型"""
TWITTER = "twitter"
REDDIT = "reddit"
@dataclass
class SimulationState:
"""模拟状态"""
simulation_id: str
project_id: str
graph_id: str
# 平台启用状态
enable_twitter: bool = True
enable_reddit: bool = True
# 状态
status: SimulationStatus = SimulationStatus.CREATED
# 准备阶段数据
entities_count: int = 0
profiles_count: int = 0
entity_types: List[str] = field(default_factory=list)
# 配置生成信息
config_generated: bool = False
config_reasoning: str = ""
# 运行时数据
current_round: int = 0
twitter_status: str = "not_started"
reddit_status: str = "not_started"
# 时间戳
created_at: str = field(default_factory=lambda: datetime.now().isoformat())
updated_at: str = field(default_factory=lambda: datetime.now().isoformat())
# 错误信息
error: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
"""完整状态字典(内部使用)"""
return {
"simulation_id": self.simulation_id,
"project_id": self.project_id,
"graph_id": self.graph_id,
"enable_twitter": self.enable_twitter,
"enable_reddit": self.enable_reddit,
"status": self.status.value,
"entities_count": self.entities_count,
"profiles_count": self.profiles_count,
"entity_types": self.entity_types,
"config_generated": self.config_generated,
"config_reasoning": self.config_reasoning,
"current_round": self.current_round,
"twitter_status": self.twitter_status,
"reddit_status": self.reddit_status,
"created_at": self.created_at,
"updated_at": self.updated_at,
"error": self.error,
}
def to_simple_dict(self) -> Dict[str, Any]:
"""简化状态字典API返回使用"""
return {
"simulation_id": self.simulation_id,
"project_id": self.project_id,
"graph_id": self.graph_id,
"status": self.status.value,
"entities_count": self.entities_count,
"profiles_count": self.profiles_count,
"entity_types": self.entity_types,
"config_generated": self.config_generated,
"error": self.error,
}
class SimulationManager:
"""
模拟管理器
核心功能
1. 从Zep图谱读取实体并过滤
2. 生成OASIS Agent Profile
3. 使用LLM智能生成模拟配置参数
4. 准备预设脚本所需的所有文件
"""
# 模拟数据存储目录
SIMULATION_DATA_DIR = os.path.join(
os.path.dirname(__file__),
'../../uploads/simulations'
)
def __init__(self):
# 确保目录存在
os.makedirs(self.SIMULATION_DATA_DIR, exist_ok=True)
# 内存中的模拟状态缓存
self._simulations: Dict[str, SimulationState] = {}
def _get_simulation_dir(self, simulation_id: str) -> str:
"""获取模拟数据目录"""
sim_dir = os.path.join(self.SIMULATION_DATA_DIR, simulation_id)
os.makedirs(sim_dir, exist_ok=True)
return sim_dir
def _save_simulation_state(self, state: SimulationState):
"""保存模拟状态到文件"""
sim_dir = self._get_simulation_dir(state.simulation_id)
state_file = os.path.join(sim_dir, "state.json")
state.updated_at = datetime.now().isoformat()
with open(state_file, 'w', encoding='utf-8') as f:
json.dump(state.to_dict(), f, ensure_ascii=False, indent=2)
self._simulations[state.simulation_id] = state
def _load_simulation_state(self, simulation_id: str) -> Optional[SimulationState]:
"""从文件加载模拟状态"""
if simulation_id in self._simulations:
return self._simulations[simulation_id]
sim_dir = self._get_simulation_dir(simulation_id)
state_file = os.path.join(sim_dir, "state.json")
if not os.path.exists(state_file):
return None
with open(state_file, 'r', encoding='utf-8') as f:
data = json.load(f)
state = SimulationState(
simulation_id=simulation_id,
project_id=data.get("project_id", ""),
graph_id=data.get("graph_id", ""),
enable_twitter=data.get("enable_twitter", True),
enable_reddit=data.get("enable_reddit", True),
status=SimulationStatus(data.get("status", "created")),
entities_count=data.get("entities_count", 0),
profiles_count=data.get("profiles_count", 0),
entity_types=data.get("entity_types", []),
config_generated=data.get("config_generated", False),
config_reasoning=data.get("config_reasoning", ""),
current_round=data.get("current_round", 0),
twitter_status=data.get("twitter_status", "not_started"),
reddit_status=data.get("reddit_status", "not_started"),
created_at=data.get("created_at", datetime.now().isoformat()),
updated_at=data.get("updated_at", datetime.now().isoformat()),
error=data.get("error"),
)
self._simulations[simulation_id] = state
return state
def create_simulation(
self,
project_id: str,
graph_id: str,
enable_twitter: bool = True,
enable_reddit: bool = True,
) -> SimulationState:
"""
创建新的模拟
Args:
project_id: 项目ID
graph_id: Zep图谱ID
enable_twitter: 是否启用Twitter模拟
enable_reddit: 是否启用Reddit模拟
Returns:
SimulationState
"""
import uuid
simulation_id = f"sim_{uuid.uuid4().hex[:12]}"
state = SimulationState(
simulation_id=simulation_id,
project_id=project_id,
graph_id=graph_id,
enable_twitter=enable_twitter,
enable_reddit=enable_reddit,
status=SimulationStatus.CREATED,
)
self._save_simulation_state(state)
logger.info(f"创建模拟: {simulation_id}, project={project_id}, graph={graph_id}")
return state
def prepare_simulation(
self,
simulation_id: str,
simulation_requirement: str,
document_text: str,
defined_entity_types: Optional[List[str]] = None,
use_llm_for_profiles: bool = True,
progress_callback: Optional[callable] = None,
parallel_profile_count: int = 3
) -> SimulationState:
"""
准备模拟环境全程自动化
步骤
1. 从Zep图谱读取并过滤实体
2. 为每个实体生成OASIS Agent Profile可选LLM增强支持并行
3. 使用LLM智能生成模拟配置参数时间活跃度发言频率等
4. 保存配置文件和Profile文件
5. 复制预设脚本到模拟目录
Args:
simulation_id: 模拟ID
simulation_requirement: 模拟需求描述用于LLM生成配置
document_text: 原始文档内容用于LLM理解背景
defined_entity_types: 预定义的实体类型可选
use_llm_for_profiles: 是否使用LLM生成详细人设
progress_callback: 进度回调函数 (stage, progress, message)
parallel_profile_count: 并行生成人设的数量默认3
Returns:
SimulationState
"""
state = self._load_simulation_state(simulation_id)
if not state:
raise ValueError(f"模拟不存在: {simulation_id}")
try:
state.status = SimulationStatus.PREPARING
self._save_simulation_state(state)
sim_dir = self._get_simulation_dir(simulation_id)
# ========== 阶段1: 读取并过滤实体 ==========
if progress_callback:
progress_callback("reading", 0, t('progress.connectingZepGraph'))
reader = ZepEntityReader()
if progress_callback:
progress_callback("reading", 30, t('progress.readingNodeData'))
filtered = reader.filter_defined_entities(
graph_id=state.graph_id,
defined_entity_types=defined_entity_types,
enrich_with_edges=True
)
state.entities_count = filtered.filtered_count
state.entity_types = list(filtered.entity_types)
if progress_callback:
progress_callback(
"reading", 100,
t('progress.readingComplete', count=filtered.filtered_count),
current=filtered.filtered_count,
total=filtered.filtered_count
)
if filtered.filtered_count == 0:
state.status = SimulationStatus.FAILED
state.error = "没有找到符合条件的实体,请检查图谱是否正确构建"
self._save_simulation_state(state)
return state
# ========== 阶段2: 生成Agent Profile ==========
total_entities = len(filtered.entities)
if progress_callback:
progress_callback(
"generating_profiles", 0,
t('progress.startGenerating'),
current=0,
total=total_entities
)
# 传入graph_id以启用Zep检索功能获取更丰富的上下文
generator = OasisProfileGenerator(graph_id=state.graph_id)
def profile_progress(current, total, msg):
if progress_callback:
progress_callback(
"generating_profiles",
int(current / total * 100),
msg,
current=current,
total=total,
item_name=msg
)
# 设置实时保存的文件路径(优先使用 Reddit JSON 格式)
realtime_output_path = None
realtime_platform = "reddit"
if state.enable_reddit:
realtime_output_path = os.path.join(sim_dir, "reddit_profiles.json")
realtime_platform = "reddit"
elif state.enable_twitter:
realtime_output_path = os.path.join(sim_dir, "twitter_profiles.csv")
realtime_platform = "twitter"
profiles = generator.generate_profiles_from_entities(
entities=filtered.entities,
use_llm=use_llm_for_profiles,
progress_callback=profile_progress,
graph_id=state.graph_id, # 传入graph_id用于Zep检索
parallel_count=parallel_profile_count, # 并行生成数量
realtime_output_path=realtime_output_path, # 实时保存路径
output_platform=realtime_platform # 输出格式
)
state.profiles_count = len(profiles)
# 保存Profile文件注意Twitter使用CSV格式Reddit使用JSON格式
# Reddit 已经在生成过程中实时保存了,这里再保存一次确保完整性
if progress_callback:
progress_callback(
"generating_profiles", 95,
t('progress.savingProfiles'),
current=total_entities,
total=total_entities
)
if state.enable_reddit:
generator.save_profiles(
profiles=profiles,
file_path=os.path.join(sim_dir, "reddit_profiles.json"),
platform="reddit"
)
if state.enable_twitter:
# Twitter使用CSV格式这是OASIS的要求
generator.save_profiles(
profiles=profiles,
file_path=os.path.join(sim_dir, "twitter_profiles.csv"),
platform="twitter"
)
if progress_callback:
progress_callback(
"generating_profiles", 100,
t('progress.profilesComplete', count=len(profiles)),
current=len(profiles),
total=len(profiles)
)
# ========== 阶段3: LLM智能生成模拟配置 ==========
if progress_callback:
progress_callback(
"generating_config", 0,
t('progress.analyzingRequirements'),
current=0,
total=3
)
config_generator = SimulationConfigGenerator()
if progress_callback:
progress_callback(
"generating_config", 30,
t('progress.callingLLMConfig'),
current=1,
total=3
)
sim_params = config_generator.generate_config(
simulation_id=simulation_id,
project_id=state.project_id,
graph_id=state.graph_id,
simulation_requirement=simulation_requirement,
document_text=document_text,
entities=filtered.entities,
enable_twitter=state.enable_twitter,
enable_reddit=state.enable_reddit
)
if progress_callback:
progress_callback(
"generating_config", 70,
t('progress.savingConfigFiles'),
current=2,
total=3
)
# 保存配置文件
config_path = os.path.join(sim_dir, "simulation_config.json")
with open(config_path, 'w', encoding='utf-8') as f:
f.write(sim_params.to_json())
state.config_generated = True
state.config_reasoning = sim_params.generation_reasoning
if progress_callback:
progress_callback(
"generating_config", 100,
t('progress.configComplete'),
current=3,
total=3
)
# 注意:运行脚本保留在 backend/scripts/ 目录,不再复制到模拟目录
# 启动模拟时simulation_runner 会从 scripts/ 目录运行脚本
# 更新状态
state.status = SimulationStatus.READY
self._save_simulation_state(state)
logger.info(f"模拟准备完成: {simulation_id}, "
f"entities={state.entities_count}, profiles={state.profiles_count}")
return state
except Exception as e:
logger.error(f"模拟准备失败: {simulation_id}, error={str(e)}")
import traceback
logger.error(traceback.format_exc())
state.status = SimulationStatus.FAILED
state.error = str(e)
self._save_simulation_state(state)
raise
def get_simulation(self, simulation_id: str) -> Optional[SimulationState]:
"""获取模拟状态"""
return self._load_simulation_state(simulation_id)
def list_simulations(self, project_id: Optional[str] = None) -> List[SimulationState]:
"""列出所有模拟"""
simulations = []
if os.path.exists(self.SIMULATION_DATA_DIR):
for sim_id in os.listdir(self.SIMULATION_DATA_DIR):
# 跳过隐藏文件(如 .DS_Store和非目录文件
sim_path = os.path.join(self.SIMULATION_DATA_DIR, sim_id)
if sim_id.startswith('.') or not os.path.isdir(sim_path):
continue
state = self._load_simulation_state(sim_id)
if state:
if project_id is None or state.project_id == project_id:
simulations.append(state)
return simulations
def get_profiles(self, simulation_id: str, platform: str = "reddit") -> List[Dict[str, Any]]:
"""获取模拟的Agent Profile"""
state = self._load_simulation_state(simulation_id)
if not state:
raise ValueError(f"模拟不存在: {simulation_id}")
sim_dir = self._get_simulation_dir(simulation_id)
profile_path = os.path.join(sim_dir, f"{platform}_profiles.json")
if not os.path.exists(profile_path):
return []
with open(profile_path, 'r', encoding='utf-8') as f:
return json.load(f)
def get_simulation_config(self, simulation_id: str) -> Optional[Dict[str, Any]]:
"""获取模拟配置"""
sim_dir = self._get_simulation_dir(simulation_id)
config_path = os.path.join(sim_dir, "simulation_config.json")
if not os.path.exists(config_path):
return None
with open(config_path, 'r', encoding='utf-8') as f:
return json.load(f)
def get_run_instructions(self, simulation_id: str) -> Dict[str, str]:
"""获取运行说明"""
sim_dir = self._get_simulation_dir(simulation_id)
config_path = os.path.join(sim_dir, "simulation_config.json")
scripts_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../scripts'))
return {
"simulation_dir": sim_dir,
"scripts_dir": scripts_dir,
"config_file": config_path,
"commands": {
"twitter": f"python {scripts_dir}/run_twitter_simulation.py --config {config_path}",
"reddit": f"python {scripts_dir}/run_reddit_simulation.py --config {config_path}",
"parallel": f"python {scripts_dir}/run_parallel_simulation.py --config {config_path}",
},
"instructions": (
f"1. 激活conda环境: conda activate MiroFish\n"
f"2. 运行模拟 (脚本位于 {scripts_dir}):\n"
f" - 单独运行Twitter: python {scripts_dir}/run_twitter_simulation.py --config {config_path}\n"
f" - 单独运行Reddit: python {scripts_dir}/run_reddit_simulation.py --config {config_path}\n"
f" - 并行运行双平台: python {scripts_dir}/run_parallel_simulation.py --config {config_path}"
)
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,71 @@
"""
文本处理服务
"""
from typing import List, Optional
from ..utils.file_parser import FileParser, split_text_into_chunks
class TextProcessor:
"""文本处理器"""
@staticmethod
def extract_from_files(file_paths: List[str]) -> str:
"""从多个文件提取文本"""
return FileParser.extract_from_multiple(file_paths)
@staticmethod
def split_text(
text: str,
chunk_size: int = 500,
overlap: int = 50
) -> List[str]:
"""
分割文本
Args:
text: 原始文本
chunk_size: 块大小
overlap: 重叠大小
Returns:
文本块列表
"""
return split_text_into_chunks(text, chunk_size, overlap)
@staticmethod
def preprocess_text(text: str) -> str:
"""
预处理文本
- 移除多余空白
- 标准化换行
Args:
text: 原始文本
Returns:
处理后的文本
"""
import re
# 标准化换行
text = text.replace('\r\n', '\n').replace('\r', '\n')
# 移除连续空行(保留最多两个换行)
text = re.sub(r'\n{3,}', '\n\n', text)
# 移除行首行尾空白
lines = [line.strip() for line in text.split('\n')]
text = '\n'.join(lines)
return text.strip()
@staticmethod
def get_text_stats(text: str) -> dict:
"""获取文本统计信息"""
return {
"total_chars": len(text),
"total_lines": text.count('\n') + 1,
"total_words": len(text.split()),
}

View File

@ -0,0 +1,437 @@
"""
Zep实体读取与过滤服务
从Zep图谱中读取节点筛选出符合预定义实体类型的节点
"""
import time
from typing import Dict, Any, List, Optional, Set, Callable, TypeVar
from dataclasses import dataclass, field
from zep_cloud.client import Zep
from ..config import Config
from ..utils.logger import get_logger
from ..utils.zep_paging import fetch_all_nodes, fetch_all_edges
logger = get_logger('mirofish.zep_entity_reader')
# 用于泛型返回类型
T = TypeVar('T')
@dataclass
class EntityNode:
"""实体节点数据结构"""
uuid: str
name: str
labels: List[str]
summary: str
attributes: Dict[str, Any]
# 相关的边信息
related_edges: List[Dict[str, Any]] = field(default_factory=list)
# 相关的其他节点信息
related_nodes: List[Dict[str, Any]] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
return {
"uuid": self.uuid,
"name": self.name,
"labels": self.labels,
"summary": self.summary,
"attributes": self.attributes,
"related_edges": self.related_edges,
"related_nodes": self.related_nodes,
}
def get_entity_type(self) -> Optional[str]:
"""获取实体类型排除默认的Entity标签"""
for label in self.labels:
if label not in ["Entity", "Node"]:
return label
return None
@dataclass
class FilteredEntities:
"""过滤后的实体集合"""
entities: List[EntityNode]
entity_types: Set[str]
total_count: int
filtered_count: int
def to_dict(self) -> Dict[str, Any]:
return {
"entities": [e.to_dict() for e in self.entities],
"entity_types": list(self.entity_types),
"total_count": self.total_count,
"filtered_count": self.filtered_count,
}
class ZepEntityReader:
"""
Zep实体读取与过滤服务
主要功能
1. 从Zep图谱读取所有节点
2. 筛选出符合预定义实体类型的节点Labels不只是Entity的节点
3. 获取每个实体的相关边和关联节点信息
"""
def __init__(self, api_key: Optional[str] = None):
self.api_key = api_key or Config.ZEP_API_KEY
if not self.api_key:
raise ValueError("ZEP_API_KEY 未配置")
self.client = Zep(api_key=self.api_key)
def _call_with_retry(
self,
func: Callable[[], T],
operation_name: str,
max_retries: int = 3,
initial_delay: float = 2.0
) -> T:
"""
带重试机制的Zep API调用
Args:
func: 要执行的函数无参数的lambda或callable
operation_name: 操作名称用于日志
max_retries: 最大重试次数默认3次即最多尝试3次
initial_delay: 初始延迟秒数
Returns:
API调用结果
"""
last_exception = None
delay = initial_delay
for attempt in range(max_retries):
try:
return func()
except Exception as e:
last_exception = e
if attempt < max_retries - 1:
logger.warning(
f"Zep {operation_name}{attempt + 1} 次尝试失败: {str(e)[:100]}, "
f"{delay:.1f}秒后重试..."
)
time.sleep(delay)
delay *= 2 # 指数退避
else:
logger.error(f"Zep {operation_name}{max_retries} 次尝试后仍失败: {str(e)}")
raise last_exception
def get_all_nodes(self, graph_id: str) -> List[Dict[str, Any]]:
"""
获取图谱的所有节点分页获取
Args:
graph_id: 图谱ID
Returns:
节点列表
"""
logger.info(f"获取图谱 {graph_id} 的所有节点...")
nodes = fetch_all_nodes(self.client, graph_id)
nodes_data = []
for node in nodes:
nodes_data.append({
"uuid": getattr(node, 'uuid_', None) or getattr(node, 'uuid', ''),
"name": node.name or "",
"labels": node.labels or [],
"summary": node.summary or "",
"attributes": node.attributes or {},
})
logger.info(f"共获取 {len(nodes_data)} 个节点")
return nodes_data
def get_all_edges(self, graph_id: str) -> List[Dict[str, Any]]:
"""
获取图谱的所有边分页获取
Args:
graph_id: 图谱ID
Returns:
边列表
"""
logger.info(f"获取图谱 {graph_id} 的所有边...")
edges = fetch_all_edges(self.client, graph_id)
edges_data = []
for edge in edges:
edges_data.append({
"uuid": getattr(edge, 'uuid_', None) or getattr(edge, 'uuid', ''),
"name": edge.name or "",
"fact": edge.fact or "",
"source_node_uuid": edge.source_node_uuid,
"target_node_uuid": edge.target_node_uuid,
"attributes": edge.attributes or {},
})
logger.info(f"共获取 {len(edges_data)} 条边")
return edges_data
def get_node_edges(self, node_uuid: str) -> List[Dict[str, Any]]:
"""
获取指定节点的所有相关边带重试机制
Args:
node_uuid: 节点UUID
Returns:
边列表
"""
try:
# 使用重试机制调用Zep API
edges = self._call_with_retry(
func=lambda: self.client.graph.node.get_entity_edges(node_uuid=node_uuid),
operation_name=f"获取节点边(node={node_uuid[:8]}...)"
)
edges_data = []
for edge in edges:
edges_data.append({
"uuid": getattr(edge, 'uuid_', None) or getattr(edge, 'uuid', ''),
"name": edge.name or "",
"fact": edge.fact or "",
"source_node_uuid": edge.source_node_uuid,
"target_node_uuid": edge.target_node_uuid,
"attributes": edge.attributes or {},
})
return edges_data
except Exception as e:
logger.warning(f"获取节点 {node_uuid} 的边失败: {str(e)}")
return []
def filter_defined_entities(
self,
graph_id: str,
defined_entity_types: Optional[List[str]] = None,
enrich_with_edges: bool = True
) -> FilteredEntities:
"""
筛选出符合预定义实体类型的节点
筛选逻辑
- 如果节点的Labels只有一个"Entity"说明这个实体不符合我们预定义的类型跳过
- 如果节点的Labels包含除"Entity""Node"之外的标签说明符合预定义类型保留
Args:
graph_id: 图谱ID
defined_entity_types: 预定义的实体类型列表可选如果提供则只保留这些类型
enrich_with_edges: 是否获取每个实体的相关边信息
Returns:
FilteredEntities: 过滤后的实体集合
"""
logger.info(f"开始筛选图谱 {graph_id} 的实体...")
# 获取所有节点
all_nodes = self.get_all_nodes(graph_id)
total_count = len(all_nodes)
# 获取所有边(用于后续关联查找)
all_edges = self.get_all_edges(graph_id) if enrich_with_edges else []
# 构建节点UUID到节点数据的映射
node_map = {n["uuid"]: n for n in all_nodes}
# 筛选符合条件的实体
filtered_entities = []
entity_types_found = set()
for node in all_nodes:
labels = node.get("labels", [])
# 筛选逻辑Labels必须包含除"Entity"和"Node"之外的标签
custom_labels = [l for l in labels if l not in ["Entity", "Node"]]
if not custom_labels:
# 只有默认标签,跳过
continue
# 如果指定了预定义类型,检查是否匹配
if defined_entity_types:
matching_labels = [l for l in custom_labels if l in defined_entity_types]
if not matching_labels:
continue
entity_type = matching_labels[0]
else:
entity_type = custom_labels[0]
entity_types_found.add(entity_type)
# 创建实体节点对象
entity = EntityNode(
uuid=node["uuid"],
name=node["name"],
labels=labels,
summary=node["summary"],
attributes=node["attributes"],
)
# 获取相关边和节点
if enrich_with_edges:
related_edges = []
related_node_uuids = set()
for edge in all_edges:
if edge["source_node_uuid"] == node["uuid"]:
related_edges.append({
"direction": "outgoing",
"edge_name": edge["name"],
"fact": edge["fact"],
"target_node_uuid": edge["target_node_uuid"],
})
related_node_uuids.add(edge["target_node_uuid"])
elif edge["target_node_uuid"] == node["uuid"]:
related_edges.append({
"direction": "incoming",
"edge_name": edge["name"],
"fact": edge["fact"],
"source_node_uuid": edge["source_node_uuid"],
})
related_node_uuids.add(edge["source_node_uuid"])
entity.related_edges = related_edges
# 获取关联节点的基本信息
related_nodes = []
for related_uuid in related_node_uuids:
if related_uuid in node_map:
related_node = node_map[related_uuid]
related_nodes.append({
"uuid": related_node["uuid"],
"name": related_node["name"],
"labels": related_node["labels"],
"summary": related_node.get("summary", ""),
})
entity.related_nodes = related_nodes
filtered_entities.append(entity)
logger.info(f"筛选完成: 总节点 {total_count}, 符合条件 {len(filtered_entities)}, "
f"实体类型: {entity_types_found}")
return FilteredEntities(
entities=filtered_entities,
entity_types=entity_types_found,
total_count=total_count,
filtered_count=len(filtered_entities),
)
def get_entity_with_context(
self,
graph_id: str,
entity_uuid: str
) -> Optional[EntityNode]:
"""
获取单个实体及其完整上下文边和关联节点带重试机制
Args:
graph_id: 图谱ID
entity_uuid: 实体UUID
Returns:
EntityNode或None
"""
try:
# 使用重试机制获取节点
node = self._call_with_retry(
func=lambda: self.client.graph.node.get(uuid_=entity_uuid),
operation_name=f"获取节点详情(uuid={entity_uuid[:8]}...)"
)
if not node:
return None
# 获取节点的边
edges = self.get_node_edges(entity_uuid)
# 获取所有节点用于关联查找
all_nodes = self.get_all_nodes(graph_id)
node_map = {n["uuid"]: n for n in all_nodes}
# 处理相关边和节点
related_edges = []
related_node_uuids = set()
for edge in edges:
if edge["source_node_uuid"] == entity_uuid:
related_edges.append({
"direction": "outgoing",
"edge_name": edge["name"],
"fact": edge["fact"],
"target_node_uuid": edge["target_node_uuid"],
})
related_node_uuids.add(edge["target_node_uuid"])
else:
related_edges.append({
"direction": "incoming",
"edge_name": edge["name"],
"fact": edge["fact"],
"source_node_uuid": edge["source_node_uuid"],
})
related_node_uuids.add(edge["source_node_uuid"])
# 获取关联节点信息
related_nodes = []
for related_uuid in related_node_uuids:
if related_uuid in node_map:
related_node = node_map[related_uuid]
related_nodes.append({
"uuid": related_node["uuid"],
"name": related_node["name"],
"labels": related_node["labels"],
"summary": related_node.get("summary", ""),
})
return EntityNode(
uuid=getattr(node, 'uuid_', None) or getattr(node, 'uuid', ''),
name=node.name or "",
labels=node.labels or [],
summary=node.summary or "",
attributes=node.attributes or {},
related_edges=related_edges,
related_nodes=related_nodes,
)
except Exception as e:
logger.error(f"获取实体 {entity_uuid} 失败: {str(e)}")
return None
def get_entities_by_type(
self,
graph_id: str,
entity_type: str,
enrich_with_edges: bool = True
) -> List[EntityNode]:
"""
获取指定类型的所有实体
Args:
graph_id: 图谱ID
entity_type: 实体类型 "Student", "PublicFigure"
enrich_with_edges: 是否获取相关边信息
Returns:
实体列表
"""
result = self.filter_defined_entities(
graph_id=graph_id,
defined_entity_types=[entity_type],
enrich_with_edges=enrich_with_edges
)
return result.entities

View File

@ -0,0 +1,554 @@
"""
Zep图谱记忆更新服务
将模拟中的Agent活动动态更新到Zep图谱中
"""
import os
import time
import threading
import json
from typing import Dict, Any, List, Optional, Callable
from dataclasses import dataclass
from datetime import datetime
from queue import Queue, Empty
from zep_cloud.client import Zep
from ..config import Config
from ..utils.logger import get_logger
from ..utils.locale import get_locale, set_locale
logger = get_logger('mirofish.zep_graph_memory_updater')
@dataclass
class AgentActivity:
"""Agent活动记录"""
platform: str # twitter / reddit
agent_id: int
agent_name: str
action_type: str # CREATE_POST, LIKE_POST, etc.
action_args: Dict[str, Any]
round_num: int
timestamp: str
def to_episode_text(self) -> str:
"""
将活动转换为可以发送给Zep的文本描述
采用自然语言描述格式让Zep能够从中提取实体和关系
不添加模拟相关的前缀避免误导图谱更新
"""
# 根据不同的动作类型生成不同的描述
action_descriptions = {
"CREATE_POST": self._describe_create_post,
"LIKE_POST": self._describe_like_post,
"DISLIKE_POST": self._describe_dislike_post,
"REPOST": self._describe_repost,
"QUOTE_POST": self._describe_quote_post,
"FOLLOW": self._describe_follow,
"CREATE_COMMENT": self._describe_create_comment,
"LIKE_COMMENT": self._describe_like_comment,
"DISLIKE_COMMENT": self._describe_dislike_comment,
"SEARCH_POSTS": self._describe_search,
"SEARCH_USER": self._describe_search_user,
"MUTE": self._describe_mute,
}
describe_func = action_descriptions.get(self.action_type, self._describe_generic)
description = describe_func()
# 直接返回 "agent名称: 活动描述" 格式,不添加模拟前缀
return f"{self.agent_name}: {description}"
def _describe_create_post(self) -> str:
content = self.action_args.get("content", "")
if content:
return f"发布了一条帖子:「{content}"
return "发布了一条帖子"
def _describe_like_post(self) -> str:
"""点赞帖子 - 包含帖子原文和作者信息"""
post_content = self.action_args.get("post_content", "")
post_author = self.action_args.get("post_author_name", "")
if post_content and post_author:
return f"点赞了{post_author}的帖子:「{post_content}"
elif post_content:
return f"点赞了一条帖子:「{post_content}"
elif post_author:
return f"点赞了{post_author}的一条帖子"
return "点赞了一条帖子"
def _describe_dislike_post(self) -> str:
"""踩帖子 - 包含帖子原文和作者信息"""
post_content = self.action_args.get("post_content", "")
post_author = self.action_args.get("post_author_name", "")
if post_content and post_author:
return f"踩了{post_author}的帖子:「{post_content}"
elif post_content:
return f"踩了一条帖子:「{post_content}"
elif post_author:
return f"踩了{post_author}的一条帖子"
return "踩了一条帖子"
def _describe_repost(self) -> str:
"""转发帖子 - 包含原帖内容和作者信息"""
original_content = self.action_args.get("original_content", "")
original_author = self.action_args.get("original_author_name", "")
if original_content and original_author:
return f"转发了{original_author}的帖子:「{original_content}"
elif original_content:
return f"转发了一条帖子:「{original_content}"
elif original_author:
return f"转发了{original_author}的一条帖子"
return "转发了一条帖子"
def _describe_quote_post(self) -> str:
"""引用帖子 - 包含原帖内容、作者信息和引用评论"""
original_content = self.action_args.get("original_content", "")
original_author = self.action_args.get("original_author_name", "")
quote_content = self.action_args.get("quote_content", "") or self.action_args.get("content", "")
base = ""
if original_content and original_author:
base = f"引用了{original_author}的帖子「{original_content}"
elif original_content:
base = f"引用了一条帖子「{original_content}"
elif original_author:
base = f"引用了{original_author}的一条帖子"
else:
base = "引用了一条帖子"
if quote_content:
base += f",并评论道:「{quote_content}"
return base
def _describe_follow(self) -> str:
"""关注用户 - 包含被关注用户的名称"""
target_user_name = self.action_args.get("target_user_name", "")
if target_user_name:
return f"关注了用户「{target_user_name}"
return "关注了一个用户"
def _describe_create_comment(self) -> str:
"""发表评论 - 包含评论内容和所评论的帖子信息"""
content = self.action_args.get("content", "")
post_content = self.action_args.get("post_content", "")
post_author = self.action_args.get("post_author_name", "")
if content:
if post_content and post_author:
return f"{post_author}的帖子「{post_content}」下评论道:「{content}"
elif post_content:
return f"在帖子「{post_content}」下评论道:「{content}"
elif post_author:
return f"{post_author}的帖子下评论道:「{content}"
return f"评论道:「{content}"
return "发表了评论"
def _describe_like_comment(self) -> str:
"""点赞评论 - 包含评论内容和作者信息"""
comment_content = self.action_args.get("comment_content", "")
comment_author = self.action_args.get("comment_author_name", "")
if comment_content and comment_author:
return f"点赞了{comment_author}的评论:「{comment_content}"
elif comment_content:
return f"点赞了一条评论:「{comment_content}"
elif comment_author:
return f"点赞了{comment_author}的一条评论"
return "点赞了一条评论"
def _describe_dislike_comment(self) -> str:
"""踩评论 - 包含评论内容和作者信息"""
comment_content = self.action_args.get("comment_content", "")
comment_author = self.action_args.get("comment_author_name", "")
if comment_content and comment_author:
return f"踩了{comment_author}的评论:「{comment_content}"
elif comment_content:
return f"踩了一条评论:「{comment_content}"
elif comment_author:
return f"踩了{comment_author}的一条评论"
return "踩了一条评论"
def _describe_search(self) -> str:
"""搜索帖子 - 包含搜索关键词"""
query = self.action_args.get("query", "") or self.action_args.get("keyword", "")
return f"搜索了「{query}" if query else "进行了搜索"
def _describe_search_user(self) -> str:
"""搜索用户 - 包含搜索关键词"""
query = self.action_args.get("query", "") or self.action_args.get("username", "")
return f"搜索了用户「{query}" if query else "搜索了用户"
def _describe_mute(self) -> str:
"""屏蔽用户 - 包含被屏蔽用户的名称"""
target_user_name = self.action_args.get("target_user_name", "")
if target_user_name:
return f"屏蔽了用户「{target_user_name}"
return "屏蔽了一个用户"
def _describe_generic(self) -> str:
# 对于未知的动作类型,生成通用描述
return f"执行了{self.action_type}操作"
class ZepGraphMemoryUpdater:
"""
Zep图谱记忆更新器
监控模拟的actions日志文件将新的agent活动实时更新到Zep图谱中
按平台分组每累积BATCH_SIZE条活动后批量发送到Zep
所有有意义的行为都会被更新到Zepaction_args中会包含完整的上下文信息
- 点赞/踩的帖子原文
- 转发/引用的帖子原文
- 关注/屏蔽的用户名
- 点赞/踩的评论原文
"""
# 批量发送大小(每个平台累积多少条后发送)
BATCH_SIZE = 5
# 平台名称映射(用于控制台显示)
PLATFORM_DISPLAY_NAMES = {
'twitter': '世界1',
'reddit': '世界2',
}
# 发送间隔(秒),避免请求过快
SEND_INTERVAL = 0.5
# 重试配置
MAX_RETRIES = 3
RETRY_DELAY = 2 # 秒
def __init__(self, graph_id: str, api_key: Optional[str] = None):
"""
初始化更新器
Args:
graph_id: Zep图谱ID
api_key: Zep API Key可选默认从配置读取
"""
self.graph_id = graph_id
self.api_key = api_key or Config.ZEP_API_KEY
if not self.api_key:
raise ValueError("ZEP_API_KEY未配置")
self.client = Zep(api_key=self.api_key)
# 活动队列
self._activity_queue: Queue = Queue()
# 按平台分组的活动缓冲区每个平台各自累积到BATCH_SIZE后批量发送
self._platform_buffers: Dict[str, List[AgentActivity]] = {
'twitter': [],
'reddit': [],
}
self._buffer_lock = threading.Lock()
# 控制标志
self._running = False
self._worker_thread: Optional[threading.Thread] = None
# 统计
self._total_activities = 0 # 实际添加到队列的活动数
self._total_sent = 0 # 成功发送到Zep的批次数
self._total_items_sent = 0 # 成功发送到Zep的活动条数
self._failed_count = 0 # 发送失败的批次数
self._skipped_count = 0 # 被过滤跳过的活动数DO_NOTHING
logger.info(f"ZepGraphMemoryUpdater 初始化完成: graph_id={graph_id}, batch_size={self.BATCH_SIZE}")
def _get_platform_display_name(self, platform: str) -> str:
"""获取平台的显示名称"""
return self.PLATFORM_DISPLAY_NAMES.get(platform.lower(), platform)
def start(self):
"""启动后台工作线程"""
if self._running:
return
# Capture locale before spawning background thread
current_locale = get_locale()
self._running = True
self._worker_thread = threading.Thread(
target=self._worker_loop,
args=(current_locale,),
daemon=True,
name=f"ZepMemoryUpdater-{self.graph_id[:8]}"
)
self._worker_thread.start()
logger.info(f"ZepGraphMemoryUpdater 已启动: graph_id={self.graph_id}")
def stop(self):
"""停止后台工作线程"""
self._running = False
# 发送剩余的活动
self._flush_remaining()
if self._worker_thread and self._worker_thread.is_alive():
self._worker_thread.join(timeout=10)
logger.info(f"ZepGraphMemoryUpdater 已停止: graph_id={self.graph_id}, "
f"total_activities={self._total_activities}, "
f"batches_sent={self._total_sent}, "
f"items_sent={self._total_items_sent}, "
f"failed={self._failed_count}, "
f"skipped={self._skipped_count}")
def add_activity(self, activity: AgentActivity):
"""
添加一个agent活动到队列
所有有意义的行为都会被添加到队列包括
- CREATE_POST发帖
- CREATE_COMMENT评论
- QUOTE_POST引用帖子
- SEARCH_POSTS搜索帖子
- SEARCH_USER搜索用户
- LIKE_POST/DISLIKE_POST点赞/踩帖子
- REPOST转发
- FOLLOW关注
- MUTE屏蔽
- LIKE_COMMENT/DISLIKE_COMMENT点赞/踩评论
action_args中会包含完整的上下文信息如帖子原文用户名等
Args:
activity: Agent活动记录
"""
# 跳过DO_NOTHING类型的活动
if activity.action_type == "DO_NOTHING":
self._skipped_count += 1
return
self._activity_queue.put(activity)
self._total_activities += 1
logger.debug(f"添加活动到Zep队列: {activity.agent_name} - {activity.action_type}")
def add_activity_from_dict(self, data: Dict[str, Any], platform: str):
"""
从字典数据添加活动
Args:
data: 从actions.jsonl解析的字典数据
platform: 平台名称 (twitter/reddit)
"""
# 跳过事件类型的条目
if "event_type" in data:
return
activity = AgentActivity(
platform=platform,
agent_id=data.get("agent_id", 0),
agent_name=data.get("agent_name", ""),
action_type=data.get("action_type", ""),
action_args=data.get("action_args", {}),
round_num=data.get("round", 0),
timestamp=data.get("timestamp", datetime.now().isoformat()),
)
self.add_activity(activity)
def _worker_loop(self, locale: str = 'zh'):
"""后台工作循环 - 按平台批量发送活动到Zep"""
set_locale(locale)
while self._running or not self._activity_queue.empty():
try:
# 尝试从队列获取活动超时1秒
try:
activity = self._activity_queue.get(timeout=1)
# 将活动添加到对应平台的缓冲区
platform = activity.platform.lower()
with self._buffer_lock:
if platform not in self._platform_buffers:
self._platform_buffers[platform] = []
self._platform_buffers[platform].append(activity)
# 检查该平台是否达到批量大小
if len(self._platform_buffers[platform]) >= self.BATCH_SIZE:
batch = self._platform_buffers[platform][:self.BATCH_SIZE]
self._platform_buffers[platform] = self._platform_buffers[platform][self.BATCH_SIZE:]
# 释放锁后再发送
self._send_batch_activities(batch, platform)
# 发送间隔,避免请求过快
time.sleep(self.SEND_INTERVAL)
except Empty:
pass
except Exception as e:
logger.error(f"工作循环异常: {e}")
time.sleep(1)
def _send_batch_activities(self, activities: List[AgentActivity], platform: str):
"""
批量发送活动到Zep图谱合并为一条文本
Args:
activities: Agent活动列表
platform: 平台名称
"""
if not activities:
return
# 将多条活动合并为一条文本,用换行分隔
episode_texts = [activity.to_episode_text() for activity in activities]
combined_text = "\n".join(episode_texts)
# 带重试的发送
for attempt in range(self.MAX_RETRIES):
try:
self.client.graph.add(
graph_id=self.graph_id,
type="text",
data=combined_text
)
self._total_sent += 1
self._total_items_sent += len(activities)
display_name = self._get_platform_display_name(platform)
logger.info(f"成功批量发送 {len(activities)}{display_name}活动到图谱 {self.graph_id}")
logger.debug(f"批量内容预览: {combined_text[:200]}...")
return
except Exception as e:
if attempt < self.MAX_RETRIES - 1:
logger.warning(f"批量发送到Zep失败 (尝试 {attempt + 1}/{self.MAX_RETRIES}): {e}")
time.sleep(self.RETRY_DELAY * (attempt + 1))
else:
logger.error(f"批量发送到Zep失败已重试{self.MAX_RETRIES}次: {e}")
self._failed_count += 1
def _flush_remaining(self):
"""发送队列和缓冲区中剩余的活动"""
# 首先处理队列中剩余的活动,添加到缓冲区
while not self._activity_queue.empty():
try:
activity = self._activity_queue.get_nowait()
platform = activity.platform.lower()
with self._buffer_lock:
if platform not in self._platform_buffers:
self._platform_buffers[platform] = []
self._platform_buffers[platform].append(activity)
except Empty:
break
# 然后发送各平台缓冲区中剩余的活动即使不足BATCH_SIZE条
with self._buffer_lock:
for platform, buffer in self._platform_buffers.items():
if buffer:
display_name = self._get_platform_display_name(platform)
logger.info(f"发送{display_name}平台剩余的 {len(buffer)} 条活动")
self._send_batch_activities(buffer, platform)
# 清空所有缓冲区
for platform in self._platform_buffers:
self._platform_buffers[platform] = []
def get_stats(self) -> Dict[str, Any]:
"""获取统计信息"""
with self._buffer_lock:
buffer_sizes = {p: len(b) for p, b in self._platform_buffers.items()}
return {
"graph_id": self.graph_id,
"batch_size": self.BATCH_SIZE,
"total_activities": self._total_activities, # 添加到队列的活动总数
"batches_sent": self._total_sent, # 成功发送的批次数
"items_sent": self._total_items_sent, # 成功发送的活动条数
"failed_count": self._failed_count, # 发送失败的批次数
"skipped_count": self._skipped_count, # 被过滤跳过的活动数DO_NOTHING
"queue_size": self._activity_queue.qsize(),
"buffer_sizes": buffer_sizes, # 各平台缓冲区大小
"running": self._running,
}
class ZepGraphMemoryManager:
"""
管理多个模拟的Zep图谱记忆更新器
每个模拟可以有自己的更新器实例
"""
_updaters: Dict[str, ZepGraphMemoryUpdater] = {}
_lock = threading.Lock()
@classmethod
def create_updater(cls, simulation_id: str, graph_id: str) -> ZepGraphMemoryUpdater:
"""
为模拟创建图谱记忆更新器
Args:
simulation_id: 模拟ID
graph_id: Zep图谱ID
Returns:
ZepGraphMemoryUpdater实例
"""
with cls._lock:
# 如果已存在,先停止旧的
if simulation_id in cls._updaters:
cls._updaters[simulation_id].stop()
updater = ZepGraphMemoryUpdater(graph_id)
updater.start()
cls._updaters[simulation_id] = updater
logger.info(f"创建图谱记忆更新器: simulation_id={simulation_id}, graph_id={graph_id}")
return updater
@classmethod
def get_updater(cls, simulation_id: str) -> Optional[ZepGraphMemoryUpdater]:
"""获取模拟的更新器"""
return cls._updaters.get(simulation_id)
@classmethod
def stop_updater(cls, simulation_id: str):
"""停止并移除模拟的更新器"""
with cls._lock:
if simulation_id in cls._updaters:
cls._updaters[simulation_id].stop()
del cls._updaters[simulation_id]
logger.info(f"已停止图谱记忆更新器: simulation_id={simulation_id}")
# 防止 stop_all 重复调用的标志
_stop_all_done = False
@classmethod
def stop_all(cls):
"""停止所有更新器"""
# 防止重复调用
if cls._stop_all_done:
return
cls._stop_all_done = True
with cls._lock:
if cls._updaters:
for simulation_id, updater in list(cls._updaters.items()):
try:
updater.stop()
except Exception as e:
logger.error(f"停止更新器失败: simulation_id={simulation_id}, error={e}")
cls._updaters.clear()
logger.info("已停止所有图谱记忆更新器")
@classmethod
def get_all_stats(cls) -> Dict[str, Dict[str, Any]]:
"""获取所有更新器的统计信息"""
return {
sim_id: updater.get_stats()
for sim_id, updater in cls._updaters.items()
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,10 @@
"""
工具模块
"""
from .file_parser import FileParser
from .llm_client import LLMClient
from .locale import t, get_locale, set_locale, get_language_instruction
__all__ = ['FileParser', 'LLMClient', 't', 'get_locale', 'set_locale', 'get_language_instruction']

View File

@ -0,0 +1,203 @@
"""
文件解析工具
支持PDFMarkdownTXT文件的文本提取
"""
import os
from pathlib import Path
from typing import List, Optional
def _read_text_with_fallback(file_path: str) -> str:
"""
读取文本文件UTF-8失败时自动探测编码
采用多级回退策略
1. 首先尝试 UTF-8 解码
2. 使用 charset_normalizer 检测编码
3. 回退到 chardet 检测编码
4. 最终使用 UTF-8 + errors='replace' 兜底
Args:
file_path: 文件路径
Returns:
解码后的文本内容
"""
data = Path(file_path).read_bytes()
# 首先尝试 UTF-8
try:
return data.decode('utf-8')
except UnicodeDecodeError:
pass
# 尝试使用 charset_normalizer 检测编码
encoding = None
try:
from charset_normalizer import from_bytes
best = from_bytes(data).best()
if best and best.encoding:
encoding = best.encoding
except Exception:
pass
# 回退到 chardet
if not encoding:
try:
import chardet
result = chardet.detect(data)
encoding = result.get('encoding') if result else None
except Exception:
pass
# 最终兜底:使用 UTF-8 + replace
if not encoding:
encoding = 'utf-8'
return data.decode(encoding, errors='replace')
class FileParser:
"""文件解析器"""
SUPPORTED_EXTENSIONS = {'.pdf', '.md', '.markdown', '.txt'}
@classmethod
def is_supported(cls, file_path: str) -> bool:
"""
检查文件是否为支持的格式
Args:
file_path: 文件路径
Returns:
如果文件格式受支持则返回 True
"""
suffix = Path(file_path).suffix.lower()
return suffix in cls.SUPPORTED_EXTENSIONS
@classmethod
def extract_text(cls, file_path: str) -> str:
"""
从文件中提取文本
Args:
file_path: 文件路径
Returns:
提取的文本内容
"""
path = Path(file_path)
if not path.exists():
raise FileNotFoundError(f"文件不存在: {file_path}")
suffix = path.suffix.lower()
if suffix not in cls.SUPPORTED_EXTENSIONS:
raise ValueError(f"不支持的文件格式: {suffix}")
if suffix == '.pdf':
return cls._extract_from_pdf(file_path)
elif suffix in {'.md', '.markdown'}:
return cls._extract_from_md(file_path)
elif suffix == '.txt':
return cls._extract_from_txt(file_path)
raise ValueError(f"无法处理的文件格式: {suffix}")
@staticmethod
def _extract_from_pdf(file_path: str) -> str:
"""从PDF提取文本"""
try:
import fitz # PyMuPDF
except ImportError:
raise ImportError("需要安装PyMuPDF: pip install PyMuPDF")
text_parts = []
with fitz.open(file_path) as doc:
for page in doc:
text = page.get_text()
if text.strip():
text_parts.append(text)
return "\n\n".join(text_parts)
@staticmethod
def _extract_from_md(file_path: str) -> str:
"""从Markdown提取文本支持自动编码检测"""
return _read_text_with_fallback(file_path)
@staticmethod
def _extract_from_txt(file_path: str) -> str:
"""从TXT提取文本支持自动编码检测"""
return _read_text_with_fallback(file_path)
@classmethod
def extract_from_multiple(cls, file_paths: List[str]) -> str:
"""
从多个文件提取文本并合并
Args:
file_paths: 文件路径列表
Returns:
合并后的文本
"""
all_texts = []
for i, file_path in enumerate(file_paths, 1):
try:
text = cls.extract_text(file_path)
filename = Path(file_path).name
all_texts.append(f"=== 文档 {i}: {filename} ===\n{text}")
except Exception as e:
all_texts.append(f"=== 文档 {i}: {file_path} (提取失败: {str(e)}) ===")
return "\n\n".join(all_texts)
def split_text_into_chunks(
text: str,
chunk_size: int = 500,
overlap: int = 50
) -> List[str]:
"""
将文本分割成小块
Args:
text: 原始文本
chunk_size: 每块的字符数
overlap: 重叠字符数
Returns:
文本块列表
"""
if len(text) <= chunk_size:
return [text] if text.strip() else []
chunks = []
start = 0
while start < len(text):
end = start + chunk_size
# 尝试在句子边界处分割
if end < len(text):
# 查找最近的句子结束符
for sep in ['', '', '', '.\n', '!\n', '?\n', '\n\n', '. ', '! ', '? ']:
last_sep = text[start:end].rfind(sep)
if last_sep != -1 and last_sep > chunk_size * 0.3:
end = start + last_sep + len(sep)
break
chunk = text[start:end].strip()
if chunk:
chunks.append(chunk)
# 下一个块从重叠位置开始
start = end - overlap if end < len(text) else len(text)
return chunks

View File

@ -0,0 +1,103 @@
"""
LLM客户端封装
统一使用OpenAI格式调用
"""
import json
import re
from typing import Optional, Dict, Any, List
from openai import OpenAI
from ..config import Config
class LLMClient:
"""LLM客户端"""
def __init__(
self,
api_key: Optional[str] = None,
base_url: Optional[str] = None,
model: Optional[str] = None
):
self.api_key = api_key or Config.LLM_API_KEY
self.base_url = base_url or Config.LLM_BASE_URL
self.model = model or Config.LLM_MODEL_NAME
if not self.api_key:
raise ValueError("LLM_API_KEY 未配置")
self.client = OpenAI(
api_key=self.api_key,
base_url=self.base_url
)
def chat(
self,
messages: List[Dict[str, str]],
temperature: float = 0.7,
max_tokens: int = 4096,
response_format: Optional[Dict] = None
) -> str:
"""
发送聊天请求
Args:
messages: 消息列表
temperature: 温度参数
max_tokens: 最大token数
response_format: 响应格式如JSON模式
Returns:
模型响应文本
"""
kwargs = {
"model": self.model,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
}
if response_format:
kwargs["response_format"] = response_format
response = self.client.chat.completions.create(**kwargs)
content = response.choices[0].message.content
# 部分模型如MiniMax M2.5会在content中包含<think>思考内容,需要移除
content = re.sub(r'<think>[\s\S]*?</think>', '', content).strip()
return content
def chat_json(
self,
messages: List[Dict[str, str]],
temperature: float = 0.3,
max_tokens: int = 4096
) -> Dict[str, Any]:
"""
发送聊天请求并返回JSON
Args:
messages: 消息列表
temperature: 温度参数
max_tokens: 最大token数
Returns:
解析后的JSON对象
"""
response = self.chat(
messages=messages,
temperature=temperature,
max_tokens=max_tokens,
response_format={"type": "json_object"}
)
# 清理markdown代码块标记
cleaned_response = response.strip()
cleaned_response = re.sub(r'^```(?:json)?\s*\n?', '', cleaned_response, flags=re.IGNORECASE)
cleaned_response = re.sub(r'\n?```\s*$', '', cleaned_response)
cleaned_response = cleaned_response.strip()
try:
return json.loads(cleaned_response)
except json.JSONDecodeError:
raise ValueError(f"LLM返回的JSON格式无效: {cleaned_response}")

View File

@ -0,0 +1,69 @@
import json
import os
import threading
from flask import request, has_request_context
_thread_local = threading.local()
_locales_dir = os.path.join(os.path.dirname(__file__), '..', '..', '..', 'locales')
# Load language registry
with open(os.path.join(_locales_dir, 'languages.json'), 'r', encoding='utf-8') as f:
_languages = json.load(f)
# Load translation files
_translations = {}
for filename in os.listdir(_locales_dir):
if filename.endswith('.json') and filename != 'languages.json':
locale_name = filename[:-5]
with open(os.path.join(_locales_dir, filename), 'r', encoding='utf-8') as f:
_translations[locale_name] = json.load(f)
def set_locale(locale: str):
"""Set locale for current thread. Call at the start of background threads."""
_thread_local.locale = locale
def get_locale() -> str:
if has_request_context():
raw = request.headers.get('Accept-Language', 'zh')
return raw if raw in _translations else 'zh'
return getattr(_thread_local, 'locale', 'zh')
def t(key: str, **kwargs) -> str:
locale = get_locale()
messages = _translations.get(locale, _translations.get('zh', {}))
value = messages
for part in key.split('.'):
if isinstance(value, dict):
value = value.get(part)
else:
value = None
break
if value is None:
value = _translations.get('zh', {})
for part in key.split('.'):
if isinstance(value, dict):
value = value.get(part)
else:
value = None
break
if value is None:
return key
if kwargs:
for k, v in kwargs.items():
value = value.replace(f'{{{k}}}', str(v))
return value
def get_language_instruction() -> str:
locale = get_locale()
lang_config = _languages.get(locale, _languages.get('zh', {}))
return lang_config.get('llmInstruction', '请使用中文回答。')

126
backend/app/utils/logger.py Normal file
View File

@ -0,0 +1,126 @@
"""
日志配置模块
提供统一的日志管理同时输出到控制台和文件
"""
import os
import sys
import logging
from datetime import datetime
from logging.handlers import RotatingFileHandler
def _ensure_utf8_stdout():
"""
确保 stdout/stderr 使用 UTF-8 编码
解决 Windows 控制台中文乱码问题
"""
if sys.platform == 'win32':
# Windows 下重新配置标准输出为 UTF-8
if hasattr(sys.stdout, 'reconfigure'):
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
if hasattr(sys.stderr, 'reconfigure'):
sys.stderr.reconfigure(encoding='utf-8', errors='replace')
# 日志目录
LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'logs')
def setup_logger(name: str = 'mirofish', level: int = logging.DEBUG) -> logging.Logger:
"""
设置日志器
Args:
name: 日志器名称
level: 日志级别
Returns:
配置好的日志器
"""
# 确保日志目录存在
os.makedirs(LOG_DIR, exist_ok=True)
# 创建日志器
logger = logging.getLogger(name)
logger.setLevel(level)
# 阻止日志向上传播到根 logger避免重复输出
logger.propagate = False
# 如果已经有处理器,不重复添加
if logger.handlers:
return logger
# 日志格式
detailed_formatter = logging.Formatter(
'[%(asctime)s] %(levelname)s [%(name)s.%(funcName)s:%(lineno)d] %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
simple_formatter = logging.Formatter(
'[%(asctime)s] %(levelname)s: %(message)s',
datefmt='%H:%M:%S'
)
# 1. 文件处理器 - 详细日志(按日期命名,带轮转)
log_filename = datetime.now().strftime('%Y-%m-%d') + '.log'
file_handler = RotatingFileHandler(
os.path.join(LOG_DIR, log_filename),
maxBytes=10 * 1024 * 1024, # 10MB
backupCount=5,
encoding='utf-8'
)
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(detailed_formatter)
# 2. 控制台处理器 - 简洁日志INFO及以上
# 确保 Windows 下使用 UTF-8 编码,避免中文乱码
_ensure_utf8_stdout()
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setLevel(logging.INFO)
console_handler.setFormatter(simple_formatter)
# 添加处理器
logger.addHandler(file_handler)
logger.addHandler(console_handler)
return logger
def get_logger(name: str = 'mirofish') -> logging.Logger:
"""
获取日志器如果不存在则创建
Args:
name: 日志器名称
Returns:
日志器实例
"""
logger = logging.getLogger(name)
if not logger.handlers:
return setup_logger(name)
return logger
# 创建默认日志器
logger = setup_logger()
# 便捷方法
def debug(msg: str, *args, **kwargs) -> None:
logger.debug(msg, *args, **kwargs)
def info(msg: str, *args, **kwargs) -> None:
logger.info(msg, *args, **kwargs)
def warning(msg: str, *args, **kwargs) -> None:
logger.warning(msg, *args, **kwargs)
def error(msg: str, *args, **kwargs) -> None:
logger.error(msg, *args, **kwargs)
def critical(msg: str, *args, **kwargs) -> None:
logger.critical(msg, *args, **kwargs)

238
backend/app/utils/retry.py Normal file
View File

@ -0,0 +1,238 @@
"""
API调用重试机制
用于处理LLM等外部API调用的重试逻辑
"""
import time
import random
import functools
from typing import Callable, Any, Optional, Type, Tuple
from ..utils.logger import get_logger
logger = get_logger('mirofish.retry')
def retry_with_backoff(
max_retries: int = 3,
initial_delay: float = 1.0,
max_delay: float = 30.0,
backoff_factor: float = 2.0,
jitter: bool = True,
exceptions: Tuple[Type[Exception], ...] = (Exception,),
on_retry: Optional[Callable[[Exception, int], None]] = None
):
"""
带指数退避的重试装饰器
Args:
max_retries: 最大重试次数
initial_delay: 初始延迟
max_delay: 最大延迟
backoff_factor: 退避因子
jitter: 是否添加随机抖动
exceptions: 需要重试的异常类型
on_retry: 重试时的回调函数 (exception, retry_count)
Usage:
@retry_with_backoff(max_retries=3)
def call_llm_api():
...
"""
def decorator(func: Callable) -> Callable:
@functools.wraps(func)
def wrapper(*args, **kwargs) -> Any:
last_exception = None
delay = initial_delay
for attempt in range(max_retries + 1):
try:
return func(*args, **kwargs)
except exceptions as e:
last_exception = e
if attempt == max_retries:
logger.error(f"函数 {func.__name__}{max_retries} 次重试后仍失败: {str(e)}")
raise
# 计算延迟
current_delay = min(delay, max_delay)
if jitter:
current_delay = current_delay * (0.5 + random.random())
logger.warning(
f"函数 {func.__name__}{attempt + 1} 次尝试失败: {str(e)}, "
f"{current_delay:.1f}秒后重试..."
)
if on_retry:
on_retry(e, attempt + 1)
time.sleep(current_delay)
delay *= backoff_factor
raise last_exception
return wrapper
return decorator
def retry_with_backoff_async(
max_retries: int = 3,
initial_delay: float = 1.0,
max_delay: float = 30.0,
backoff_factor: float = 2.0,
jitter: bool = True,
exceptions: Tuple[Type[Exception], ...] = (Exception,),
on_retry: Optional[Callable[[Exception, int], None]] = None
):
"""
异步版本的重试装饰器
"""
import asyncio
def decorator(func: Callable) -> Callable:
@functools.wraps(func)
async def wrapper(*args, **kwargs) -> Any:
last_exception = None
delay = initial_delay
for attempt in range(max_retries + 1):
try:
return await func(*args, **kwargs)
except exceptions as e:
last_exception = e
if attempt == max_retries:
logger.error(f"异步函数 {func.__name__}{max_retries} 次重试后仍失败: {str(e)}")
raise
current_delay = min(delay, max_delay)
if jitter:
current_delay = current_delay * (0.5 + random.random())
logger.warning(
f"异步函数 {func.__name__}{attempt + 1} 次尝试失败: {str(e)}, "
f"{current_delay:.1f}秒后重试..."
)
if on_retry:
on_retry(e, attempt + 1)
await asyncio.sleep(current_delay)
delay *= backoff_factor
raise last_exception
return wrapper
return decorator
class RetryableAPIClient:
"""
可重试的API客户端封装
"""
def __init__(
self,
max_retries: int = 3,
initial_delay: float = 1.0,
max_delay: float = 30.0,
backoff_factor: float = 2.0
):
self.max_retries = max_retries
self.initial_delay = initial_delay
self.max_delay = max_delay
self.backoff_factor = backoff_factor
def call_with_retry(
self,
func: Callable,
*args,
exceptions: Tuple[Type[Exception], ...] = (Exception,),
**kwargs
) -> Any:
"""
执行函数调用并在失败时重试
Args:
func: 要调用的函数
*args: 函数参数
exceptions: 需要重试的异常类型
**kwargs: 函数关键字参数
Returns:
函数返回值
"""
last_exception = None
delay = self.initial_delay
for attempt in range(self.max_retries + 1):
try:
return func(*args, **kwargs)
except exceptions as e:
last_exception = e
if attempt == self.max_retries:
logger.error(f"API调用在 {self.max_retries} 次重试后仍失败: {str(e)}")
raise
current_delay = min(delay, self.max_delay)
current_delay = current_delay * (0.5 + random.random())
logger.warning(
f"API调用第 {attempt + 1} 次尝试失败: {str(e)}, "
f"{current_delay:.1f}秒后重试..."
)
time.sleep(current_delay)
delay *= self.backoff_factor
raise last_exception
def call_batch_with_retry(
self,
items: list,
process_func: Callable,
exceptions: Tuple[Type[Exception], ...] = (Exception,),
continue_on_failure: bool = True
) -> Tuple[list, list]:
"""
批量调用并对每个失败项单独重试
Args:
items: 要处理的项目列表
process_func: 处理函数接收单个item作为参数
exceptions: 需要重试的异常类型
continue_on_failure: 单项失败后是否继续处理其他项
Returns:
(成功结果列表, 失败项列表)
"""
results = []
failures = []
for idx, item in enumerate(items):
try:
result = self.call_with_retry(
process_func,
item,
exceptions=exceptions
)
results.append(result)
except Exception as e:
logger.error(f"处理第 {idx + 1} 项失败: {str(e)}")
failures.append({
"index": idx,
"item": item,
"error": str(e)
})
if not continue_on_failure:
raise
return results, failures

View File

@ -0,0 +1,143 @@
"""Zep Graph 分页读取工具。
Zep node/edge 列表接口使用 UUID cursor 分页
本模块封装自动翻页逻辑含单页重试对调用方透明地返回完整列表
"""
from __future__ import annotations
import time
from collections.abc import Callable
from typing import Any
from zep_cloud import InternalServerError
from zep_cloud.client import Zep
from .logger import get_logger
logger = get_logger('mirofish.zep_paging')
_DEFAULT_PAGE_SIZE = 100
_MAX_NODES = 2000
_DEFAULT_MAX_RETRIES = 3
_DEFAULT_RETRY_DELAY = 2.0 # seconds, doubles each retry
def _fetch_page_with_retry(
api_call: Callable[..., list[Any]],
*args: Any,
max_retries: int = _DEFAULT_MAX_RETRIES,
retry_delay: float = _DEFAULT_RETRY_DELAY,
page_description: str = "page",
**kwargs: Any,
) -> list[Any]:
"""单页请求,失败时指数退避重试。仅重试网络/IO类瞬态错误。"""
if max_retries < 1:
raise ValueError("max_retries must be >= 1")
last_exception: Exception | None = None
delay = retry_delay
for attempt in range(max_retries):
try:
return api_call(*args, **kwargs)
except (ConnectionError, TimeoutError, OSError, InternalServerError) as e:
last_exception = e
if attempt < max_retries - 1:
logger.warning(
f"Zep {page_description} attempt {attempt + 1} failed: {str(e)[:100]}, retrying in {delay:.1f}s..."
)
time.sleep(delay)
delay *= 2
else:
logger.error(f"Zep {page_description} failed after {max_retries} attempts: {str(e)}")
assert last_exception is not None
raise last_exception
def fetch_all_nodes(
client: Zep,
graph_id: str,
page_size: int = _DEFAULT_PAGE_SIZE,
max_items: int = _MAX_NODES,
max_retries: int = _DEFAULT_MAX_RETRIES,
retry_delay: float = _DEFAULT_RETRY_DELAY,
) -> list[Any]:
"""分页获取图谱节点,最多返回 max_items 条(默认 2000。每页请求自带重试。"""
all_nodes: list[Any] = []
cursor: str | None = None
page_num = 0
while True:
kwargs: dict[str, Any] = {"limit": page_size}
if cursor is not None:
kwargs["uuid_cursor"] = cursor
page_num += 1
batch = _fetch_page_with_retry(
client.graph.node.get_by_graph_id,
graph_id,
max_retries=max_retries,
retry_delay=retry_delay,
page_description=f"fetch nodes page {page_num} (graph={graph_id})",
**kwargs,
)
if not batch:
break
all_nodes.extend(batch)
if len(all_nodes) >= max_items:
all_nodes = all_nodes[:max_items]
logger.warning(f"Node count reached limit ({max_items}), stopping pagination for graph {graph_id}")
break
if len(batch) < page_size:
break
cursor = getattr(batch[-1], "uuid_", None) or getattr(batch[-1], "uuid", None)
if cursor is None:
logger.warning(f"Node missing uuid field, stopping pagination at {len(all_nodes)} nodes")
break
return all_nodes
def fetch_all_edges(
client: Zep,
graph_id: str,
page_size: int = _DEFAULT_PAGE_SIZE,
max_retries: int = _DEFAULT_MAX_RETRIES,
retry_delay: float = _DEFAULT_RETRY_DELAY,
) -> list[Any]:
"""分页获取图谱所有边,返回完整列表。每页请求自带重试。"""
all_edges: list[Any] = []
cursor: str | None = None
page_num = 0
while True:
kwargs: dict[str, Any] = {"limit": page_size}
if cursor is not None:
kwargs["uuid_cursor"] = cursor
page_num += 1
batch = _fetch_page_with_retry(
client.graph.edge.get_by_graph_id,
graph_id,
max_retries=max_retries,
retry_delay=retry_delay,
page_description=f"fetch edges page {page_num} (graph={graph_id})",
**kwargs,
)
if not batch:
break
all_edges.extend(batch)
if len(batch) < page_size:
break
cursor = getattr(batch[-1], "uuid_", None) or getattr(batch[-1], "uuid", None)
if cursor is None:
logger.warning(f"Edge missing uuid field, stopping pagination at {len(all_edges)} edges")
break
return all_edges

55
backend/pyproject.toml Normal file
View File

@ -0,0 +1,55 @@
[project]
name = "mirofish-backend"
version = "0.1.0"
description = "MiroFish - 简洁通用的群体智能引擎,预测万物"
requires-python = ">=3.11,<3.13"
license = { text = "AGPL-3.0" }
authors = [
{ name = "MiroFish Team" }
]
dependencies = [
# 核心框架
"flask>=3.0.0",
"flask-cors>=6.0.0",
# LLM 相关
"openai>=1.0.0",
# Zep Cloud
"zep-cloud==3.13.0",
# OASIS 社交媒体模拟
"camel-oasis==0.2.5",
"camel-ai==0.2.78",
# 文件处理
"PyMuPDF>=1.24.0",
# 编码检测支持非UTF-8编码的文本文件
"charset-normalizer>=3.0.0",
"chardet>=5.0.0",
# 工具库
"python-dotenv>=1.0.0",
"pydantic>=2.0.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0.0",
"pytest-asyncio>=0.23.0",
"pipreqs>=0.5.0",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[dependency-groups]
dev = [
"pytest>=8.0.0",
"pytest-asyncio>=0.23.0",
]
[tool.hatch.build.targets.wheel]
packages = ["app"]

35
backend/requirements.txt Normal file
View File

@ -0,0 +1,35 @@
# ===========================================
# MiroFish Backend Dependencies
# ===========================================
# Python 3.11+ required
# Install: pip install -r requirements.txt
# ===========================================
# ============= 核心框架 =============
flask>=3.0.0
flask-cors>=6.0.0
# ============= LLM 相关 =============
# OpenAI SDK统一使用 OpenAI 格式调用 LLM
openai>=1.0.0
# ============= Zep Cloud =============
zep-cloud==3.13.0
# ============= OASIS 社交媒体模拟 =============
# OASIS 社交模拟框架
camel-oasis==0.2.5
camel-ai==0.2.78
# ============= 文件处理 =============
PyMuPDF>=1.24.0
# 编码检测支持非UTF-8编码的文本文件
charset-normalizer>=3.0.0
chardet>=5.0.0
# ============= 工具库 =============
# 环境变量加载
python-dotenv>=1.0.0
# 数据验证
pydantic>=2.0.0

50
backend/run.py Normal file
View File

@ -0,0 +1,50 @@
"""
MiroFish Backend 启动入口
"""
import os
import sys
# 解决 Windows 控制台中文乱码问题:在所有导入之前设置 UTF-8 编码
if sys.platform == 'win32':
# 设置环境变量确保 Python 使用 UTF-8
os.environ.setdefault('PYTHONIOENCODING', 'utf-8')
# 重新配置标准输出流为 UTF-8
if hasattr(sys.stdout, 'reconfigure'):
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
if hasattr(sys.stderr, 'reconfigure'):
sys.stderr.reconfigure(encoding='utf-8', errors='replace')
# 添加项目根目录到路径
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from app import create_app
from app.config import Config
def main():
"""主函数"""
# 验证配置
errors = Config.validate()
if errors:
print("配置错误:")
for err in errors:
print(f" - {err}")
print("\n请检查 .env 文件中的配置")
sys.exit(1)
# 创建应用
app = create_app()
# 获取运行配置
host = os.environ.get('FLASK_HOST', '0.0.0.0')
port = int(os.environ.get('FLASK_PORT', 5001))
debug = Config.DEBUG
# 启动服务
app.run(host=host, port=port, debug=debug, threaded=True)
if __name__ == '__main__':
main()

View File

@ -0,0 +1,305 @@
"""
动作日志记录器
用于记录OASIS模拟中每个Agent的动作供后端监控使用
日志结构:
sim_xxx/
twitter/
actions.jsonl # Twitter 平台动作日志
reddit/
actions.jsonl # Reddit 平台动作日志
simulation.log # 主模拟进程日志
run_state.json # 运行状态API 查询用)
"""
import json
import os
import logging
from datetime import datetime
from typing import Dict, Any, Optional
class PlatformActionLogger:
"""单平台动作日志记录器"""
def __init__(self, platform: str, base_dir: str):
"""
初始化日志记录器
Args:
platform: 平台名称 (twitter/reddit)
base_dir: 模拟目录的基础路径
"""
self.platform = platform
self.base_dir = base_dir
self.log_dir = os.path.join(base_dir, platform)
self.log_path = os.path.join(self.log_dir, "actions.jsonl")
self._ensure_dir()
def _ensure_dir(self):
"""确保目录存在"""
os.makedirs(self.log_dir, exist_ok=True)
def log_action(
self,
round_num: int,
agent_id: int,
agent_name: str,
action_type: str,
action_args: Optional[Dict[str, Any]] = None,
result: Optional[str] = None,
success: bool = True
):
"""记录一个动作"""
entry = {
"round": round_num,
"timestamp": datetime.now().isoformat(),
"agent_id": agent_id,
"agent_name": agent_name,
"action_type": action_type,
"action_args": action_args or {},
"result": result,
"success": success,
}
with open(self.log_path, 'a', encoding='utf-8') as f:
f.write(json.dumps(entry, ensure_ascii=False) + '\n')
def log_round_start(self, round_num: int, simulated_hour: int):
"""记录轮次开始"""
entry = {
"round": round_num,
"timestamp": datetime.now().isoformat(),
"event_type": "round_start",
"simulated_hour": simulated_hour,
}
with open(self.log_path, 'a', encoding='utf-8') as f:
f.write(json.dumps(entry, ensure_ascii=False) + '\n')
def log_round_end(self, round_num: int, actions_count: int):
"""记录轮次结束"""
entry = {
"round": round_num,
"timestamp": datetime.now().isoformat(),
"event_type": "round_end",
"actions_count": actions_count,
}
with open(self.log_path, 'a', encoding='utf-8') as f:
f.write(json.dumps(entry, ensure_ascii=False) + '\n')
def log_simulation_start(self, config: Dict[str, Any]):
"""记录模拟开始"""
entry = {
"timestamp": datetime.now().isoformat(),
"event_type": "simulation_start",
"platform": self.platform,
"total_rounds": config.get("time_config", {}).get("total_simulation_hours", 72) * 2,
"agents_count": len(config.get("agent_configs", [])),
}
with open(self.log_path, 'a', encoding='utf-8') as f:
f.write(json.dumps(entry, ensure_ascii=False) + '\n')
def log_simulation_end(self, total_rounds: int, total_actions: int):
"""记录模拟结束"""
entry = {
"timestamp": datetime.now().isoformat(),
"event_type": "simulation_end",
"platform": self.platform,
"total_rounds": total_rounds,
"total_actions": total_actions,
}
with open(self.log_path, 'a', encoding='utf-8') as f:
f.write(json.dumps(entry, ensure_ascii=False) + '\n')
class SimulationLogManager:
"""
模拟日志管理器
统一管理所有日志文件按平台分离
"""
def __init__(self, simulation_dir: str):
"""
初始化日志管理器
Args:
simulation_dir: 模拟目录路径
"""
self.simulation_dir = simulation_dir
self.twitter_logger: Optional[PlatformActionLogger] = None
self.reddit_logger: Optional[PlatformActionLogger] = None
self._main_logger: Optional[logging.Logger] = None
# 设置主日志
self._setup_main_logger()
def _setup_main_logger(self):
"""设置主模拟日志"""
log_path = os.path.join(self.simulation_dir, "simulation.log")
# 创建 logger
self._main_logger = logging.getLogger(f"simulation.{os.path.basename(self.simulation_dir)}")
self._main_logger.setLevel(logging.INFO)
self._main_logger.handlers.clear()
# 文件处理器
file_handler = logging.FileHandler(log_path, encoding='utf-8', mode='w')
file_handler.setLevel(logging.INFO)
file_handler.setFormatter(logging.Formatter(
'%(asctime)s - %(levelname)s - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
))
self._main_logger.addHandler(file_handler)
# 控制台处理器
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.INFO)
console_handler.setFormatter(logging.Formatter(
'[%(asctime)s] %(message)s',
datefmt='%H:%M:%S'
))
self._main_logger.addHandler(console_handler)
self._main_logger.propagate = False
def get_twitter_logger(self) -> PlatformActionLogger:
"""获取 Twitter 平台日志记录器"""
if self.twitter_logger is None:
self.twitter_logger = PlatformActionLogger("twitter", self.simulation_dir)
return self.twitter_logger
def get_reddit_logger(self) -> PlatformActionLogger:
"""获取 Reddit 平台日志记录器"""
if self.reddit_logger is None:
self.reddit_logger = PlatformActionLogger("reddit", self.simulation_dir)
return self.reddit_logger
def log(self, message: str, level: str = "info"):
"""记录主日志"""
if self._main_logger:
getattr(self._main_logger, level.lower(), self._main_logger.info)(message)
def info(self, message: str):
self.log(message, "info")
def warning(self, message: str):
self.log(message, "warning")
def error(self, message: str):
self.log(message, "error")
def debug(self, message: str):
self.log(message, "debug")
# ============ 兼容旧接口 ============
class ActionLogger:
"""
动作日志记录器兼容旧接口
建议使用 SimulationLogManager 代替
"""
def __init__(self, log_path: str):
self.log_path = log_path
self._ensure_dir()
def _ensure_dir(self):
log_dir = os.path.dirname(self.log_path)
if log_dir:
os.makedirs(log_dir, exist_ok=True)
def log_action(
self,
round_num: int,
platform: str,
agent_id: int,
agent_name: str,
action_type: str,
action_args: Optional[Dict[str, Any]] = None,
result: Optional[str] = None,
success: bool = True
):
entry = {
"round": round_num,
"timestamp": datetime.now().isoformat(),
"platform": platform,
"agent_id": agent_id,
"agent_name": agent_name,
"action_type": action_type,
"action_args": action_args or {},
"result": result,
"success": success,
}
with open(self.log_path, 'a', encoding='utf-8') as f:
f.write(json.dumps(entry, ensure_ascii=False) + '\n')
def log_round_start(self, round_num: int, simulated_hour: int, platform: str):
entry = {
"round": round_num,
"timestamp": datetime.now().isoformat(),
"platform": platform,
"event_type": "round_start",
"simulated_hour": simulated_hour,
}
with open(self.log_path, 'a', encoding='utf-8') as f:
f.write(json.dumps(entry, ensure_ascii=False) + '\n')
def log_round_end(self, round_num: int, actions_count: int, platform: str):
entry = {
"round": round_num,
"timestamp": datetime.now().isoformat(),
"platform": platform,
"event_type": "round_end",
"actions_count": actions_count,
}
with open(self.log_path, 'a', encoding='utf-8') as f:
f.write(json.dumps(entry, ensure_ascii=False) + '\n')
def log_simulation_start(self, platform: str, config: Dict[str, Any]):
entry = {
"timestamp": datetime.now().isoformat(),
"platform": platform,
"event_type": "simulation_start",
"total_rounds": config.get("time_config", {}).get("total_simulation_hours", 72) * 2,
"agents_count": len(config.get("agent_configs", [])),
}
with open(self.log_path, 'a', encoding='utf-8') as f:
f.write(json.dumps(entry, ensure_ascii=False) + '\n')
def log_simulation_end(self, platform: str, total_rounds: int, total_actions: int):
entry = {
"timestamp": datetime.now().isoformat(),
"platform": platform,
"event_type": "simulation_end",
"total_rounds": total_rounds,
"total_actions": total_actions,
}
with open(self.log_path, 'a', encoding='utf-8') as f:
f.write(json.dumps(entry, ensure_ascii=False) + '\n')
# 全局日志实例(兼容旧接口)
_global_logger: Optional[ActionLogger] = None
def get_logger(log_path: Optional[str] = None) -> ActionLogger:
"""获取全局日志实例(兼容旧接口)"""
global _global_logger
if log_path:
_global_logger = ActionLogger(log_path)
if _global_logger is None:
_global_logger = ActionLogger("actions.jsonl")
return _global_logger

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,769 @@
"""
OASIS Reddit模拟预设脚本
此脚本读取配置文件中的参数来执行模拟实现全程自动化
功能特性:
- 完成模拟后不立即关闭环境进入等待命令模式
- 支持通过IPC接收Interview命令
- 支持单个Agent采访和批量采访
- 支持远程关闭环境命令
使用方式:
python run_reddit_simulation.py --config /path/to/simulation_config.json
python run_reddit_simulation.py --config /path/to/simulation_config.json --no-wait # 完成后立即关闭
"""
import argparse
import asyncio
import json
import logging
import os
import random
import signal
import sys
import sqlite3
from datetime import datetime
from typing import Dict, Any, List, Optional
# 全局变量:用于信号处理
_shutdown_event = None
_cleanup_done = False
# 添加项目路径
_scripts_dir = os.path.dirname(os.path.abspath(__file__))
_backend_dir = os.path.abspath(os.path.join(_scripts_dir, '..'))
_project_root = os.path.abspath(os.path.join(_backend_dir, '..'))
sys.path.insert(0, _scripts_dir)
sys.path.insert(0, _backend_dir)
# 加载项目根目录的 .env 文件(包含 LLM_API_KEY 等配置)
from dotenv import load_dotenv
_env_file = os.path.join(_project_root, '.env')
if os.path.exists(_env_file):
load_dotenv(_env_file)
else:
_backend_env = os.path.join(_backend_dir, '.env')
if os.path.exists(_backend_env):
load_dotenv(_backend_env)
import re
class UnicodeFormatter(logging.Formatter):
"""自定义格式化器,将 Unicode 转义序列转换为可读字符"""
UNICODE_ESCAPE_PATTERN = re.compile(r'\\u([0-9a-fA-F]{4})')
def format(self, record):
result = super().format(record)
def replace_unicode(match):
try:
return chr(int(match.group(1), 16))
except (ValueError, OverflowError):
return match.group(0)
return self.UNICODE_ESCAPE_PATTERN.sub(replace_unicode, result)
class MaxTokensWarningFilter(logging.Filter):
"""过滤掉 camel-ai 关于 max_tokens 的警告(我们故意不设置 max_tokens让模型自行决定"""
def filter(self, record):
# 过滤掉包含 max_tokens 警告的日志
if "max_tokens" in record.getMessage() and "Invalid or missing" in record.getMessage():
return False
return True
# 在模块加载时立即添加过滤器,确保在 camel 代码执行前生效
logging.getLogger().addFilter(MaxTokensWarningFilter())
def setup_oasis_logging(log_dir: str):
"""配置 OASIS 的日志,使用固定名称的日志文件"""
os.makedirs(log_dir, exist_ok=True)
# 清理旧的日志文件
for f in os.listdir(log_dir):
old_log = os.path.join(log_dir, f)
if os.path.isfile(old_log) and f.endswith('.log'):
try:
os.remove(old_log)
except OSError:
pass
formatter = UnicodeFormatter("%(levelname)s - %(asctime)s - %(name)s - %(message)s")
loggers_config = {
"social.agent": os.path.join(log_dir, "social.agent.log"),
"social.twitter": os.path.join(log_dir, "social.twitter.log"),
"social.rec": os.path.join(log_dir, "social.rec.log"),
"oasis.env": os.path.join(log_dir, "oasis.env.log"),
"table": os.path.join(log_dir, "table.log"),
}
for logger_name, log_file in loggers_config.items():
logger = logging.getLogger(logger_name)
logger.setLevel(logging.DEBUG)
logger.handlers.clear()
file_handler = logging.FileHandler(log_file, encoding='utf-8', mode='w')
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
logger.propagate = False
try:
from camel.models import ModelFactory
from camel.types import ModelPlatformType
import oasis
from oasis import (
ActionType,
LLMAction,
ManualAction,
generate_reddit_agent_graph
)
except ImportError as e:
print(f"错误: 缺少依赖 {e}")
print("请先安装: pip install oasis-ai camel-ai")
sys.exit(1)
# IPC相关常量
IPC_COMMANDS_DIR = "ipc_commands"
IPC_RESPONSES_DIR = "ipc_responses"
ENV_STATUS_FILE = "env_status.json"
class CommandType:
"""命令类型常量"""
INTERVIEW = "interview"
BATCH_INTERVIEW = "batch_interview"
CLOSE_ENV = "close_env"
class IPCHandler:
"""IPC命令处理器"""
def __init__(self, simulation_dir: str, env, agent_graph):
self.simulation_dir = simulation_dir
self.env = env
self.agent_graph = agent_graph
self.commands_dir = os.path.join(simulation_dir, IPC_COMMANDS_DIR)
self.responses_dir = os.path.join(simulation_dir, IPC_RESPONSES_DIR)
self.status_file = os.path.join(simulation_dir, ENV_STATUS_FILE)
self._running = True
# 确保目录存在
os.makedirs(self.commands_dir, exist_ok=True)
os.makedirs(self.responses_dir, exist_ok=True)
def update_status(self, status: str):
"""更新环境状态"""
with open(self.status_file, 'w', encoding='utf-8') as f:
json.dump({
"status": status,
"timestamp": datetime.now().isoformat()
}, f, ensure_ascii=False, indent=2)
def poll_command(self) -> Optional[Dict[str, Any]]:
"""轮询获取待处理命令"""
if not os.path.exists(self.commands_dir):
return None
# 获取命令文件(按时间排序)
command_files = []
for filename in os.listdir(self.commands_dir):
if filename.endswith('.json'):
filepath = os.path.join(self.commands_dir, filename)
command_files.append((filepath, os.path.getmtime(filepath)))
command_files.sort(key=lambda x: x[1])
for filepath, _ in command_files:
try:
with open(filepath, 'r', encoding='utf-8') as f:
return json.load(f)
except (json.JSONDecodeError, OSError):
continue
return None
def send_response(self, command_id: str, status: str, result: Dict = None, error: str = None):
"""发送响应"""
response = {
"command_id": command_id,
"status": status,
"result": result,
"error": error,
"timestamp": datetime.now().isoformat()
}
response_file = os.path.join(self.responses_dir, f"{command_id}.json")
with open(response_file, 'w', encoding='utf-8') as f:
json.dump(response, f, ensure_ascii=False, indent=2)
# 删除命令文件
command_file = os.path.join(self.commands_dir, f"{command_id}.json")
try:
os.remove(command_file)
except OSError:
pass
async def handle_interview(self, command_id: str, agent_id: int, prompt: str) -> bool:
"""
处理单个Agent采访命令
Returns:
True 表示成功False 表示失败
"""
try:
# 获取Agent
agent = self.agent_graph.get_agent(agent_id)
# 创建Interview动作
interview_action = ManualAction(
action_type=ActionType.INTERVIEW,
action_args={"prompt": prompt}
)
# 执行Interview
actions = {agent: interview_action}
await self.env.step(actions)
# 从数据库获取结果
result = self._get_interview_result(agent_id)
self.send_response(command_id, "completed", result=result)
print(f" Interview完成: agent_id={agent_id}")
return True
except Exception as e:
error_msg = str(e)
print(f" Interview失败: agent_id={agent_id}, error={error_msg}")
self.send_response(command_id, "failed", error=error_msg)
return False
async def handle_batch_interview(self, command_id: str, interviews: List[Dict]) -> bool:
"""
处理批量采访命令
Args:
interviews: [{"agent_id": int, "prompt": str}, ...]
"""
try:
# 构建动作字典
actions = {}
agent_prompts = {} # 记录每个agent的prompt
for interview in interviews:
agent_id = interview.get("agent_id")
prompt = interview.get("prompt", "")
try:
agent = self.agent_graph.get_agent(agent_id)
actions[agent] = ManualAction(
action_type=ActionType.INTERVIEW,
action_args={"prompt": prompt}
)
agent_prompts[agent_id] = prompt
except Exception as e:
print(f" 警告: 无法获取Agent {agent_id}: {e}")
if not actions:
self.send_response(command_id, "failed", error="没有有效的Agent")
return False
# 执行批量Interview
await self.env.step(actions)
# 获取所有结果
results = {}
for agent_id in agent_prompts.keys():
result = self._get_interview_result(agent_id)
results[agent_id] = result
self.send_response(command_id, "completed", result={
"interviews_count": len(results),
"results": results
})
print(f" 批量Interview完成: {len(results)} 个Agent")
return True
except Exception as e:
error_msg = str(e)
print(f" 批量Interview失败: {error_msg}")
self.send_response(command_id, "failed", error=error_msg)
return False
def _get_interview_result(self, agent_id: int) -> Dict[str, Any]:
"""从数据库获取最新的Interview结果"""
db_path = os.path.join(self.simulation_dir, "reddit_simulation.db")
result = {
"agent_id": agent_id,
"response": None,
"timestamp": None
}
if not os.path.exists(db_path):
return result
try:
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# 查询最新的Interview记录
cursor.execute("""
SELECT user_id, info, created_at
FROM trace
WHERE action = ? AND user_id = ?
ORDER BY created_at DESC
LIMIT 1
""", (ActionType.INTERVIEW.value, agent_id))
row = cursor.fetchone()
if row:
user_id, info_json, created_at = row
try:
info = json.loads(info_json) if info_json else {}
result["response"] = info.get("response", info)
result["timestamp"] = created_at
except json.JSONDecodeError:
result["response"] = info_json
conn.close()
except Exception as e:
print(f" 读取Interview结果失败: {e}")
return result
async def process_commands(self) -> bool:
"""
处理所有待处理命令
Returns:
True 表示继续运行False 表示应该退出
"""
command = self.poll_command()
if not command:
return True
command_id = command.get("command_id")
command_type = command.get("command_type")
args = command.get("args", {})
print(f"\n收到IPC命令: {command_type}, id={command_id}")
if command_type == CommandType.INTERVIEW:
await self.handle_interview(
command_id,
args.get("agent_id", 0),
args.get("prompt", "")
)
return True
elif command_type == CommandType.BATCH_INTERVIEW:
await self.handle_batch_interview(
command_id,
args.get("interviews", [])
)
return True
elif command_type == CommandType.CLOSE_ENV:
print("收到关闭环境命令")
self.send_response(command_id, "completed", result={"message": "环境即将关闭"})
return False
else:
self.send_response(command_id, "failed", error=f"未知命令类型: {command_type}")
return True
class RedditSimulationRunner:
"""Reddit模拟运行器"""
# Reddit可用动作不包含INTERVIEWINTERVIEW只能通过ManualAction手动触发
AVAILABLE_ACTIONS = [
ActionType.LIKE_POST,
ActionType.DISLIKE_POST,
ActionType.CREATE_POST,
ActionType.CREATE_COMMENT,
ActionType.LIKE_COMMENT,
ActionType.DISLIKE_COMMENT,
ActionType.SEARCH_POSTS,
ActionType.SEARCH_USER,
ActionType.TREND,
ActionType.REFRESH,
ActionType.DO_NOTHING,
ActionType.FOLLOW,
ActionType.MUTE,
]
def __init__(self, config_path: str, wait_for_commands: bool = True):
"""
初始化模拟运行器
Args:
config_path: 配置文件路径 (simulation_config.json)
wait_for_commands: 模拟完成后是否等待命令默认True
"""
self.config_path = config_path
self.config = self._load_config()
self.simulation_dir = os.path.dirname(config_path)
self.wait_for_commands = wait_for_commands
self.env = None
self.agent_graph = None
self.ipc_handler = None
def _load_config(self) -> Dict[str, Any]:
"""加载配置文件"""
with open(self.config_path, 'r', encoding='utf-8') as f:
return json.load(f)
def _get_profile_path(self) -> str:
"""获取Profile文件路径"""
return os.path.join(self.simulation_dir, "reddit_profiles.json")
def _get_db_path(self) -> str:
"""获取数据库路径"""
return os.path.join(self.simulation_dir, "reddit_simulation.db")
def _create_model(self):
"""
创建LLM模型
统一使用项目根目录 .env 文件中的配置优先级最高
- LLM_API_KEY: API密钥
- LLM_BASE_URL: API基础URL
- LLM_MODEL_NAME: 模型名称
"""
# 优先从 .env 读取配置
llm_api_key = os.environ.get("LLM_API_KEY", "")
llm_base_url = os.environ.get("LLM_BASE_URL", "")
llm_model = os.environ.get("LLM_MODEL_NAME", "")
# 如果 .env 中没有,则使用 config 作为备用
if not llm_model:
llm_model = self.config.get("llm_model", "gpt-4o-mini")
# 设置 camel-ai 所需的环境变量
if llm_api_key:
os.environ["OPENAI_API_KEY"] = llm_api_key
if not os.environ.get("OPENAI_API_KEY"):
raise ValueError("缺少 API Key 配置,请在项目根目录 .env 文件中设置 LLM_API_KEY")
if llm_base_url:
os.environ["OPENAI_API_BASE_URL"] = llm_base_url
print(f"LLM配置: model={llm_model}, base_url={llm_base_url[:40] if llm_base_url else '默认'}...")
return ModelFactory.create(
model_platform=ModelPlatformType.OPENAI,
model_type=llm_model,
)
def _get_active_agents_for_round(
self,
env,
current_hour: int,
round_num: int
) -> List:
"""
根据时间和配置决定本轮激活哪些Agent
"""
time_config = self.config.get("time_config", {})
agent_configs = self.config.get("agent_configs", [])
base_min = time_config.get("agents_per_hour_min", 5)
base_max = time_config.get("agents_per_hour_max", 20)
peak_hours = time_config.get("peak_hours", [9, 10, 11, 14, 15, 20, 21, 22])
off_peak_hours = time_config.get("off_peak_hours", [0, 1, 2, 3, 4, 5])
if current_hour in peak_hours:
multiplier = time_config.get("peak_activity_multiplier", 1.5)
elif current_hour in off_peak_hours:
multiplier = time_config.get("off_peak_activity_multiplier", 0.3)
else:
multiplier = 1.0
target_count = int(random.uniform(base_min, base_max) * multiplier)
candidates = []
for cfg in agent_configs:
agent_id = cfg.get("agent_id", 0)
active_hours = cfg.get("active_hours", list(range(8, 23)))
activity_level = cfg.get("activity_level", 0.5)
if current_hour not in active_hours:
continue
if random.random() < activity_level:
candidates.append(agent_id)
selected_ids = random.sample(
candidates,
min(target_count, len(candidates))
) if candidates else []
active_agents = []
for agent_id in selected_ids:
try:
agent = env.agent_graph.get_agent(agent_id)
active_agents.append((agent_id, agent))
except Exception:
pass
return active_agents
async def run(self, max_rounds: int = None):
"""运行Reddit模拟
Args:
max_rounds: 最大模拟轮数可选用于截断过长的模拟
"""
print("=" * 60)
print("OASIS Reddit模拟")
print(f"配置文件: {self.config_path}")
print(f"模拟ID: {self.config.get('simulation_id', 'unknown')}")
print(f"等待命令模式: {'启用' if self.wait_for_commands else '禁用'}")
print("=" * 60)
time_config = self.config.get("time_config", {})
total_hours = time_config.get("total_simulation_hours", 72)
minutes_per_round = time_config.get("minutes_per_round", 30)
total_rounds = (total_hours * 60) // minutes_per_round
# 如果指定了最大轮数,则截断
if max_rounds is not None and max_rounds > 0:
original_rounds = total_rounds
total_rounds = min(total_rounds, max_rounds)
if total_rounds < original_rounds:
print(f"\n轮数已截断: {original_rounds} -> {total_rounds} (max_rounds={max_rounds})")
print(f"\n模拟参数:")
print(f" - 总模拟时长: {total_hours}小时")
print(f" - 每轮时间: {minutes_per_round}分钟")
print(f" - 总轮数: {total_rounds}")
if max_rounds:
print(f" - 最大轮数限制: {max_rounds}")
print(f" - Agent数量: {len(self.config.get('agent_configs', []))}")
print("\n初始化LLM模型...")
model = self._create_model()
print("加载Agent Profile...")
profile_path = self._get_profile_path()
if not os.path.exists(profile_path):
print(f"错误: Profile文件不存在: {profile_path}")
return
self.agent_graph = await generate_reddit_agent_graph(
profile_path=profile_path,
model=model,
available_actions=self.AVAILABLE_ACTIONS,
)
db_path = self._get_db_path()
if os.path.exists(db_path):
os.remove(db_path)
print(f"已删除旧数据库: {db_path}")
print("创建OASIS环境...")
self.env = oasis.make(
agent_graph=self.agent_graph,
platform=oasis.DefaultPlatformType.REDDIT,
database_path=db_path,
semaphore=30, # 限制最大并发 LLM 请求数,防止 API 过载
)
await self.env.reset()
print("环境初始化完成\n")
# 初始化IPC处理器
self.ipc_handler = IPCHandler(self.simulation_dir, self.env, self.agent_graph)
self.ipc_handler.update_status("running")
# 执行初始事件
event_config = self.config.get("event_config", {})
initial_posts = event_config.get("initial_posts", [])
if initial_posts:
print(f"执行初始事件 ({len(initial_posts)}条初始帖子)...")
initial_actions = {}
for post in initial_posts:
agent_id = post.get("poster_agent_id", 0)
content = post.get("content", "")
try:
agent = self.env.agent_graph.get_agent(agent_id)
if agent in initial_actions:
if not isinstance(initial_actions[agent], list):
initial_actions[agent] = [initial_actions[agent]]
initial_actions[agent].append(ManualAction(
action_type=ActionType.CREATE_POST,
action_args={"content": content}
))
else:
initial_actions[agent] = ManualAction(
action_type=ActionType.CREATE_POST,
action_args={"content": content}
)
except Exception as e:
print(f" 警告: 无法为Agent {agent_id}创建初始帖子: {e}")
if initial_actions:
await self.env.step(initial_actions)
print(f" 已发布 {len(initial_actions)} 条初始帖子")
# 主模拟循环
print("\n开始模拟循环...")
start_time = datetime.now()
for round_num in range(total_rounds):
simulated_minutes = round_num * minutes_per_round
simulated_hour = (simulated_minutes // 60) % 24
simulated_day = simulated_minutes // (60 * 24) + 1
active_agents = self._get_active_agents_for_round(
self.env, simulated_hour, round_num
)
if not active_agents:
continue
actions = {
agent: LLMAction()
for _, agent in active_agents
}
await self.env.step(actions)
if (round_num + 1) % 10 == 0 or round_num == 0:
elapsed = (datetime.now() - start_time).total_seconds()
progress = (round_num + 1) / total_rounds * 100
print(f" [Day {simulated_day}, {simulated_hour:02d}:00] "
f"Round {round_num + 1}/{total_rounds} ({progress:.1f}%) "
f"- {len(active_agents)} agents active "
f"- elapsed: {elapsed:.1f}s")
total_elapsed = (datetime.now() - start_time).total_seconds()
print(f"\n模拟循环完成!")
print(f" - 总耗时: {total_elapsed:.1f}")
print(f" - 数据库: {db_path}")
# 是否进入等待命令模式
if self.wait_for_commands:
print("\n" + "=" * 60)
print("进入等待命令模式 - 环境保持运行")
print("支持的命令: interview, batch_interview, close_env")
print("=" * 60)
self.ipc_handler.update_status("alive")
# 等待命令循环(使用全局 _shutdown_event
try:
while not _shutdown_event.is_set():
should_continue = await self.ipc_handler.process_commands()
if not should_continue:
break
try:
await asyncio.wait_for(_shutdown_event.wait(), timeout=0.5)
break # 收到退出信号
except asyncio.TimeoutError:
pass
except KeyboardInterrupt:
print("\n收到中断信号")
except asyncio.CancelledError:
print("\n任务被取消")
except Exception as e:
print(f"\n命令处理出错: {e}")
print("\n关闭环境...")
# 关闭环境
self.ipc_handler.update_status("stopped")
await self.env.close()
print("环境已关闭")
print("=" * 60)
async def main():
parser = argparse.ArgumentParser(description='OASIS Reddit模拟')
parser.add_argument(
'--config',
type=str,
required=True,
help='配置文件路径 (simulation_config.json)'
)
parser.add_argument(
'--max-rounds',
type=int,
default=None,
help='最大模拟轮数(可选,用于截断过长的模拟)'
)
parser.add_argument(
'--no-wait',
action='store_true',
default=False,
help='模拟完成后立即关闭环境,不进入等待命令模式'
)
args = parser.parse_args()
# 在 main 函数开始时创建 shutdown 事件
global _shutdown_event
_shutdown_event = asyncio.Event()
if not os.path.exists(args.config):
print(f"错误: 配置文件不存在: {args.config}")
sys.exit(1)
# 初始化日志配置(使用固定文件名,清理旧日志)
simulation_dir = os.path.dirname(args.config) or "."
setup_oasis_logging(os.path.join(simulation_dir, "log"))
runner = RedditSimulationRunner(
config_path=args.config,
wait_for_commands=not args.no_wait
)
await runner.run(max_rounds=args.max_rounds)
def setup_signal_handlers():
"""
设置信号处理器确保收到 SIGTERM/SIGINT 时能够正确退出
让程序有机会正常清理资源关闭数据库环境等
"""
def signal_handler(signum, frame):
global _cleanup_done
sig_name = "SIGTERM" if signum == signal.SIGTERM else "SIGINT"
print(f"\n收到 {sig_name} 信号,正在退出...")
if not _cleanup_done:
_cleanup_done = True
if _shutdown_event:
_shutdown_event.set()
else:
# 重复收到信号才强制退出
print("强制退出...")
sys.exit(1)
signal.signal(signal.SIGTERM, signal_handler)
signal.signal(signal.SIGINT, signal_handler)
if __name__ == "__main__":
setup_signal_handlers()
try:
asyncio.run(main())
except KeyboardInterrupt:
print("\n程序被中断")
except SystemExit:
pass
finally:
print("模拟进程已退出")

View File

@ -0,0 +1,780 @@
"""
OASIS Twitter模拟预设脚本
此脚本读取配置文件中的参数来执行模拟实现全程自动化
功能特性:
- 完成模拟后不立即关闭环境进入等待命令模式
- 支持通过IPC接收Interview命令
- 支持单个Agent采访和批量采访
- 支持远程关闭环境命令
使用方式:
python run_twitter_simulation.py --config /path/to/simulation_config.json
python run_twitter_simulation.py --config /path/to/simulation_config.json --no-wait # 完成后立即关闭
"""
import argparse
import asyncio
import json
import logging
import os
import random
import signal
import sys
import sqlite3
from datetime import datetime
from typing import Dict, Any, List, Optional
# 全局变量:用于信号处理
_shutdown_event = None
_cleanup_done = False
# 添加项目路径
_scripts_dir = os.path.dirname(os.path.abspath(__file__))
_backend_dir = os.path.abspath(os.path.join(_scripts_dir, '..'))
_project_root = os.path.abspath(os.path.join(_backend_dir, '..'))
sys.path.insert(0, _scripts_dir)
sys.path.insert(0, _backend_dir)
# 加载项目根目录的 .env 文件(包含 LLM_API_KEY 等配置)
from dotenv import load_dotenv
_env_file = os.path.join(_project_root, '.env')
if os.path.exists(_env_file):
load_dotenv(_env_file)
else:
_backend_env = os.path.join(_backend_dir, '.env')
if os.path.exists(_backend_env):
load_dotenv(_backend_env)
import re
class UnicodeFormatter(logging.Formatter):
"""自定义格式化器,将 Unicode 转义序列转换为可读字符"""
UNICODE_ESCAPE_PATTERN = re.compile(r'\\u([0-9a-fA-F]{4})')
def format(self, record):
result = super().format(record)
def replace_unicode(match):
try:
return chr(int(match.group(1), 16))
except (ValueError, OverflowError):
return match.group(0)
return self.UNICODE_ESCAPE_PATTERN.sub(replace_unicode, result)
class MaxTokensWarningFilter(logging.Filter):
"""过滤掉 camel-ai 关于 max_tokens 的警告(我们故意不设置 max_tokens让模型自行决定"""
def filter(self, record):
# 过滤掉包含 max_tokens 警告的日志
if "max_tokens" in record.getMessage() and "Invalid or missing" in record.getMessage():
return False
return True
# 在模块加载时立即添加过滤器,确保在 camel 代码执行前生效
logging.getLogger().addFilter(MaxTokensWarningFilter())
def setup_oasis_logging(log_dir: str):
"""配置 OASIS 的日志,使用固定名称的日志文件"""
os.makedirs(log_dir, exist_ok=True)
# 清理旧的日志文件
for f in os.listdir(log_dir):
old_log = os.path.join(log_dir, f)
if os.path.isfile(old_log) and f.endswith('.log'):
try:
os.remove(old_log)
except OSError:
pass
formatter = UnicodeFormatter("%(levelname)s - %(asctime)s - %(name)s - %(message)s")
loggers_config = {
"social.agent": os.path.join(log_dir, "social.agent.log"),
"social.twitter": os.path.join(log_dir, "social.twitter.log"),
"social.rec": os.path.join(log_dir, "social.rec.log"),
"oasis.env": os.path.join(log_dir, "oasis.env.log"),
"table": os.path.join(log_dir, "table.log"),
}
for logger_name, log_file in loggers_config.items():
logger = logging.getLogger(logger_name)
logger.setLevel(logging.DEBUG)
logger.handlers.clear()
file_handler = logging.FileHandler(log_file, encoding='utf-8', mode='w')
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
logger.propagate = False
try:
from camel.models import ModelFactory
from camel.types import ModelPlatformType
import oasis
from oasis import (
ActionType,
LLMAction,
ManualAction,
generate_twitter_agent_graph
)
except ImportError as e:
print(f"错误: 缺少依赖 {e}")
print("请先安装: pip install oasis-ai camel-ai")
sys.exit(1)
# IPC相关常量
IPC_COMMANDS_DIR = "ipc_commands"
IPC_RESPONSES_DIR = "ipc_responses"
ENV_STATUS_FILE = "env_status.json"
class CommandType:
"""命令类型常量"""
INTERVIEW = "interview"
BATCH_INTERVIEW = "batch_interview"
CLOSE_ENV = "close_env"
class IPCHandler:
"""IPC命令处理器"""
def __init__(self, simulation_dir: str, env, agent_graph):
self.simulation_dir = simulation_dir
self.env = env
self.agent_graph = agent_graph
self.commands_dir = os.path.join(simulation_dir, IPC_COMMANDS_DIR)
self.responses_dir = os.path.join(simulation_dir, IPC_RESPONSES_DIR)
self.status_file = os.path.join(simulation_dir, ENV_STATUS_FILE)
self._running = True
# 确保目录存在
os.makedirs(self.commands_dir, exist_ok=True)
os.makedirs(self.responses_dir, exist_ok=True)
def update_status(self, status: str):
"""更新环境状态"""
with open(self.status_file, 'w', encoding='utf-8') as f:
json.dump({
"status": status,
"timestamp": datetime.now().isoformat()
}, f, ensure_ascii=False, indent=2)
def poll_command(self) -> Optional[Dict[str, Any]]:
"""轮询获取待处理命令"""
if not os.path.exists(self.commands_dir):
return None
# 获取命令文件(按时间排序)
command_files = []
for filename in os.listdir(self.commands_dir):
if filename.endswith('.json'):
filepath = os.path.join(self.commands_dir, filename)
command_files.append((filepath, os.path.getmtime(filepath)))
command_files.sort(key=lambda x: x[1])
for filepath, _ in command_files:
try:
with open(filepath, 'r', encoding='utf-8') as f:
return json.load(f)
except (json.JSONDecodeError, OSError):
continue
return None
def send_response(self, command_id: str, status: str, result: Dict = None, error: str = None):
"""发送响应"""
response = {
"command_id": command_id,
"status": status,
"result": result,
"error": error,
"timestamp": datetime.now().isoformat()
}
response_file = os.path.join(self.responses_dir, f"{command_id}.json")
with open(response_file, 'w', encoding='utf-8') as f:
json.dump(response, f, ensure_ascii=False, indent=2)
# 删除命令文件
command_file = os.path.join(self.commands_dir, f"{command_id}.json")
try:
os.remove(command_file)
except OSError:
pass
async def handle_interview(self, command_id: str, agent_id: int, prompt: str) -> bool:
"""
处理单个Agent采访命令
Returns:
True 表示成功False 表示失败
"""
try:
# 获取Agent
agent = self.agent_graph.get_agent(agent_id)
# 创建Interview动作
interview_action = ManualAction(
action_type=ActionType.INTERVIEW,
action_args={"prompt": prompt}
)
# 执行Interview
actions = {agent: interview_action}
await self.env.step(actions)
# 从数据库获取结果
result = self._get_interview_result(agent_id)
self.send_response(command_id, "completed", result=result)
print(f" Interview完成: agent_id={agent_id}")
return True
except Exception as e:
error_msg = str(e)
print(f" Interview失败: agent_id={agent_id}, error={error_msg}")
self.send_response(command_id, "failed", error=error_msg)
return False
async def handle_batch_interview(self, command_id: str, interviews: List[Dict]) -> bool:
"""
处理批量采访命令
Args:
interviews: [{"agent_id": int, "prompt": str}, ...]
"""
try:
# 构建动作字典
actions = {}
agent_prompts = {} # 记录每个agent的prompt
for interview in interviews:
agent_id = interview.get("agent_id")
prompt = interview.get("prompt", "")
try:
agent = self.agent_graph.get_agent(agent_id)
actions[agent] = ManualAction(
action_type=ActionType.INTERVIEW,
action_args={"prompt": prompt}
)
agent_prompts[agent_id] = prompt
except Exception as e:
print(f" 警告: 无法获取Agent {agent_id}: {e}")
if not actions:
self.send_response(command_id, "failed", error="没有有效的Agent")
return False
# 执行批量Interview
await self.env.step(actions)
# 获取所有结果
results = {}
for agent_id in agent_prompts.keys():
result = self._get_interview_result(agent_id)
results[agent_id] = result
self.send_response(command_id, "completed", result={
"interviews_count": len(results),
"results": results
})
print(f" 批量Interview完成: {len(results)} 个Agent")
return True
except Exception as e:
error_msg = str(e)
print(f" 批量Interview失败: {error_msg}")
self.send_response(command_id, "failed", error=error_msg)
return False
def _get_interview_result(self, agent_id: int) -> Dict[str, Any]:
"""从数据库获取最新的Interview结果"""
db_path = os.path.join(self.simulation_dir, "twitter_simulation.db")
result = {
"agent_id": agent_id,
"response": None,
"timestamp": None
}
if not os.path.exists(db_path):
return result
try:
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# 查询最新的Interview记录
cursor.execute("""
SELECT user_id, info, created_at
FROM trace
WHERE action = ? AND user_id = ?
ORDER BY created_at DESC
LIMIT 1
""", (ActionType.INTERVIEW.value, agent_id))
row = cursor.fetchone()
if row:
user_id, info_json, created_at = row
try:
info = json.loads(info_json) if info_json else {}
result["response"] = info.get("response", info)
result["timestamp"] = created_at
except json.JSONDecodeError:
result["response"] = info_json
conn.close()
except Exception as e:
print(f" 读取Interview结果失败: {e}")
return result
async def process_commands(self) -> bool:
"""
处理所有待处理命令
Returns:
True 表示继续运行False 表示应该退出
"""
command = self.poll_command()
if not command:
return True
command_id = command.get("command_id")
command_type = command.get("command_type")
args = command.get("args", {})
print(f"\n收到IPC命令: {command_type}, id={command_id}")
if command_type == CommandType.INTERVIEW:
await self.handle_interview(
command_id,
args.get("agent_id", 0),
args.get("prompt", "")
)
return True
elif command_type == CommandType.BATCH_INTERVIEW:
await self.handle_batch_interview(
command_id,
args.get("interviews", [])
)
return True
elif command_type == CommandType.CLOSE_ENV:
print("收到关闭环境命令")
self.send_response(command_id, "completed", result={"message": "环境即将关闭"})
return False
else:
self.send_response(command_id, "failed", error=f"未知命令类型: {command_type}")
return True
class TwitterSimulationRunner:
"""Twitter模拟运行器"""
# Twitter可用动作不包含INTERVIEWINTERVIEW只能通过ManualAction手动触发
AVAILABLE_ACTIONS = [
ActionType.CREATE_POST,
ActionType.LIKE_POST,
ActionType.REPOST,
ActionType.FOLLOW,
ActionType.DO_NOTHING,
ActionType.QUOTE_POST,
]
def __init__(self, config_path: str, wait_for_commands: bool = True):
"""
初始化模拟运行器
Args:
config_path: 配置文件路径 (simulation_config.json)
wait_for_commands: 模拟完成后是否等待命令默认True
"""
self.config_path = config_path
self.config = self._load_config()
self.simulation_dir = os.path.dirname(config_path)
self.wait_for_commands = wait_for_commands
self.env = None
self.agent_graph = None
self.ipc_handler = None
def _load_config(self) -> Dict[str, Any]:
"""加载配置文件"""
with open(self.config_path, 'r', encoding='utf-8') as f:
return json.load(f)
def _get_profile_path(self) -> str:
"""获取Profile文件路径OASIS Twitter使用CSV格式"""
return os.path.join(self.simulation_dir, "twitter_profiles.csv")
def _get_db_path(self) -> str:
"""获取数据库路径"""
return os.path.join(self.simulation_dir, "twitter_simulation.db")
def _create_model(self):
"""
创建LLM模型
统一使用项目根目录 .env 文件中的配置优先级最高
- LLM_API_KEY: API密钥
- LLM_BASE_URL: API基础URL
- LLM_MODEL_NAME: 模型名称
"""
# 优先从 .env 读取配置
llm_api_key = os.environ.get("LLM_API_KEY", "")
llm_base_url = os.environ.get("LLM_BASE_URL", "")
llm_model = os.environ.get("LLM_MODEL_NAME", "")
# 如果 .env 中没有,则使用 config 作为备用
if not llm_model:
llm_model = self.config.get("llm_model", "gpt-4o-mini")
# 设置 camel-ai 所需的环境变量
if llm_api_key:
os.environ["OPENAI_API_KEY"] = llm_api_key
if not os.environ.get("OPENAI_API_KEY"):
raise ValueError("缺少 API Key 配置,请在项目根目录 .env 文件中设置 LLM_API_KEY")
if llm_base_url:
os.environ["OPENAI_API_BASE_URL"] = llm_base_url
print(f"LLM配置: model={llm_model}, base_url={llm_base_url[:40] if llm_base_url else '默认'}...")
return ModelFactory.create(
model_platform=ModelPlatformType.OPENAI,
model_type=llm_model,
)
def _get_active_agents_for_round(
self,
env,
current_hour: int,
round_num: int
) -> List:
"""
根据时间和配置决定本轮激活哪些Agent
Args:
env: OASIS环境
current_hour: 当前模拟小时0-23
round_num: 当前轮数
Returns:
激活的Agent列表
"""
time_config = self.config.get("time_config", {})
agent_configs = self.config.get("agent_configs", [])
# 基础激活数量
base_min = time_config.get("agents_per_hour_min", 5)
base_max = time_config.get("agents_per_hour_max", 20)
# 根据时段调整
peak_hours = time_config.get("peak_hours", [9, 10, 11, 14, 15, 20, 21, 22])
off_peak_hours = time_config.get("off_peak_hours", [0, 1, 2, 3, 4, 5])
if current_hour in peak_hours:
multiplier = time_config.get("peak_activity_multiplier", 1.5)
elif current_hour in off_peak_hours:
multiplier = time_config.get("off_peak_activity_multiplier", 0.3)
else:
multiplier = 1.0
target_count = int(random.uniform(base_min, base_max) * multiplier)
# 根据每个Agent的配置计算激活概率
candidates = []
for cfg in agent_configs:
agent_id = cfg.get("agent_id", 0)
active_hours = cfg.get("active_hours", list(range(8, 23)))
activity_level = cfg.get("activity_level", 0.5)
# 检查是否在活跃时间
if current_hour not in active_hours:
continue
# 根据活跃度计算概率
if random.random() < activity_level:
candidates.append(agent_id)
# 随机选择
selected_ids = random.sample(
candidates,
min(target_count, len(candidates))
) if candidates else []
# 转换为Agent对象
active_agents = []
for agent_id in selected_ids:
try:
agent = env.agent_graph.get_agent(agent_id)
active_agents.append((agent_id, agent))
except Exception:
pass
return active_agents
async def run(self, max_rounds: int = None):
"""运行Twitter模拟
Args:
max_rounds: 最大模拟轮数可选用于截断过长的模拟
"""
print("=" * 60)
print("OASIS Twitter模拟")
print(f"配置文件: {self.config_path}")
print(f"模拟ID: {self.config.get('simulation_id', 'unknown')}")
print(f"等待命令模式: {'启用' if self.wait_for_commands else '禁用'}")
print("=" * 60)
# 加载时间配置
time_config = self.config.get("time_config", {})
total_hours = time_config.get("total_simulation_hours", 72)
minutes_per_round = time_config.get("minutes_per_round", 30)
# 计算总轮数
total_rounds = (total_hours * 60) // minutes_per_round
# 如果指定了最大轮数,则截断
if max_rounds is not None and max_rounds > 0:
original_rounds = total_rounds
total_rounds = min(total_rounds, max_rounds)
if total_rounds < original_rounds:
print(f"\n轮数已截断: {original_rounds} -> {total_rounds} (max_rounds={max_rounds})")
print(f"\n模拟参数:")
print(f" - 总模拟时长: {total_hours}小时")
print(f" - 每轮时间: {minutes_per_round}分钟")
print(f" - 总轮数: {total_rounds}")
if max_rounds:
print(f" - 最大轮数限制: {max_rounds}")
print(f" - Agent数量: {len(self.config.get('agent_configs', []))}")
# 创建模型
print("\n初始化LLM模型...")
model = self._create_model()
# 加载Agent图
print("加载Agent Profile...")
profile_path = self._get_profile_path()
if not os.path.exists(profile_path):
print(f"错误: Profile文件不存在: {profile_path}")
return
self.agent_graph = await generate_twitter_agent_graph(
profile_path=profile_path,
model=model,
available_actions=self.AVAILABLE_ACTIONS,
)
# 数据库路径
db_path = self._get_db_path()
if os.path.exists(db_path):
os.remove(db_path)
print(f"已删除旧数据库: {db_path}")
# 创建环境
print("创建OASIS环境...")
self.env = oasis.make(
agent_graph=self.agent_graph,
platform=oasis.DefaultPlatformType.TWITTER,
database_path=db_path,
semaphore=30, # 限制最大并发 LLM 请求数,防止 API 过载
)
await self.env.reset()
print("环境初始化完成\n")
# 初始化IPC处理器
self.ipc_handler = IPCHandler(self.simulation_dir, self.env, self.agent_graph)
self.ipc_handler.update_status("running")
# 执行初始事件
event_config = self.config.get("event_config", {})
initial_posts = event_config.get("initial_posts", [])
if initial_posts:
print(f"执行初始事件 ({len(initial_posts)}条初始帖子)...")
initial_actions = {}
for post in initial_posts:
agent_id = post.get("poster_agent_id", 0)
content = post.get("content", "")
try:
agent = self.env.agent_graph.get_agent(agent_id)
initial_actions[agent] = ManualAction(
action_type=ActionType.CREATE_POST,
action_args={"content": content}
)
except Exception as e:
print(f" 警告: 无法为Agent {agent_id}创建初始帖子: {e}")
if initial_actions:
await self.env.step(initial_actions)
print(f" 已发布 {len(initial_actions)} 条初始帖子")
# 主模拟循环
print("\n开始模拟循环...")
start_time = datetime.now()
for round_num in range(total_rounds):
# 计算当前模拟时间
simulated_minutes = round_num * minutes_per_round
simulated_hour = (simulated_minutes // 60) % 24
simulated_day = simulated_minutes // (60 * 24) + 1
# 获取本轮激活的Agent
active_agents = self._get_active_agents_for_round(
self.env, simulated_hour, round_num
)
if not active_agents:
continue
# 构建动作
actions = {
agent: LLMAction()
for _, agent in active_agents
}
# 执行动作
await self.env.step(actions)
# 打印进度
if (round_num + 1) % 10 == 0 or round_num == 0:
elapsed = (datetime.now() - start_time).total_seconds()
progress = (round_num + 1) / total_rounds * 100
print(f" [Day {simulated_day}, {simulated_hour:02d}:00] "
f"Round {round_num + 1}/{total_rounds} ({progress:.1f}%) "
f"- {len(active_agents)} agents active "
f"- elapsed: {elapsed:.1f}s")
total_elapsed = (datetime.now() - start_time).total_seconds()
print(f"\n模拟循环完成!")
print(f" - 总耗时: {total_elapsed:.1f}")
print(f" - 数据库: {db_path}")
# 是否进入等待命令模式
if self.wait_for_commands:
print("\n" + "=" * 60)
print("进入等待命令模式 - 环境保持运行")
print("支持的命令: interview, batch_interview, close_env")
print("=" * 60)
self.ipc_handler.update_status("alive")
# 等待命令循环(使用全局 _shutdown_event
try:
while not _shutdown_event.is_set():
should_continue = await self.ipc_handler.process_commands()
if not should_continue:
break
try:
await asyncio.wait_for(_shutdown_event.wait(), timeout=0.5)
break # 收到退出信号
except asyncio.TimeoutError:
pass
except KeyboardInterrupt:
print("\n收到中断信号")
except asyncio.CancelledError:
print("\n任务被取消")
except Exception as e:
print(f"\n命令处理出错: {e}")
print("\n关闭环境...")
# 关闭环境
self.ipc_handler.update_status("stopped")
await self.env.close()
print("环境已关闭")
print("=" * 60)
async def main():
parser = argparse.ArgumentParser(description='OASIS Twitter模拟')
parser.add_argument(
'--config',
type=str,
required=True,
help='配置文件路径 (simulation_config.json)'
)
parser.add_argument(
'--max-rounds',
type=int,
default=None,
help='最大模拟轮数(可选,用于截断过长的模拟)'
)
parser.add_argument(
'--no-wait',
action='store_true',
default=False,
help='模拟完成后立即关闭环境,不进入等待命令模式'
)
args = parser.parse_args()
# 在 main 函数开始时创建 shutdown 事件
global _shutdown_event
_shutdown_event = asyncio.Event()
if not os.path.exists(args.config):
print(f"错误: 配置文件不存在: {args.config}")
sys.exit(1)
# 初始化日志配置(使用固定文件名,清理旧日志)
simulation_dir = os.path.dirname(args.config) or "."
setup_oasis_logging(os.path.join(simulation_dir, "log"))
runner = TwitterSimulationRunner(
config_path=args.config,
wait_for_commands=not args.no_wait
)
await runner.run(max_rounds=args.max_rounds)
def setup_signal_handlers():
"""
设置信号处理器确保收到 SIGTERM/SIGINT 时能够正确退出
让程序有机会正常清理资源关闭数据库环境等
"""
def signal_handler(signum, frame):
global _cleanup_done
sig_name = "SIGTERM" if signum == signal.SIGTERM else "SIGINT"
print(f"\n收到 {sig_name} 信号,正在退出...")
if not _cleanup_done:
_cleanup_done = True
if _shutdown_event:
_shutdown_event.set()
else:
# 重复收到信号才强制退出
print("强制退出...")
sys.exit(1)
signal.signal(signal.SIGTERM, signal_handler)
signal.signal(signal.SIGINT, signal_handler)
if __name__ == "__main__":
setup_signal_handlers()
try:
asyncio.run(main())
except KeyboardInterrupt:
print("\n程序被中断")
except SystemExit:
pass
finally:
print("模拟进程已退出")

View File

@ -0,0 +1,166 @@
"""
测试Profile格式生成是否符合OASIS要求
验证
1. Twitter Profile生成CSV格式
2. Reddit Profile生成JSON详细格式
"""
import os
import sys
import json
import csv
import tempfile
# 添加项目路径
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.services.oasis_profile_generator import OasisProfileGenerator, OasisAgentProfile
def test_profile_formats():
"""测试Profile格式"""
print("=" * 60)
print("OASIS Profile格式测试")
print("=" * 60)
# 创建测试Profile数据
test_profiles = [
OasisAgentProfile(
user_id=0,
user_name="test_user_123",
name="Test User",
bio="A test user for validation",
persona="Test User is an enthusiastic participant in social discussions.",
karma=1500,
friend_count=100,
follower_count=200,
statuses_count=500,
age=25,
gender="male",
mbti="INTJ",
country="China",
profession="Student",
interested_topics=["Technology", "Education"],
source_entity_uuid="test-uuid-123",
source_entity_type="Student",
),
OasisAgentProfile(
user_id=1,
user_name="org_official_456",
name="Official Organization",
bio="Official account for Organization",
persona="This is an official institutional account that communicates official positions.",
karma=5000,
friend_count=50,
follower_count=10000,
statuses_count=200,
profession="Organization",
interested_topics=["Public Policy", "Announcements"],
source_entity_uuid="test-uuid-456",
source_entity_type="University",
),
]
generator = OasisProfileGenerator.__new__(OasisProfileGenerator)
# 使用临时目录
with tempfile.TemporaryDirectory() as temp_dir:
twitter_path = os.path.join(temp_dir, "twitter_profiles.csv")
reddit_path = os.path.join(temp_dir, "reddit_profiles.json")
# 测试Twitter CSV格式
print("\n1. 测试Twitter Profile (CSV格式)")
print("-" * 40)
generator._save_twitter_csv(test_profiles, twitter_path)
# 读取并验证CSV
with open(twitter_path, 'r', encoding='utf-8') as f:
reader = csv.DictReader(f)
rows = list(reader)
print(f" 文件: {twitter_path}")
print(f" 行数: {len(rows)}")
print(f" 表头: {list(rows[0].keys())}")
print(f"\n 示例数据 (第1行):")
for key, value in rows[0].items():
print(f" {key}: {value}")
# 验证必需字段
required_twitter_fields = ['user_id', 'user_name', 'name', 'bio',
'friend_count', 'follower_count', 'statuses_count', 'created_at']
missing = set(required_twitter_fields) - set(rows[0].keys())
if missing:
print(f"\n [错误] 缺少字段: {missing}")
else:
print(f"\n [通过] 所有必需字段都存在")
# 测试Reddit JSON格式
print("\n2. 测试Reddit Profile (JSON详细格式)")
print("-" * 40)
generator._save_reddit_json(test_profiles, reddit_path)
# 读取并验证JSON
with open(reddit_path, 'r', encoding='utf-8') as f:
reddit_data = json.load(f)
print(f" 文件: {reddit_path}")
print(f" 条目数: {len(reddit_data)}")
print(f" 字段: {list(reddit_data[0].keys())}")
print(f"\n 示例数据 (第1条):")
print(json.dumps(reddit_data[0], ensure_ascii=False, indent=4))
# 验证详细格式字段
required_reddit_fields = ['realname', 'username', 'bio', 'persona']
optional_reddit_fields = ['age', 'gender', 'mbti', 'country', 'profession', 'interested_topics']
missing = set(required_reddit_fields) - set(reddit_data[0].keys())
if missing:
print(f"\n [错误] 缺少必需字段: {missing}")
else:
print(f"\n [通过] 所有必需字段都存在")
present_optional = set(optional_reddit_fields) & set(reddit_data[0].keys())
print(f" [信息] 可选字段: {present_optional}")
print("\n" + "=" * 60)
print("测试完成!")
print("=" * 60)
def show_expected_formats():
"""显示OASIS期望的格式"""
print("\n" + "=" * 60)
print("OASIS 期望的Profile格式参考")
print("=" * 60)
print("\n1. Twitter Profile (CSV格式)")
print("-" * 40)
twitter_example = """user_id,user_name,name,bio,friend_count,follower_count,statuses_count,created_at
0,user0,User Zero,I am user zero with interests in technology.,100,150,500,2023-01-01
1,user1,User One,Tech enthusiast and coffee lover.,200,250,1000,2023-01-02"""
print(twitter_example)
print("\n2. Reddit Profile (JSON详细格式)")
print("-" * 40)
reddit_example = [
{
"realname": "James Miller",
"username": "millerhospitality",
"bio": "Passionate about hospitality & tourism.",
"persona": "James is a seasoned professional in the Hospitality & Tourism industry...",
"age": 40,
"gender": "male",
"mbti": "ESTJ",
"country": "UK",
"profession": "Hospitality & Tourism",
"interested_topics": ["Economics", "Business"]
}
]
print(json.dumps(reddit_example, ensure_ascii=False, indent=2))
if __name__ == "__main__":
test_profile_formats()
show_expected_formats()

2789
backend/uv.lock Normal file

File diff suppressed because it is too large Load Diff

14
docker-compose.yml Normal file
View File

@ -0,0 +1,14 @@
services:
mirofish:
image: ghcr.io/666ghj/mirofish:latest
# 加速镜像(如拉取缓慢可替换上方地址)
# image: ghcr.nju.edu.cn/666ghj/mirofish:latest
container_name: mirofish
env_file:
- .env
ports:
- "3000:3000"
- "5001:5001"
restart: unless-stopped
volumes:
- ./backend/uploads:/app/backend/uploads

24
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

18
frontend/index.html Normal file
View File

@ -0,0 +1,18 @@
<!doctype html>
<html lang="zh">
<head>
<script>document.documentElement.lang = localStorage.getItem('locale') || 'zh'</script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@100..800&family=Noto+Sans+SC:wght@300;400;500;700;800;900&family=Space+Grotesk:wght@300..700&display=swap" rel="stylesheet">
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="MiroFish - 社交媒体舆论模拟系统" />
<title>MiroFish - 预测万物</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

2185
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
frontend/package.json Normal file
View File

@ -0,0 +1,22 @@
{
"name": "frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --host",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.14.0",
"d3": "^7.9.0",
"vue": "^3.5.24",
"vue-i18n": "^11.3.0",
"vue-router": "^4.6.3"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"vite": "^7.2.4"
}
}

BIN
frontend/public/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

47
frontend/src/App.vue Normal file
View File

@ -0,0 +1,47 @@
<template>
<router-view />
</template>
<script setup>
// 使 Vue Router
</script>
<style>
/* 全局样式重置 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
#app {
font-family: 'JetBrains Mono', 'Space Grotesk', 'Noto Sans SC', monospace;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color: #000000;
background-color: #ffffff;
}
/* 滚动条样式 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
}
::-webkit-scrollbar-thumb {
background: #000000;
}
::-webkit-scrollbar-thumb:hover {
background: #333333;
}
/* 全局按钮样式 */
button {
font-family: inherit;
}
</style>

70
frontend/src/api/graph.js Normal file
View File

@ -0,0 +1,70 @@
import service, { requestWithRetry } from './index'
/**
* 生成本体上传文档和模拟需求
* @param {Object} data - 包含files, simulation_requirement, project_name等
* @returns {Promise}
*/
export function generateOntology(formData) {
return requestWithRetry(() =>
service({
url: '/api/graph/ontology/generate',
method: 'post',
data: formData,
headers: {
'Content-Type': 'multipart/form-data'
}
})
)
}
/**
* 构建图谱
* @param {Object} data - 包含project_id, graph_name等
* @returns {Promise}
*/
export function buildGraph(data) {
return requestWithRetry(() =>
service({
url: '/api/graph/build',
method: 'post',
data
})
)
}
/**
* 查询任务状态
* @param {String} taskId - 任务ID
* @returns {Promise}
*/
export function getTaskStatus(taskId) {
return service({
url: `/api/graph/task/${taskId}`,
method: 'get'
})
}
/**
* 获取图谱数据
* @param {String} graphId - 图谱ID
* @returns {Promise}
*/
export function getGraphData(graphId) {
return service({
url: `/api/graph/data/${graphId}`,
method: 'get'
})
}
/**
* 获取项目信息
* @param {String} projectId - 项目ID
* @returns {Promise}
*/
export function getProject(projectId) {
return service({
url: `/api/graph/project/${projectId}`,
method: 'get'
})
}

69
frontend/src/api/index.js Normal file
View File

@ -0,0 +1,69 @@
import axios from 'axios'
import i18n from '../i18n'
// 创建axios实例
const service = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:5001',
timeout: 300000, // 5分钟超时本体生成可能需要较长时间
headers: {
'Content-Type': 'application/json'
}
})
// 请求拦截器
service.interceptors.request.use(
config => {
config.headers['Accept-Language'] = i18n.global.locale.value
return config
},
error => {
console.error('Request error:', error)
return Promise.reject(error)
}
)
// 响应拦截器(容错重试机制)
service.interceptors.response.use(
response => {
const res = response.data
// 如果返回的状态码不是success则抛出错误
if (!res.success && res.success !== undefined) {
console.error('API Error:', res.error || res.message || 'Unknown error')
return Promise.reject(new Error(res.error || res.message || 'Error'))
}
return res
},
error => {
console.error('Response error:', error)
// 处理超时
if (error.code === 'ECONNABORTED' && error.message.includes('timeout')) {
console.error('Request timeout')
}
// 处理网络错误
if (error.message === 'Network Error') {
console.error('Network error - please check your connection')
}
return Promise.reject(error)
}
)
// 带重试的请求函数
export const requestWithRetry = async (requestFn, maxRetries = 3, delay = 1000) => {
for (let i = 0; i < maxRetries; i++) {
try {
return await requestFn()
} catch (error) {
if (i === maxRetries - 1) throw error
console.warn(`Request failed, retrying (${i + 1}/${maxRetries})...`)
await new Promise(resolve => setTimeout(resolve, delay * Math.pow(2, i)))
}
}
}
export default service

View File

@ -0,0 +1,51 @@
import service, { requestWithRetry } from './index'
/**
* 开始报告生成
* @param {Object} data - { simulation_id, force_regenerate? }
*/
export const generateReport = (data) => {
return requestWithRetry(() => service.post('/api/report/generate', data), 3, 1000)
}
/**
* 获取报告生成状态
* @param {string} reportId
*/
export const getReportStatus = (reportId) => {
return service.get(`/api/report/generate/status`, { params: { report_id: reportId } })
}
/**
* 获取 Agent 日志增量
* @param {string} reportId
* @param {number} fromLine - 从第几行开始获取
*/
export const getAgentLog = (reportId, fromLine = 0) => {
return service.get(`/api/report/${reportId}/agent-log`, { params: { from_line: fromLine } })
}
/**
* 获取控制台日志增量
* @param {string} reportId
* @param {number} fromLine - 从第几行开始获取
*/
export const getConsoleLog = (reportId, fromLine = 0) => {
return service.get(`/api/report/${reportId}/console-log`, { params: { from_line: fromLine } })
}
/**
* 获取报告详情
* @param {string} reportId
*/
export const getReport = (reportId) => {
return service.get(`/api/report/${reportId}`)
}
/**
* Report Agent 对话
* @param {Object} data - { simulation_id, message, chat_history? }
*/
export const chatWithReport = (data) => {
return requestWithRetry(() => service.post('/api/report/chat', data), 3, 1000)
}

View File

@ -0,0 +1,187 @@
import service, { requestWithRetry } from './index'
/**
* 创建模拟
* @param {Object} data - { project_id, graph_id?, enable_twitter?, enable_reddit? }
*/
export const createSimulation = (data) => {
return requestWithRetry(() => service.post('/api/simulation/create', data), 3, 1000)
}
/**
* 准备模拟环境异步任务
* @param {Object} data - { simulation_id, entity_types?, use_llm_for_profiles?, parallel_profile_count?, force_regenerate? }
*/
export const prepareSimulation = (data) => {
return requestWithRetry(() => service.post('/api/simulation/prepare', data), 3, 1000)
}
/**
* 查询准备任务进度
* @param {Object} data - { task_id?, simulation_id? }
*/
export const getPrepareStatus = (data) => {
return service.post('/api/simulation/prepare/status', data)
}
/**
* 获取模拟状态
* @param {string} simulationId
*/
export const getSimulation = (simulationId) => {
return service.get(`/api/simulation/${simulationId}`)
}
/**
* 获取模拟的 Agent Profiles
* @param {string} simulationId
* @param {string} platform - 'reddit' | 'twitter'
*/
export const getSimulationProfiles = (simulationId, platform = 'reddit') => {
return service.get(`/api/simulation/${simulationId}/profiles`, { params: { platform } })
}
/**
* 实时获取生成中的 Agent Profiles
* @param {string} simulationId
* @param {string} platform - 'reddit' | 'twitter'
*/
export const getSimulationProfilesRealtime = (simulationId, platform = 'reddit') => {
return service.get(`/api/simulation/${simulationId}/profiles/realtime`, { params: { platform } })
}
/**
* 获取模拟配置
* @param {string} simulationId
*/
export const getSimulationConfig = (simulationId) => {
return service.get(`/api/simulation/${simulationId}/config`)
}
/**
* 实时获取生成中的模拟配置
* @param {string} simulationId
* @returns {Promise} 返回配置信息包含元数据和配置内容
*/
export const getSimulationConfigRealtime = (simulationId) => {
return service.get(`/api/simulation/${simulationId}/config/realtime`)
}
/**
* 列出所有模拟
* @param {string} projectId - 可选按项目ID过滤
*/
export const listSimulations = (projectId) => {
const params = projectId ? { project_id: projectId } : {}
return service.get('/api/simulation/list', { params })
}
/**
* 启动模拟
* @param {Object} data - { simulation_id, platform?, max_rounds?, enable_graph_memory_update? }
*/
export const startSimulation = (data) => {
return requestWithRetry(() => service.post('/api/simulation/start', data), 3, 1000)
}
/**
* 停止模拟
* @param {Object} data - { simulation_id }
*/
export const stopSimulation = (data) => {
return service.post('/api/simulation/stop', data)
}
/**
* 获取模拟运行实时状态
* @param {string} simulationId
*/
export const getRunStatus = (simulationId) => {
return service.get(`/api/simulation/${simulationId}/run-status`)
}
/**
* 获取模拟运行详细状态包含最近动作
* @param {string} simulationId
*/
export const getRunStatusDetail = (simulationId) => {
return service.get(`/api/simulation/${simulationId}/run-status/detail`)
}
/**
* 获取模拟中的帖子
* @param {string} simulationId
* @param {string} platform - 'reddit' | 'twitter'
* @param {number} limit - 返回数量
* @param {number} offset - 偏移量
*/
export const getSimulationPosts = (simulationId, platform = 'reddit', limit = 50, offset = 0) => {
return service.get(`/api/simulation/${simulationId}/posts`, {
params: { platform, limit, offset }
})
}
/**
* 获取模拟时间线按轮次汇总
* @param {string} simulationId
* @param {number} startRound - 起始轮次
* @param {number} endRound - 结束轮次
*/
export const getSimulationTimeline = (simulationId, startRound = 0, endRound = null) => {
const params = { start_round: startRound }
if (endRound !== null) {
params.end_round = endRound
}
return service.get(`/api/simulation/${simulationId}/timeline`, { params })
}
/**
* 获取Agent统计信息
* @param {string} simulationId
*/
export const getAgentStats = (simulationId) => {
return service.get(`/api/simulation/${simulationId}/agent-stats`)
}
/**
* 获取模拟动作历史
* @param {string} simulationId
* @param {Object} params - { limit, offset, platform, agent_id, round_num }
*/
export const getSimulationActions = (simulationId, params = {}) => {
return service.get(`/api/simulation/${simulationId}/actions`, { params })
}
/**
* 关闭模拟环境优雅退出
* @param {Object} data - { simulation_id, timeout? }
*/
export const closeSimulationEnv = (data) => {
return service.post('/api/simulation/close-env', data)
}
/**
* 获取模拟环境状态
* @param {Object} data - { simulation_id }
*/
export const getEnvStatus = (data) => {
return service.post('/api/simulation/env-status', data)
}
/**
* 批量采访 Agent
* @param {Object} data - { simulation_id, interviews: [{ agent_id, prompt }] }
*/
export const interviewAgents = (data) => {
return requestWithRetry(() => service.post('/api/simulation/interview/batch', data), 3, 1000)
}
/**
* 获取历史模拟列表带项目详情
* 用于首页历史项目展示
* @param {number} limit - 返回数量限制
*/
export const getSimulationHistory = (limit = 20) => {
return service.get('/api/simulation/history', { params: { limit } })
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 587 KiB

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,124 @@
<template>
<div class="language-switcher" ref="switcherRef">
<button class="switcher-trigger" @click="toggleDropdown">
{{ currentLabel }}
<span class="caret">{{ open ? '▲' : '▼' }}</span>
</button>
<ul v-if="open" class="switcher-dropdown">
<li
v-for="loc in availableLocales"
:key="loc.key"
class="switcher-option"
:class="{ active: loc.key === locale }"
@click="switchLocale(loc.key)"
>
{{ loc.label }}
</li>
</ul>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { availableLocales } from '@/i18n/index.js'
const { locale } = useI18n()
const open = ref(false)
const switcherRef = ref(null)
const currentLabel = computed(() => {
const found = availableLocales.find(l => l.key === locale.value)
return found ? found.label : locale.value
})
const toggleDropdown = () => {
open.value = !open.value
}
const switchLocale = (key) => {
locale.value = key
localStorage.setItem('locale', key)
document.documentElement.lang = key
open.value = false
}
const onClickOutside = (e) => {
if (switcherRef.value && !switcherRef.value.contains(e.target)) {
open.value = false
}
}
onMounted(() => {
document.addEventListener('click', onClickOutside)
document.documentElement.lang = locale.value
})
onUnmounted(() => {
document.removeEventListener('click', onClickOutside)
})
</script>
<style scoped>
.language-switcher {
position: relative;
display: inline-block;
font-family: 'JetBrains Mono', monospace;
}
/* Light theme (default - for white header backgrounds) */
.switcher-trigger {
background: transparent;
color: #333;
border: 1px solid #CCC;
padding: 4px 12px;
font-family: 'JetBrains Mono', monospace;
font-size: 0.8rem;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
transition: border-color 0.2s, opacity 0.2s;
}
.switcher-trigger:hover {
border-color: #999;
}
.caret {
font-size: 0.6rem;
}
.switcher-dropdown {
position: absolute;
top: 100%;
right: 0;
margin-top: 4px;
background: #FFFFFF;
border: 1px solid #DDD;
list-style: none;
padding: 4px 0;
min-width: 100%;
z-index: 1000;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.switcher-option {
padding: 6px 12px;
font-size: 0.8rem;
color: #333;
cursor: pointer;
white-space: nowrap;
transition: background 0.15s;
}
.switcher-option:hover {
background: #F0F0F0;
}
.switcher-option.active {
color: var(--orange, #FF4500);
}
</style>

View File

@ -0,0 +1,700 @@
<template>
<div class="workbench-panel">
<div class="scroll-container">
<!-- Step 01: Ontology -->
<div class="step-card" :class="{ 'active': currentPhase === 0, 'completed': currentPhase > 0 }">
<div class="card-header">
<div class="step-info">
<span class="step-num">01</span>
<span class="step-title">{{ $t('step1.ontologyGeneration') }}</span>
</div>
<div class="step-status">
<span v-if="currentPhase > 0" class="badge success">{{ $t('step1.ontologyCompleted') }}</span>
<span v-else-if="currentPhase === 0" class="badge processing">{{ $t('step1.ontologyGenerating') }}</span>
<span v-else class="badge pending">{{ $t('step1.ontologyPending') }}</span>
</div>
</div>
<div class="card-content">
<p class="api-note">POST /api/graph/ontology/generate</p>
<p class="description">
{{ $t('step1.ontologyDesc') }}
</p>
<!-- Loading / Progress -->
<div v-if="currentPhase === 0 && ontologyProgress" class="progress-section">
<div class="spinner-sm"></div>
<span>{{ ontologyProgress.message || $t('step1.analyzingDocs') }}</span>
</div>
<!-- Detail Overlay -->
<div v-if="selectedOntologyItem" class="ontology-detail-overlay">
<div class="detail-header">
<div class="detail-title-group">
<span class="detail-type-badge">{{ selectedOntologyItem.itemType === 'entity' ? 'ENTITY' : 'RELATION' }}</span>
<span class="detail-name">{{ selectedOntologyItem.name }}</span>
</div>
<button class="close-btn" @click="selectedOntologyItem = null">×</button>
</div>
<div class="detail-body">
<div class="detail-desc">{{ selectedOntologyItem.description }}</div>
<!-- Attributes -->
<div class="detail-section" v-if="selectedOntologyItem.attributes?.length">
<span class="section-label">ATTRIBUTES</span>
<div class="attr-list">
<div v-for="attr in selectedOntologyItem.attributes" :key="attr.name" class="attr-item">
<span class="attr-name">{{ attr.name }}</span>
<span class="attr-type">({{ attr.type }})</span>
<span class="attr-desc">{{ attr.description }}</span>
</div>
</div>
</div>
<!-- Examples (Entity) -->
<div class="detail-section" v-if="selectedOntologyItem.examples?.length">
<span class="section-label">EXAMPLES</span>
<div class="example-list">
<span v-for="ex in selectedOntologyItem.examples" :key="ex" class="example-tag">{{ ex }}</span>
</div>
</div>
<!-- Source/Target (Relation) -->
<div class="detail-section" v-if="selectedOntologyItem.source_targets?.length">
<span class="section-label">CONNECTIONS</span>
<div class="conn-list">
<div v-for="(conn, idx) in selectedOntologyItem.source_targets" :key="idx" class="conn-item">
<span class="conn-node">{{ conn.source }}</span>
<span class="conn-arrow"></span>
<span class="conn-node">{{ conn.target }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- Generated Entity Tags -->
<div v-if="projectData?.ontology?.entity_types" class="tags-container" :class="{ 'dimmed': selectedOntologyItem }">
<span class="tag-label">GENERATED ENTITY TYPES</span>
<div class="tags-list">
<span
v-for="entity in projectData.ontology.entity_types"
:key="entity.name"
class="entity-tag clickable"
@click="selectOntologyItem(entity, 'entity')"
>
{{ entity.name }}
</span>
</div>
</div>
<!-- Generated Relation Tags -->
<div v-if="projectData?.ontology?.edge_types" class="tags-container" :class="{ 'dimmed': selectedOntologyItem }">
<span class="tag-label">GENERATED RELATION TYPES</span>
<div class="tags-list">
<span
v-for="rel in projectData.ontology.edge_types"
:key="rel.name"
class="entity-tag clickable"
@click="selectOntologyItem(rel, 'relation')"
>
{{ rel.name }}
</span>
</div>
</div>
</div>
</div>
<!-- Step 02: Graph Build -->
<div class="step-card" :class="{ 'active': currentPhase === 1, 'completed': currentPhase > 1 }">
<div class="card-header">
<div class="step-info">
<span class="step-num">02</span>
<span class="step-title">{{ $t('step1.graphRagBuild') }}</span>
</div>
<div class="step-status">
<span v-if="currentPhase > 1" class="badge success">{{ $t('step1.ontologyCompleted') }}</span>
<span v-else-if="currentPhase === 1" class="badge processing">{{ buildProgress?.progress || 0 }}%</span>
<span v-else class="badge pending">{{ $t('step1.ontologyPending') }}</span>
</div>
</div>
<div class="card-content">
<p class="api-note">POST /api/graph/build</p>
<p class="description">
{{ $t('step1.graphRagDesc') }}
</p>
<!-- Stats Cards -->
<div class="stats-grid">
<div class="stat-card">
<span class="stat-value">{{ graphStats.nodes }}</span>
<span class="stat-label">{{ $t('step1.entityNodes') }}</span>
</div>
<div class="stat-card">
<span class="stat-value">{{ graphStats.edges }}</span>
<span class="stat-label">{{ $t('step1.relationEdges') }}</span>
</div>
<div class="stat-card">
<span class="stat-value">{{ graphStats.types }}</span>
<span class="stat-label">{{ $t('step1.schemaTypes') }}</span>
</div>
</div>
</div>
</div>
<!-- Step 03: Complete -->
<div class="step-card" :class="{ 'active': currentPhase === 2, 'completed': currentPhase >= 2 }">
<div class="card-header">
<div class="step-info">
<span class="step-num">03</span>
<span class="step-title">{{ $t('step1.buildComplete') }}</span>
</div>
<div class="step-status">
<span v-if="currentPhase >= 2" class="badge accent">{{ $t('step1.inProgress') }}</span>
</div>
</div>
<div class="card-content">
<p class="api-note">POST /api/simulation/create</p>
<p class="description">{{ $t('step1.buildCompleteDesc') }}</p>
<button
class="action-btn"
:disabled="currentPhase < 2 || creatingSimulation"
@click="handleEnterEnvSetup"
>
<span v-if="creatingSimulation" class="spinner-sm"></span>
{{ creatingSimulation ? $t('step1.creating') : $t('step1.enterEnvSetup') + ' ➝' }}
</button>
</div>
</div>
</div>
<!-- Bottom Info / Logs -->
<div class="system-logs">
<div class="log-header">
<span class="log-title">SYSTEM DASHBOARD</span>
<span class="log-id">{{ projectData?.project_id || 'NO_PROJECT' }}</span>
</div>
<div class="log-content" ref="logContent">
<div class="log-line" v-for="(log, idx) in systemLogs" :key="idx">
<span class="log-time">{{ log.time }}</span>
<span class="log-msg">{{ log.msg }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, ref, watch, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { createSimulation } from '../api/simulation'
const router = useRouter()
const { t } = useI18n()
const props = defineProps({
currentPhase: { type: Number, default: 0 },
projectData: Object,
ontologyProgress: Object,
buildProgress: Object,
graphData: Object,
systemLogs: { type: Array, default: () => [] }
})
defineEmits(['next-step'])
const selectedOntologyItem = ref(null)
const logContent = ref(null)
const creatingSimulation = ref(false)
// - simulation
const handleEnterEnvSetup = async () => {
if (!props.projectData?.project_id || !props.projectData?.graph_id) {
console.error('缺少项目或图谱信息')
return
}
creatingSimulation.value = true
try {
const res = await createSimulation({
project_id: props.projectData.project_id,
graph_id: props.projectData.graph_id,
enable_twitter: true,
enable_reddit: true
})
if (res.success && res.data?.simulation_id) {
// simulation
router.push({
name: 'Simulation',
params: { simulationId: res.data.simulation_id }
})
} else {
console.error('创建模拟失败:', res.error)
alert(t('step1.createSimulationFailed', { error: res.error || t('common.unknownError') }))
}
} catch (err) {
console.error('创建模拟异常:', err)
alert(t('step1.createSimulationException', { error: err.message }))
} finally {
creatingSimulation.value = false
}
}
const selectOntologyItem = (item, type) => {
selectedOntologyItem.value = { ...item, itemType: type }
}
const graphStats = computed(() => {
const nodes = props.graphData?.node_count || props.graphData?.nodes?.length || 0
const edges = props.graphData?.edge_count || props.graphData?.edges?.length || 0
const types = props.projectData?.ontology?.entity_types?.length || 0
return { nodes, edges, types }
})
const formatDate = (dateStr) => {
if (!dateStr) return '--:--:--'
const d = new Date(dateStr)
return d.toLocaleTimeString('en-US', { hour12: false }) + '.' + d.getMilliseconds()
}
// Auto-scroll logs
watch(() => props.systemLogs.length, () => {
nextTick(() => {
if (logContent.value) {
logContent.value.scrollTop = logContent.value.scrollHeight
}
})
})
</script>
<style scoped>
.workbench-panel {
height: 100%;
background-color: #FAFAFA;
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
}
.scroll-container {
flex: 1;
overflow-y: auto;
padding: 24px;
display: flex;
flex-direction: column;
gap: 20px;
}
.step-card {
background: #FFF;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
border: 1px solid #EAEAEA;
transition: all 0.3s ease;
position: relative; /* For absolute overlay */
}
.step-card.active {
border-color: #FF5722;
box-shadow: 0 4px 12px rgba(255, 87, 34, 0.08);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.step-info {
display: flex;
align-items: center;
gap: 12px;
}
.step-num {
font-family: 'JetBrains Mono', monospace;
font-size: 20px;
font-weight: 700;
color: #E0E0E0;
}
.step-card.active .step-num,
.step-card.completed .step-num {
color: #000;
}
.step-title {
font-weight: 600;
font-size: 14px;
letter-spacing: 0.5px;
}
.badge {
font-size: 10px;
padding: 4px 8px;
border-radius: 4px;
font-weight: 600;
text-transform: uppercase;
}
.badge.success { background: #E8F5E9; color: #2E7D32; }
.badge.processing { background: #FF5722; color: #FFF; }
.badge.accent { background: #FF5722; color: #FFF; }
.badge.pending { background: #F5F5F5; color: #999; }
.api-note {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: #999;
margin-bottom: 8px;
}
.description {
font-size: 12px;
color: #666;
line-height: 1.5;
margin-bottom: 16px;
}
/* Step 01 Tags */
.tags-container {
margin-top: 12px;
transition: opacity 0.3s;
}
.tags-container.dimmed {
opacity: 0.3;
pointer-events: none;
}
.tag-label {
display: block;
font-size: 10px;
color: #AAA;
margin-bottom: 8px;
font-weight: 600;
}
.tags-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.entity-tag {
background: #F5F5F5;
border: 1px solid #EEE;
padding: 4px 10px;
border-radius: 4px;
font-size: 11px;
color: #333;
font-family: 'JetBrains Mono', monospace;
transition: all 0.2s;
}
.entity-tag.clickable {
cursor: pointer;
}
.entity-tag.clickable:hover {
background: #E0E0E0;
border-color: #CCC;
}
/* Ontology Detail Overlay */
.ontology-detail-overlay {
position: absolute;
top: 60px; /* Below header roughly */
left: 20px;
right: 20px;
bottom: 20px;
background: rgba(255, 255, 255, 0.98);
backdrop-filter: blur(4px);
z-index: 10;
border: 1px solid #EAEAEA;
box-shadow: 0 4px 20px rgba(0,0,0,0.05);
border-radius: 6px;
display: flex;
flex-direction: column;
overflow: hidden;
animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: translateY(0); } }
.detail-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid #EAEAEA;
background: #FAFAFA;
}
.detail-title-group {
display: flex;
align-items: center;
gap: 8px;
}
.detail-type-badge {
font-size: 9px;
font-weight: 700;
color: #FFF;
background: #000;
padding: 2px 6px;
border-radius: 2px;
text-transform: uppercase;
}
.detail-name {
font-size: 14px;
font-weight: 700;
font-family: 'JetBrains Mono', monospace;
}
.close-btn {
background: none;
border: none;
font-size: 18px;
color: #999;
cursor: pointer;
line-height: 1;
}
.close-btn:hover {
color: #333;
}
.detail-body {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.detail-desc {
font-size: 12px;
color: #444;
line-height: 1.5;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px dashed #EAEAEA;
}
.detail-section {
margin-bottom: 16px;
}
.section-label {
display: block;
font-size: 10px;
font-weight: 600;
color: #AAA;
margin-bottom: 8px;
}
.attr-list, .conn-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.attr-item {
font-size: 11px;
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: baseline;
padding: 4px;
background: #F9F9F9;
border-radius: 4px;
}
.attr-name {
font-family: 'JetBrains Mono', monospace;
font-weight: 600;
color: #000;
}
.attr-type {
color: #999;
font-size: 10px;
}
.attr-desc {
color: #555;
flex: 1;
min-width: 150px;
}
.example-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.example-tag {
font-size: 11px;
background: #FFF;
border: 1px solid #E0E0E0;
padding: 3px 8px;
border-radius: 12px;
color: #555;
}
.conn-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
padding: 6px;
background: #F5F5F5;
border-radius: 4px;
font-family: 'JetBrains Mono', monospace;
}
.conn-node {
font-weight: 600;
color: #333;
}
.conn-arrow {
color: #BBB;
}
/* Step 02 Stats */
.stats-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 12px;
background: #F9F9F9;
padding: 16px;
border-radius: 6px;
}
.stat-card {
text-align: center;
}
.stat-value {
display: block;
font-size: 20px;
font-weight: 700;
color: #000;
font-family: 'JetBrains Mono', monospace;
}
.stat-label {
font-size: 9px;
color: #999;
text-transform: uppercase;
margin-top: 4px;
display: block;
}
/* Step 03 Button */
.action-btn {
width: 100%;
background: #000;
color: #FFF;
border: none;
padding: 14px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
}
.action-btn:hover:not(:disabled) {
opacity: 0.8;
}
.action-btn:disabled {
background: #CCC;
cursor: not-allowed;
}
.progress-section {
display: flex;
align-items: center;
gap: 10px;
font-size: 12px;
color: #FF5722;
margin-bottom: 12px;
}
.spinner-sm {
width: 14px;
height: 14px;
border: 2px solid #FFCCBC;
border-top-color: #FF5722;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* System Logs */
.system-logs {
background: #000;
color: #DDD;
padding: 16px;
font-family: 'JetBrains Mono', monospace;
border-top: 1px solid #222;
flex-shrink: 0;
}
.log-header {
display: flex;
justify-content: space-between;
border-bottom: 1px solid #333;
padding-bottom: 8px;
margin-bottom: 8px;
font-size: 10px;
color: #888;
}
.log-content {
display: flex;
flex-direction: column;
gap: 4px;
height: 80px; /* Approx 4 lines visible */
overflow-y: auto;
padding-right: 4px;
}
.log-content::-webkit-scrollbar {
width: 4px;
}
.log-content::-webkit-scrollbar-thumb {
background: #333;
border-radius: 2px;
}
.log-line {
font-size: 11px;
display: flex;
gap: 12px;
line-height: 1.5;
}
.log-time {
color: #666;
min-width: 75px;
}
.log-msg {
color: #CCC;
word-break: break-all;
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,27 @@
import { createI18n } from 'vue-i18n'
import languages from '../../../locales/languages.json'
const localeFiles = import.meta.glob('../../../locales/!(languages).json', { eager: true })
const messages = {}
const availableLocales = []
for (const path in localeFiles) {
const key = path.match(/\/([^/]+)\.json$/)[1]
if (languages[key]) {
messages[key] = localeFiles[path].default
availableLocales.push({ key, label: languages[key].label })
}
}
const savedLocale = localStorage.getItem('locale') || 'zh'
const i18n = createI18n({
legacy: false,
locale: savedLocale,
fallbackLocale: 'zh',
messages
})
export { availableLocales }
export default i18n

11
frontend/src/main.js Normal file
View File

@ -0,0 +1,11 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import i18n from './i18n'
const app = createApp(App)
app.use(router)
app.use(i18n)
app.mount('#app')

View File

@ -0,0 +1,52 @@
import { createRouter, createWebHistory } from 'vue-router'
import Home from '../views/Home.vue'
import Process from '../views/MainView.vue'
import SimulationView from '../views/SimulationView.vue'
import SimulationRunView from '../views/SimulationRunView.vue'
import ReportView from '../views/ReportView.vue'
import InteractionView from '../views/InteractionView.vue'
const routes = [
{
path: '/',
name: 'Home',
component: Home
},
{
path: '/process/:projectId',
name: 'Process',
component: Process,
props: true
},
{
path: '/simulation/:simulationId',
name: 'Simulation',
component: SimulationView,
props: true
},
{
path: '/simulation/:simulationId/start',
name: 'SimulationRun',
component: SimulationRunView,
props: true
},
{
path: '/report/:reportId',
name: 'Report',
component: ReportView,
props: true
},
{
path: '/interaction/:reportId',
name: 'Interaction',
component: InteractionView,
props: true
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
export default router

View File

@ -0,0 +1,33 @@
/**
* 临时存储待上传的文件和需求
* 用于首页点击启动引擎后立即跳转在Process页面再进行API调用
*/
import { reactive } from 'vue'
const state = reactive({
files: [],
simulationRequirement: '',
isPending: false
})
export function setPendingUpload(files, requirement) {
state.files = files
state.simulationRequirement = requirement
state.isPending = true
}
export function getPendingUpload() {
return {
files: state.files,
simulationRequirement: state.simulationRequirement,
isPending: state.isPending
}
}
export function clearPendingUpload() {
state.files = []
state.simulationRequirement = ''
state.isPending = false
}
export default state

953
frontend/src/views/Home.vue Normal file
View File

@ -0,0 +1,953 @@
<template>
<div class="home-container">
<!-- 顶部导航栏 -->
<nav class="navbar">
<div class="nav-brand">MIROFISH</div>
<div class="nav-links">
<LanguageSwitcher />
<a href="https://github.com/666ghj/MiroFish" target="_blank" class="github-link">
{{ $t('nav.visitGithub') }} <span class="arrow"></span>
</a>
</div>
</nav>
<div class="main-content">
<!-- 上半部分Hero 区域 -->
<section class="hero-section">
<div class="hero-left">
<div class="tag-row">
<span class="orange-tag">{{ $t('home.tagline') }}</span>
<span class="version-text">{{ $t('home.version') }}</span>
</div>
<h1 class="main-title">
{{ $t('home.heroTitle1') }}<br>
<span class="gradient-text">{{ $t('home.heroTitle2') }}</span>
</h1>
<div class="hero-desc">
<p>
<i18n-t keypath="home.heroDesc" tag="span">
<template #brand><span class="highlight-bold">{{ $t('home.heroDescBrand') }}</span></template>
<template #agentScale><span class="highlight-orange">{{ $t('home.heroDescAgentScale') }}</span></template>
<template #optimalSolution><span class="highlight-code">{{ $t('home.heroDescOptimalSolution') }}</span></template>
</i18n-t>
</p>
<p class="slogan-text">
{{ $t('home.slogan') }}<span class="blinking-cursor">_</span>
</p>
</div>
<div class="decoration-square"></div>
</div>
<div class="hero-right">
<!-- Logo 区域 -->
<div class="logo-container">
<img src="../assets/logo/MiroFish_logo_left.jpeg" alt="MiroFish Logo" class="hero-logo" />
</div>
<button class="scroll-down-btn" @click="scrollToBottom">
</button>
</div>
</section>
<!-- 下半部分双栏布局 -->
<section class="dashboard-section">
<!-- 左栏状态与步骤 -->
<div class="left-panel">
<div class="panel-header">
<span class="status-dot"></span> {{ $t('home.systemStatus') }}
</div>
<h2 class="section-title">{{ $t('home.systemReady') }}</h2>
<p class="section-desc">
{{ $t('home.systemReadyDesc') }}
</p>
<!-- 数据指标卡片 -->
<div class="metrics-row">
<div class="metric-card">
<div class="metric-value">{{ $t('home.metricLowCost') }}</div>
<div class="metric-label">{{ $t('home.metricLowCostDesc') }}</div>
</div>
<div class="metric-card">
<div class="metric-value">{{ $t('home.metricHighAvail') }}</div>
<div class="metric-label">{{ $t('home.metricHighAvailDesc') }}</div>
</div>
</div>
<!-- 项目模拟步骤介绍 (新增区域) -->
<div class="steps-container">
<div class="steps-header">
<span class="diamond-icon"></span> {{ $t('home.workflowSequence') }}
</div>
<div class="workflow-list">
<div class="workflow-item">
<span class="step-num">01</span>
<div class="step-info">
<div class="step-title">{{ $t('home.step01Title') }}</div>
<div class="step-desc">{{ $t('home.step01Desc') }}</div>
</div>
</div>
<div class="workflow-item">
<span class="step-num">02</span>
<div class="step-info">
<div class="step-title">{{ $t('home.step02Title') }}</div>
<div class="step-desc">{{ $t('home.step02Desc') }}</div>
</div>
</div>
<div class="workflow-item">
<span class="step-num">03</span>
<div class="step-info">
<div class="step-title">{{ $t('home.step03Title') }}</div>
<div class="step-desc">{{ $t('home.step03Desc') }}</div>
</div>
</div>
<div class="workflow-item">
<span class="step-num">04</span>
<div class="step-info">
<div class="step-title">{{ $t('home.step04Title') }}</div>
<div class="step-desc">{{ $t('home.step04Desc') }}</div>
</div>
</div>
<div class="workflow-item">
<span class="step-num">05</span>
<div class="step-info">
<div class="step-title">{{ $t('home.step05Title') }}</div>
<div class="step-desc">{{ $t('home.step05Desc') }}</div>
</div>
</div>
</div>
</div>
</div>
<!-- 右栏交互控制台 -->
<div class="right-panel">
<div class="console-box">
<!-- 上传区域 -->
<div class="console-section">
<div class="console-header">
<span class="console-label">{{ $t('home.realitySeed') }}</span>
<span class="console-meta">{{ $t('home.supportedFormats') }}</span>
</div>
<div
class="upload-zone"
:class="{ 'drag-over': isDragOver, 'has-files': files.length > 0 }"
@dragover.prevent="handleDragOver"
@dragleave.prevent="handleDragLeave"
@drop.prevent="handleDrop"
@click="triggerFileInput"
>
<input
ref="fileInput"
type="file"
multiple
accept=".pdf,.md,.txt"
@change="handleFileSelect"
style="display: none"
:disabled="loading"
/>
<div v-if="files.length === 0" class="upload-placeholder">
<div class="upload-icon"></div>
<div class="upload-title">{{ $t('home.dragToUpload') }}</div>
<div class="upload-hint">{{ $t('home.orBrowse') }}</div>
</div>
<div v-else class="file-list">
<div v-for="(file, index) in files" :key="index" class="file-item">
<span class="file-icon">📄</span>
<span class="file-name">{{ file.name }}</span>
<button @click.stop="removeFile(index)" class="remove-btn">×</button>
</div>
</div>
</div>
</div>
<!-- 分割线 -->
<div class="console-divider">
<span>{{ $t('home.inputParams') }}</span>
</div>
<!-- 输入区域 -->
<div class="console-section">
<div class="console-header">
<span class="console-label">{{ $t('home.simulationPrompt') }}</span>
</div>
<div class="input-wrapper">
<textarea
v-model="formData.simulationRequirement"
class="code-input"
:placeholder="$t('home.promptPlaceholder')"
rows="6"
:disabled="loading"
></textarea>
<div class="model-badge">{{ $t('home.engineBadge') }}</div>
</div>
</div>
<!-- 启动按钮 -->
<div class="console-section btn-section">
<button
class="start-engine-btn"
@click="startSimulation"
:disabled="!canSubmit || loading"
>
<span v-if="!loading">{{ $t('home.startEngine') }}</span>
<span v-else>{{ $t('home.initializing') }}</span>
<span class="btn-arrow"></span>
</button>
</div>
</div>
</div>
</section>
<!-- 历史项目数据库 -->
<HistoryDatabase />
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import HistoryDatabase from '../components/HistoryDatabase.vue'
import LanguageSwitcher from '../components/LanguageSwitcher.vue'
const router = useRouter()
//
const formData = ref({
simulationRequirement: ''
})
//
const files = ref([])
//
const loading = ref(false)
const error = ref('')
const isDragOver = ref(false)
//
const fileInput = ref(null)
// :
const canSubmit = computed(() => {
return formData.value.simulationRequirement.trim() !== '' && files.value.length > 0
})
//
const triggerFileInput = () => {
if (!loading.value) {
fileInput.value?.click()
}
}
//
const handleFileSelect = (event) => {
const selectedFiles = Array.from(event.target.files)
addFiles(selectedFiles)
}
//
const handleDragOver = (e) => {
if (!loading.value) {
isDragOver.value = true
}
}
const handleDragLeave = (e) => {
isDragOver.value = false
}
const handleDrop = (e) => {
isDragOver.value = false
if (loading.value) return
const droppedFiles = Array.from(e.dataTransfer.files)
addFiles(droppedFiles)
}
//
const addFiles = (newFiles) => {
const validFiles = newFiles.filter(file => {
const ext = file.name.split('.').pop().toLowerCase()
return ['pdf', 'md', 'txt'].includes(ext)
})
files.value.push(...validFiles)
}
//
const removeFile = (index) => {
files.value.splice(index, 1)
}
//
const scrollToBottom = () => {
window.scrollTo({
top: document.body.scrollHeight,
behavior: 'smooth'
})
}
// - APIProcess
const startSimulation = () => {
if (!canSubmit.value || loading.value) return
//
import('../store/pendingUpload.js').then(({ setPendingUpload }) => {
setPendingUpload(files.value, formData.value.simulationRequirement)
// Process使
router.push({
name: 'Process',
params: { projectId: 'new' }
})
})
}
</script>
<style scoped>
/* 全局变量与重置 */
:root {
--black: #000000;
--white: #FFFFFF;
--orange: #FF4500;
--gray-light: #F5F5F5;
--gray-text: #666666;
--border: #E5E5E5;
/*
使用 Space Grotesk 作为主要标题字体JetBrains Mono 作为代码/标签字体
确保已在 index.html 引入这些 Google Fonts
*/
--font-mono: 'JetBrains Mono', monospace;
--font-sans: 'Space Grotesk', 'Noto Sans SC', system-ui, sans-serif;
--font-cn: 'Noto Sans SC', system-ui, sans-serif;
}
.home-container {
min-height: 100vh;
background: var(--white);
font-family: var(--font-sans);
color: var(--black);
}
/* 顶部导航 */
.navbar {
height: 60px;
background: var(--black);
color: var(--white);
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 40px;
}
.nav-brand {
font-family: var(--font-mono);
font-weight: 800;
letter-spacing: 1px;
font-size: 1.2rem;
}
.nav-links {
display: flex;
align-items: center;
gap: 16px;
}
.github-link {
color: var(--white);
text-decoration: none;
font-family: var(--font-mono);
font-size: 0.9rem;
font-weight: 500;
display: flex;
align-items: center;
gap: 8px;
transition: opacity 0.2s;
}
.github-link:hover {
opacity: 0.8;
}
.arrow {
font-family: sans-serif;
}
/* 主要内容区 */
.main-content {
max-width: 1400px;
margin: 0 auto;
padding: 60px 40px;
}
/* Hero 区域 */
.hero-section {
display: flex;
justify-content: space-between;
margin-bottom: 80px;
position: relative;
}
.hero-left {
flex: 1;
padding-right: 60px;
}
.tag-row {
display: flex;
align-items: center;
gap: 15px;
margin-bottom: 25px;
font-family: var(--font-mono);
font-size: 0.8rem;
}
.orange-tag {
background: var(--orange);
color: var(--white);
padding: 4px 10px;
font-weight: 700;
letter-spacing: 1px;
font-size: 0.75rem;
}
.version-text {
color: #999;
font-weight: 500;
letter-spacing: 0.5px;
}
.main-title {
font-size: 4.5rem;
line-height: 1.2;
font-weight: 500;
margin: 0 0 40px 0;
letter-spacing: -2px;
color: var(--black);
}
.gradient-text {
background: linear-gradient(90deg, #000000 0%, #444444 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
display: inline-block;
}
.hero-desc {
font-size: 1.05rem;
line-height: 1.8;
color: var(--gray-text);
max-width: 640px;
margin-bottom: 50px;
font-weight: 400;
text-align: justify;
}
.hero-desc p {
margin-bottom: 1.5rem;
}
.highlight-bold {
color: var(--black);
font-weight: 700;
}
.highlight-orange {
color: var(--orange);
font-weight: 700;
font-family: var(--font-mono);
}
.highlight-code {
background: rgba(0, 0, 0, 0.05);
padding: 2px 6px;
border-radius: 2px;
font-family: var(--font-mono);
font-size: 0.9em;
color: var(--black);
font-weight: 600;
}
.slogan-text {
font-size: 1.2rem;
font-weight: 520;
color: var(--black);
letter-spacing: 1px;
border-left: 3px solid var(--orange);
padding-left: 15px;
margin-top: 20px;
}
.blinking-cursor {
color: var(--orange);
animation: blink 1s step-end infinite;
font-weight: 700;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
.decoration-square {
width: 16px;
height: 16px;
background: var(--orange);
}
.hero-right {
flex: 0.8;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: flex-end;
}
.logo-container {
width: 100%;
display: flex;
justify-content: flex-end;
padding-right: 40px;
}
.hero-logo {
max-width: 500px; /* 调整logo大小 */
width: 100%;
}
.scroll-down-btn {
width: 40px;
height: 40px;
border: 1px solid var(--border);
background: transparent;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: var(--orange);
font-size: 1.2rem;
transition: all 0.2s;
}
.scroll-down-btn:hover {
border-color: var(--orange);
}
/* Dashboard 双栏布局 */
.dashboard-section {
display: flex;
gap: 60px;
border-top: 1px solid var(--border);
padding-top: 60px;
align-items: flex-start;
}
.dashboard-section .left-panel,
.dashboard-section .right-panel {
display: flex;
flex-direction: column;
}
/* 左侧面板 */
.left-panel {
flex: 0.8;
}
.panel-header {
font-family: var(--font-mono);
font-size: 0.8rem;
color: #999;
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 20px;
}
.status-dot {
color: var(--orange);
font-size: 0.8rem;
}
.section-title {
font-size: 2rem;
font-weight: 520;
margin: 0 0 15px 0;
}
.section-desc {
color: var(--gray-text);
margin-bottom: 25px;
line-height: 1.6;
}
.metrics-row {
display: flex;
gap: 20px;
margin-bottom: 15px;
}
.metric-card {
border: 1px solid var(--border);
padding: 20px 30px;
min-width: 150px;
}
.metric-value {
font-family: var(--font-mono);
font-size: 1.8rem;
font-weight: 520;
margin-bottom: 5px;
}
.metric-label {
font-size: 0.85rem;
color: #999;
}
/* 项目模拟步骤介绍 */
.steps-container {
border: 1px solid var(--border);
padding: 30px;
position: relative;
}
.steps-header {
font-family: var(--font-mono);
font-size: 0.8rem;
color: #999;
margin-bottom: 25px;
display: flex;
align-items: center;
gap: 8px;
}
.diamond-icon {
font-size: 1.2rem;
line-height: 1;
}
.workflow-list {
display: flex;
flex-direction: column;
gap: 20px;
}
.workflow-item {
display: flex;
align-items: flex-start;
gap: 20px;
}
.step-num {
font-family: var(--font-mono);
font-weight: 700;
color: var(--black);
opacity: 0.3;
}
.step-info {
flex: 1;
}
.step-title {
font-weight: 520;
font-size: 1rem;
margin-bottom: 4px;
}
.step-desc {
font-size: 0.85rem;
color: var(--gray-text);
}
/* 右侧交互控制台 */
.right-panel {
flex: 1.2;
}
.console-box {
border: 1px solid #CCC; /* 外部实线 */
padding: 8px; /* 内边距形成双重边框感 */
}
.console-section {
padding: 20px;
}
.console-section.btn-section {
padding-top: 0;
}
.console-header {
display: flex;
justify-content: space-between;
margin-bottom: 15px;
font-family: var(--font-mono);
font-size: 0.75rem;
color: #666;
}
.upload-zone {
border: 1px dashed #CCC;
height: 200px;
overflow-y: auto;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s;
background: #FAFAFA;
}
.upload-zone.has-files {
align-items: flex-start;
}
.upload-zone:hover {
background: #F0F0F0;
border-color: #999;
}
.upload-placeholder {
text-align: center;
}
.upload-icon {
width: 40px;
height: 40px;
border: 1px solid #DDD;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 15px;
color: #999;
}
.upload-title {
font-weight: 500;
font-size: 0.9rem;
margin-bottom: 5px;
}
.upload-hint {
font-family: var(--font-mono);
font-size: 0.75rem;
color: #999;
}
.file-list {
width: 100%;
padding: 15px;
display: flex;
flex-direction: column;
gap: 10px;
}
.file-item {
display: flex;
align-items: center;
background: var(--white);
padding: 8px 12px;
border: 1px solid #EEE;
font-family: var(--font-mono);
font-size: 0.85rem;
}
.file-name {
flex: 1;
margin: 0 10px;
}
.remove-btn {
background: none;
border: none;
cursor: pointer;
font-size: 1.2rem;
color: #999;
}
.console-divider {
display: flex;
align-items: center;
margin: 10px 0;
}
.console-divider::before,
.console-divider::after {
content: '';
flex: 1;
height: 1px;
background: #EEE;
}
.console-divider span {
padding: 0 15px;
font-family: var(--font-mono);
font-size: 0.7rem;
color: #BBB;
letter-spacing: 1px;
}
.input-wrapper {
position: relative;
border: 1px solid #DDD;
background: #FAFAFA;
}
.code-input {
width: 100%;
border: none;
background: transparent;
padding: 20px;
font-family: var(--font-mono);
font-size: 0.9rem;
line-height: 1.6;
resize: vertical;
outline: none;
min-height: 150px;
}
.model-badge {
position: absolute;
bottom: 10px;
right: 15px;
font-family: var(--font-mono);
font-size: 0.7rem;
color: #AAA;
}
.start-engine-btn {
width: 100%;
background: var(--black);
color: var(--white);
border: none;
padding: 20px;
font-family: var(--font-mono);
font-weight: 700;
font-size: 1.1rem;
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
transition: all 0.3s ease;
letter-spacing: 1px;
position: relative;
overflow: hidden;
}
/* 可点击状态(非禁用) */
.start-engine-btn:not(:disabled) {
background: var(--black);
border: 1px solid var(--black);
animation: pulse-border 2s infinite;
}
.start-engine-btn:hover:not(:disabled) {
background: var(--orange);
border-color: var(--orange);
transform: translateY(-2px);
}
.start-engine-btn:active:not(:disabled) {
transform: translateY(0);
}
.start-engine-btn:disabled {
background: #E5E5E5;
color: #999;
cursor: not-allowed;
transform: none;
border: 1px solid #E5E5E5;
}
/* 引导动画:微妙的边框脉冲 */
@keyframes pulse-border {
0% { box-shadow: 0 0 0 0 rgba(0, 0, 0, 0.2); }
70% { box-shadow: 0 0 0 6px rgba(0, 0, 0, 0); }
100% { box-shadow: 0 0 0 0 rgba(0, 0, 0, 0); }
}
/* 响应式适配 */
@media (max-width: 1024px) {
.dashboard-section {
flex-direction: column;
}
.hero-section {
flex-direction: column;
}
.hero-left {
padding-right: 0;
margin-bottom: 40px;
}
.hero-logo {
max-width: 200px;
margin-bottom: 20px;
}
}
</style>
<style>
/* English locale adjustments (unscoped to target html[lang]) */
html[lang="en"] .main-title {
font-size: 3.5rem;
font-family: 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
letter-spacing: -1px;
}
html[lang="en"] .hero-desc {
text-align: left;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
letter-spacing: 0;
}
html[lang="en"] .slogan-text {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
letter-spacing: 0;
}
html[lang="en"] .tag-row {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
html[lang="en"] .navbar .nav-links {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
/* Left pane: system status + workflow */
html[lang="en"] .status-section {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
html[lang="en"] .status-section .status-ready {
font-size: 1.6rem;
}
html[lang="en"] .status-section .metric-value {
font-family: 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 1.4rem;
}
html[lang="en"] .workflow-list .step-title {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
html[lang="en"] .workflow-list .step-desc {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif !important;
font-size: 0.72rem !important;
line-height: 1.4 !important;
}
html[lang="en"] .workflow-list {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
</style>

View File

@ -0,0 +1,355 @@
<template>
<div class="main-view">
<!-- Header -->
<header class="app-header">
<div class="header-left">
<div class="brand" @click="router.push('/')">MIROFISH</div>
</div>
<div class="header-center">
<div class="view-switcher">
<button
v-for="mode in ['graph', 'split', 'workbench']"
:key="mode"
class="switch-btn"
:class="{ active: viewMode === mode }"
@click="viewMode = mode"
>
{{ { graph: $t('main.layoutGraph'), split: $t('main.layoutSplit'), workbench: $t('main.layoutWorkbench') }[mode] }}
</button>
</div>
</div>
<div class="header-right">
<LanguageSwitcher />
<div class="step-divider"></div>
<div class="workflow-step">
<span class="step-num">Step 5/5</span>
<span class="step-name">{{ $tm('main.stepNames')[4] }}</span>
</div>
<div class="step-divider"></div>
<span class="status-indicator" :class="statusClass">
<span class="dot"></span>
{{ statusText }}
</span>
</div>
</header>
<!-- Main Content Area -->
<main class="content-area">
<!-- Left Panel: Graph -->
<div class="panel-wrapper left" :style="leftPanelStyle">
<GraphPanel
:graphData="graphData"
:loading="graphLoading"
:currentPhase="5"
:isSimulating="false"
@refresh="refreshGraph"
@toggle-maximize="toggleMaximize('graph')"
/>
</div>
<!-- Right Panel: Step5 深度互动 -->
<div class="panel-wrapper right" :style="rightPanelStyle">
<Step5Interaction
:reportId="currentReportId"
:simulationId="simulationId"
:systemLogs="systemLogs"
@add-log="addLog"
@update-status="updateStatus"
/>
</div>
</main>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import GraphPanel from '../components/GraphPanel.vue'
import Step5Interaction from '../components/Step5Interaction.vue'
import { getProject, getGraphData } from '../api/graph'
import { getSimulation } from '../api/simulation'
import { getReport } from '../api/report'
import LanguageSwitcher from '../components/LanguageSwitcher.vue'
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
// Props
const props = defineProps({
reportId: String
})
// Layout State -
const viewMode = ref('workbench')
// Data State
const currentReportId = ref(route.params.reportId)
const simulationId = ref(null)
const projectData = ref(null)
const graphData = ref(null)
const graphLoading = ref(false)
const systemLogs = ref([])
const currentStatus = ref('ready') // ready | processing | completed | error
// --- Computed Layout Styles ---
const leftPanelStyle = computed(() => {
if (viewMode.value === 'graph') return { width: '100%', opacity: 1, transform: 'translateX(0)' }
if (viewMode.value === 'workbench') return { width: '0%', opacity: 0, transform: 'translateX(-20px)' }
return { width: '50%', opacity: 1, transform: 'translateX(0)' }
})
const rightPanelStyle = computed(() => {
if (viewMode.value === 'workbench') return { width: '100%', opacity: 1, transform: 'translateX(0)' }
if (viewMode.value === 'graph') return { width: '0%', opacity: 0, transform: 'translateX(20px)' }
return { width: '50%', opacity: 1, transform: 'translateX(0)' }
})
// --- Status Computed ---
const statusClass = computed(() => {
return currentStatus.value
})
const statusText = computed(() => {
if (currentStatus.value === 'error') return 'Error'
if (currentStatus.value === 'completed') return 'Completed'
if (currentStatus.value === 'processing') return 'Processing'
return 'Ready'
})
// --- Helpers ---
const addLog = (msg) => {
const time = new Date().toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }) + '.' + new Date().getMilliseconds().toString().padStart(3, '0')
systemLogs.value.push({ time, msg })
if (systemLogs.value.length > 200) {
systemLogs.value.shift()
}
}
const updateStatus = (status) => {
currentStatus.value = status
}
// --- Layout Methods ---
const toggleMaximize = (target) => {
if (viewMode.value === target) {
viewMode.value = 'split'
} else {
viewMode.value = target
}
}
// --- Data Logic ---
const loadReportData = async () => {
try {
addLog(t('log.loadReportData', { id: currentReportId.value }))
// report simulation_id
const reportRes = await getReport(currentReportId.value)
if (reportRes.success && reportRes.data) {
const reportData = reportRes.data
simulationId.value = reportData.simulation_id
if (simulationId.value) {
// simulation
const simRes = await getSimulation(simulationId.value)
if (simRes.success && simRes.data) {
const simData = simRes.data
// project
if (simData.project_id) {
const projRes = await getProject(simData.project_id)
if (projRes.success && projRes.data) {
projectData.value = projRes.data
addLog(t('log.projectLoadSuccess', { id: projRes.data.project_id }))
// graph
if (projRes.data.graph_id) {
await loadGraph(projRes.data.graph_id)
}
}
}
}
}
} else {
addLog(t('log.getReportInfoFailed', { error: reportRes.error || t('common.unknownError') }))
}
} catch (err) {
addLog(t('log.loadException', { error: err.message }))
}
}
const loadGraph = async (graphId) => {
graphLoading.value = true
try {
const res = await getGraphData(graphId)
if (res.success) {
graphData.value = res.data
addLog(t('log.graphDataLoadSuccess'))
}
} catch (err) {
addLog(t('log.graphLoadFailed', { error: err.message }))
} finally {
graphLoading.value = false
}
}
const refreshGraph = () => {
if (projectData.value?.graph_id) {
loadGraph(projectData.value.graph_id)
}
}
// Watch route params
watch(() => route.params.reportId, (newId) => {
if (newId && newId !== currentReportId.value) {
currentReportId.value = newId
loadReportData()
}
}, { immediate: true })
onMounted(() => {
addLog(t('log.interactionViewInit'))
loadReportData()
})
</script>
<style scoped>
.main-view {
height: 100vh;
display: flex;
flex-direction: column;
background: #FFF;
overflow: hidden;
font-family: 'Space Grotesk', 'Noto Sans SC', system-ui, sans-serif;
}
/* Header */
.app-header {
height: 60px;
border-bottom: 1px solid #EAEAEA;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
background: #FFF;
z-index: 100;
position: relative;
}
.header-center {
position: absolute;
left: 50%;
transform: translateX(-50%);
}
.brand {
font-family: 'JetBrains Mono', monospace;
font-weight: 800;
font-size: 18px;
letter-spacing: 1px;
cursor: pointer;
}
.view-switcher {
display: flex;
background: #F5F5F5;
padding: 4px;
border-radius: 6px;
gap: 4px;
}
.switch-btn {
border: none;
background: transparent;
padding: 6px 16px;
font-size: 12px;
font-weight: 600;
color: #666;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
.switch-btn.active {
background: #FFF;
color: #000;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.header-right {
display: flex;
align-items: center;
gap: 16px;
}
.workflow-step {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
}
.step-num {
font-family: 'JetBrains Mono', monospace;
font-weight: 700;
color: #999;
}
.step-name {
font-weight: 700;
color: #000;
}
.step-divider {
width: 1px;
height: 14px;
background-color: #E0E0E0;
}
.status-indicator {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #666;
font-weight: 500;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #CCC;
}
.status-indicator.ready .dot { background: #4CAF50; }
.status-indicator.processing .dot { background: #FF9800; animation: pulse 1s infinite; }
.status-indicator.completed .dot { background: #4CAF50; }
.status-indicator.error .dot { background: #F44336; }
@keyframes pulse { 50% { opacity: 0.5; } }
/* Content */
.content-area {
flex: 1;
display: flex;
position: relative;
overflow: hidden;
}
.panel-wrapper {
height: 100%;
overflow: hidden;
transition: width 0.4s cubic-bezier(0.25, 0.8, 0.25, 1), opacity 0.3s ease, transform 0.3s ease;
will-change: width, opacity, transform;
}
.panel-wrapper.left {
border-right: 1px solid #EAEAEA;
}
</style>

View File

@ -0,0 +1,545 @@
<template>
<div class="main-view">
<!-- Header -->
<header class="app-header">
<div class="header-left">
<div class="brand" @click="router.push('/')">MIROFISH</div>
</div>
<div class="header-center">
<div class="view-switcher">
<button
v-for="mode in ['graph', 'split', 'workbench']"
:key="mode"
class="switch-btn"
:class="{ active: viewMode === mode }"
@click="viewMode = mode"
>
{{ { graph: $t('main.layoutGraph'), split: $t('main.layoutSplit'), workbench: $t('main.layoutWorkbench') }[mode] }}
</button>
</div>
</div>
<div class="header-right">
<LanguageSwitcher />
<div class="step-divider"></div>
<div class="workflow-step">
<span class="step-num">Step {{ currentStep }}/5</span>
<span class="step-name">{{ $tm('main.stepNames')[currentStep - 1] }}</span>
</div>
<div class="step-divider"></div>
<span class="status-indicator" :class="statusClass">
<span class="dot"></span>
{{ statusText }}
</span>
</div>
</header>
<!-- Main Content Area -->
<main class="content-area">
<!-- Left Panel: Graph -->
<div class="panel-wrapper left" :style="leftPanelStyle">
<GraphPanel
:graphData="graphData"
:loading="graphLoading"
:currentPhase="currentPhase"
@refresh="refreshGraph"
@toggle-maximize="toggleMaximize('graph')"
/>
</div>
<!-- Right Panel: Step Components -->
<div class="panel-wrapper right" :style="rightPanelStyle">
<!-- Step 1: 图谱构建 -->
<Step1GraphBuild
v-if="currentStep === 1"
:currentPhase="currentPhase"
:projectData="projectData"
:ontologyProgress="ontologyProgress"
:buildProgress="buildProgress"
:graphData="graphData"
:systemLogs="systemLogs"
@next-step="handleNextStep"
/>
<!-- Step 2: 环境搭建 -->
<Step2EnvSetup
v-else-if="currentStep === 2"
:projectData="projectData"
:graphData="graphData"
:systemLogs="systemLogs"
@go-back="handleGoBack"
@next-step="handleNextStep"
@add-log="addLog"
/>
</div>
</main>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import GraphPanel from '../components/GraphPanel.vue'
import Step1GraphBuild from '../components/Step1GraphBuild.vue'
import Step2EnvSetup from '../components/Step2EnvSetup.vue'
import { generateOntology, getProject, buildGraph, getTaskStatus, getGraphData } from '../api/graph'
import { getPendingUpload, clearPendingUpload } from '../store/pendingUpload'
import LanguageSwitcher from '../components/LanguageSwitcher.vue'
const route = useRoute()
const router = useRouter()
const { t, tm } = useI18n()
// Layout State
const viewMode = ref('split') // graph | split | workbench
// Step State
const currentStep = ref(1) // 1: , 2: , 3: , 4: , 5:
const stepNames = computed(() => tm('main.stepNames'))
// Data State
const currentProjectId = ref(route.params.projectId)
const loading = ref(false)
const graphLoading = ref(false)
const error = ref('')
const projectData = ref(null)
const graphData = ref(null)
const currentPhase = ref(-1) // -1: Upload, 0: Ontology, 1: Build, 2: Complete
const ontologyProgress = ref(null)
const buildProgress = ref(null)
const systemLogs = ref([])
// Polling timers
let pollTimer = null
let graphPollTimer = null
// --- Computed Layout Styles ---
const leftPanelStyle = computed(() => {
if (viewMode.value === 'graph') return { width: '100%', opacity: 1, transform: 'translateX(0)' }
if (viewMode.value === 'workbench') return { width: '0%', opacity: 0, transform: 'translateX(-20px)' }
return { width: '50%', opacity: 1, transform: 'translateX(0)' }
})
const rightPanelStyle = computed(() => {
if (viewMode.value === 'workbench') return { width: '100%', opacity: 1, transform: 'translateX(0)' }
if (viewMode.value === 'graph') return { width: '0%', opacity: 0, transform: 'translateX(20px)' }
return { width: '50%', opacity: 1, transform: 'translateX(0)' }
})
// --- Status Computed ---
const statusClass = computed(() => {
if (error.value) return 'error'
if (currentPhase.value >= 2) return 'completed'
return 'processing'
})
const statusText = computed(() => {
if (error.value) return 'Error'
if (currentPhase.value >= 2) return 'Ready'
if (currentPhase.value === 1) return 'Building Graph'
if (currentPhase.value === 0) return 'Generating Ontology'
return 'Initializing'
})
// --- Helpers ---
const addLog = (msg) => {
const time = new Date().toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }) + '.' + new Date().getMilliseconds().toString().padStart(3, '0')
systemLogs.value.push({ time, msg })
// Keep last 100 logs
if (systemLogs.value.length > 100) {
systemLogs.value.shift()
}
}
// --- Layout Methods ---
const toggleMaximize = (target) => {
if (viewMode.value === target) {
viewMode.value = 'split'
} else {
viewMode.value = target
}
}
const handleNextStep = (params = {}) => {
if (currentStep.value < 5) {
currentStep.value++
addLog(t('log.enterStep', { step: currentStep.value, name: stepNames.value[currentStep.value - 1] }))
// Step 2 Step 3
if (currentStep.value === 3 && params.maxRounds) {
addLog(t('log.customSimRounds', { rounds: params.maxRounds }))
}
}
}
const handleGoBack = () => {
if (currentStep.value > 1) {
currentStep.value--
addLog(t('log.returnToStep', { step: currentStep.value, name: stepNames.value[currentStep.value - 1] }))
}
}
// --- Data Logic ---
const initProject = async () => {
addLog('Project view initialized.')
if (currentProjectId.value === 'new') {
await handleNewProject()
} else {
await loadProject()
}
}
const handleNewProject = async () => {
const pending = getPendingUpload()
if (!pending.isPending || pending.files.length === 0) {
error.value = 'No pending files found.'
addLog('Error: No pending files found for new project.')
return
}
try {
loading.value = true
currentPhase.value = 0
ontologyProgress.value = { message: 'Uploading and analyzing docs...' }
addLog('Starting ontology generation: Uploading files...')
const formData = new FormData()
pending.files.forEach(f => formData.append('files', f))
formData.append('simulation_requirement', pending.simulationRequirement)
const res = await generateOntology(formData)
if (res.success) {
clearPendingUpload()
currentProjectId.value = res.data.project_id
projectData.value = res.data
router.replace({ name: 'Process', params: { projectId: res.data.project_id } })
ontologyProgress.value = null
addLog(`Ontology generated successfully for project ${res.data.project_id}`)
await startBuildGraph()
} else {
error.value = res.error || 'Ontology generation failed'
addLog(`Error generating ontology: ${error.value}`)
}
} catch (err) {
error.value = err.message
addLog(`Exception in handleNewProject: ${err.message}`)
} finally {
loading.value = false
}
}
const loadProject = async () => {
try {
loading.value = true
addLog(`Loading project ${currentProjectId.value}...`)
const res = await getProject(currentProjectId.value)
if (res.success) {
projectData.value = res.data
updatePhaseByStatus(res.data.status)
addLog(`Project loaded. Status: ${res.data.status}`)
if (res.data.status === 'ontology_generated' && !res.data.graph_id) {
await startBuildGraph()
} else if (res.data.status === 'graph_building' && res.data.graph_build_task_id) {
currentPhase.value = 1
startPollingTask(res.data.graph_build_task_id)
startGraphPolling()
} else if (res.data.status === 'graph_completed' && res.data.graph_id) {
currentPhase.value = 2
await loadGraph(res.data.graph_id)
}
} else {
error.value = res.error
addLog(`Error loading project: ${res.error}`)
}
} catch (err) {
error.value = err.message
addLog(`Exception in loadProject: ${err.message}`)
} finally {
loading.value = false
}
}
const updatePhaseByStatus = (status) => {
switch (status) {
case 'created':
case 'ontology_generated': currentPhase.value = 0; break;
case 'graph_building': currentPhase.value = 1; break;
case 'graph_completed': currentPhase.value = 2; break;
case 'failed': error.value = 'Project failed'; break;
}
}
const startBuildGraph = async () => {
try {
currentPhase.value = 1
buildProgress.value = { progress: 0, message: 'Starting build...' }
addLog('Initiating graph build...')
const res = await buildGraph({ project_id: currentProjectId.value })
if (res.success) {
addLog(`Graph build task started. Task ID: ${res.data.task_id}`)
startGraphPolling()
startPollingTask(res.data.task_id)
} else {
error.value = res.error
addLog(`Error starting build: ${res.error}`)
}
} catch (err) {
error.value = err.message
addLog(`Exception in startBuildGraph: ${err.message}`)
}
}
const startGraphPolling = () => {
addLog('Started polling for graph data...')
fetchGraphData()
graphPollTimer = setInterval(fetchGraphData, 10000)
}
const fetchGraphData = async () => {
try {
// Refresh project info to check for graph_id
const projRes = await getProject(currentProjectId.value)
if (projRes.success && projRes.data.graph_id) {
const gRes = await getGraphData(projRes.data.graph_id)
if (gRes.success) {
graphData.value = gRes.data
const nodeCount = gRes.data.node_count || gRes.data.nodes?.length || 0
const edgeCount = gRes.data.edge_count || gRes.data.edges?.length || 0
addLog(`Graph data refreshed. Nodes: ${nodeCount}, Edges: ${edgeCount}`)
}
}
} catch (err) {
console.warn('Graph fetch error:', err)
}
}
const startPollingTask = (taskId) => {
pollTaskStatus(taskId)
pollTimer = setInterval(() => pollTaskStatus(taskId), 2000)
}
const pollTaskStatus = async (taskId) => {
try {
const res = await getTaskStatus(taskId)
if (res.success) {
const task = res.data
// Log progress message if it changed
if (task.message && task.message !== buildProgress.value?.message) {
addLog(task.message)
}
buildProgress.value = { progress: task.progress || 0, message: task.message }
if (task.status === 'completed') {
addLog('Graph build task completed.')
stopPolling()
stopGraphPolling() // Stop polling, do final load
currentPhase.value = 2
// Final load
const projRes = await getProject(currentProjectId.value)
if (projRes.success && projRes.data.graph_id) {
projectData.value = projRes.data
await loadGraph(projRes.data.graph_id)
}
} else if (task.status === 'failed') {
stopPolling()
error.value = task.error
addLog(`Graph build task failed: ${task.error}`)
}
}
} catch (e) {
console.error(e)
}
}
const loadGraph = async (graphId) => {
graphLoading.value = true
addLog(`Loading full graph data: ${graphId}`)
try {
const res = await getGraphData(graphId)
if (res.success) {
graphData.value = res.data
addLog('Graph data loaded successfully.')
} else {
addLog(`Failed to load graph data: ${res.error}`)
}
} catch (e) {
addLog(`Exception loading graph: ${e.message}`)
} finally {
graphLoading.value = false
}
}
const refreshGraph = () => {
if (projectData.value?.graph_id) {
addLog('Manual graph refresh triggered.')
loadGraph(projectData.value.graph_id)
}
}
const stopPolling = () => {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
const stopGraphPolling = () => {
if (graphPollTimer) {
clearInterval(graphPollTimer)
graphPollTimer = null
addLog('Graph polling stopped.')
}
}
onMounted(() => {
initProject()
})
onUnmounted(() => {
stopPolling()
stopGraphPolling()
})
</script>
<style scoped>
.main-view {
height: 100vh;
display: flex;
flex-direction: column;
background: #FFF;
overflow: hidden;
font-family: 'Space Grotesk', 'Noto Sans SC', system-ui, sans-serif;
}
/* Header */
.app-header {
height: 60px;
border-bottom: 1px solid #EAEAEA;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
background: #FFF;
z-index: 100;
position: relative;
}
.header-center {
position: absolute;
left: 50%;
transform: translateX(-50%);
}
.brand {
font-family: 'JetBrains Mono', monospace;
font-weight: 800;
font-size: 18px;
letter-spacing: 1px;
cursor: pointer;
}
.view-switcher {
display: flex;
background: #F5F5F5;
padding: 4px;
border-radius: 6px;
gap: 4px;
}
.switch-btn {
border: none;
background: transparent;
padding: 6px 16px;
font-size: 12px;
font-weight: 600;
color: #666;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
.switch-btn.active {
background: #FFF;
color: #000;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.status-indicator {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #666;
font-weight: 500;
}
.header-right {
display: flex;
align-items: center;
gap: 16px;
}
.workflow-step {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
}
.step-num {
font-family: 'JetBrains Mono', monospace;
font-weight: 700;
color: #999;
}
.step-name {
font-weight: 700;
color: #000;
}
.step-divider {
width: 1px;
height: 14px;
background-color: #E0E0E0;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #CCC;
}
.status-indicator.processing .dot { background: #FF5722; animation: pulse 1s infinite; }
.status-indicator.completed .dot { background: #4CAF50; }
.status-indicator.error .dot { background: #F44336; }
@keyframes pulse { 50% { opacity: 0.5; } }
/* Content */
.content-area {
flex: 1;
display: flex;
position: relative;
overflow: hidden;
}
.panel-wrapper {
height: 100%;
overflow: hidden;
transition: width 0.4s cubic-bezier(0.25, 0.8, 0.25, 1), opacity 0.3s ease, transform 0.3s ease;
will-change: width, opacity, transform;
}
.panel-wrapper.left {
border-right: 1px solid #EAEAEA;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,353 @@
<template>
<div class="main-view">
<!-- Header -->
<header class="app-header">
<div class="header-left">
<div class="brand" @click="router.push('/')">MIROFISH</div>
</div>
<div class="header-center">
<div class="view-switcher">
<button
v-for="mode in ['graph', 'split', 'workbench']"
:key="mode"
class="switch-btn"
:class="{ active: viewMode === mode }"
@click="viewMode = mode"
>
{{ { graph: $t('main.layoutGraph'), split: $t('main.layoutSplit'), workbench: $t('main.layoutWorkbench') }[mode] }}
</button>
</div>
</div>
<div class="header-right">
<LanguageSwitcher />
<div class="step-divider"></div>
<div class="workflow-step">
<span class="step-num">Step 4/5</span>
<span class="step-name">{{ $tm('main.stepNames')[3] }}</span>
</div>
<div class="step-divider"></div>
<span class="status-indicator" :class="statusClass">
<span class="dot"></span>
{{ statusText }}
</span>
</div>
</header>
<!-- Main Content Area -->
<main class="content-area">
<!-- Left Panel: Graph -->
<div class="panel-wrapper left" :style="leftPanelStyle">
<GraphPanel
:graphData="graphData"
:loading="graphLoading"
:currentPhase="4"
:isSimulating="false"
@refresh="refreshGraph"
@toggle-maximize="toggleMaximize('graph')"
/>
</div>
<!-- Right Panel: Step4 报告生成 -->
<div class="panel-wrapper right" :style="rightPanelStyle">
<Step4Report
:reportId="currentReportId"
:simulationId="simulationId"
:systemLogs="systemLogs"
@add-log="addLog"
@update-status="updateStatus"
/>
</div>
</main>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import GraphPanel from '../components/GraphPanel.vue'
import Step4Report from '../components/Step4Report.vue'
import { getProject, getGraphData } from '../api/graph'
import { getSimulation } from '../api/simulation'
import { getReport } from '../api/report'
import LanguageSwitcher from '../components/LanguageSwitcher.vue'
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
// Props
const props = defineProps({
reportId: String
})
// Layout State -
const viewMode = ref('workbench')
// Data State
const currentReportId = ref(route.params.reportId)
const simulationId = ref(null)
const projectData = ref(null)
const graphData = ref(null)
const graphLoading = ref(false)
const systemLogs = ref([])
const currentStatus = ref('processing') // processing | completed | error
// --- Computed Layout Styles ---
const leftPanelStyle = computed(() => {
if (viewMode.value === 'graph') return { width: '100%', opacity: 1, transform: 'translateX(0)' }
if (viewMode.value === 'workbench') return { width: '0%', opacity: 0, transform: 'translateX(-20px)' }
return { width: '50%', opacity: 1, transform: 'translateX(0)' }
})
const rightPanelStyle = computed(() => {
if (viewMode.value === 'workbench') return { width: '100%', opacity: 1, transform: 'translateX(0)' }
if (viewMode.value === 'graph') return { width: '0%', opacity: 0, transform: 'translateX(20px)' }
return { width: '50%', opacity: 1, transform: 'translateX(0)' }
})
// --- Status Computed ---
const statusClass = computed(() => {
return currentStatus.value
})
const statusText = computed(() => {
if (currentStatus.value === 'error') return 'Error'
if (currentStatus.value === 'completed') return 'Completed'
return 'Generating'
})
// --- Helpers ---
const addLog = (msg) => {
const time = new Date().toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }) + '.' + new Date().getMilliseconds().toString().padStart(3, '0')
systemLogs.value.push({ time, msg })
if (systemLogs.value.length > 200) {
systemLogs.value.shift()
}
}
const updateStatus = (status) => {
currentStatus.value = status
}
// --- Layout Methods ---
const toggleMaximize = (target) => {
if (viewMode.value === target) {
viewMode.value = 'split'
} else {
viewMode.value = target
}
}
// --- Data Logic ---
const loadReportData = async () => {
try {
addLog(t('log.loadReportData', { id: currentReportId.value }))
// report simulation_id
const reportRes = await getReport(currentReportId.value)
if (reportRes.success && reportRes.data) {
const reportData = reportRes.data
simulationId.value = reportData.simulation_id
if (simulationId.value) {
// simulation
const simRes = await getSimulation(simulationId.value)
if (simRes.success && simRes.data) {
const simData = simRes.data
// project
if (simData.project_id) {
const projRes = await getProject(simData.project_id)
if (projRes.success && projRes.data) {
projectData.value = projRes.data
addLog(t('log.projectLoadSuccess', { id: projRes.data.project_id }))
// graph
if (projRes.data.graph_id) {
await loadGraph(projRes.data.graph_id)
}
}
}
}
}
} else {
addLog(t('log.getReportInfoFailed', { error: reportRes.error || t('common.unknownError') }))
}
} catch (err) {
addLog(t('log.loadException', { error: err.message }))
}
}
const loadGraph = async (graphId) => {
graphLoading.value = true
try {
const res = await getGraphData(graphId)
if (res.success) {
graphData.value = res.data
addLog(t('log.graphDataLoadSuccess'))
}
} catch (err) {
addLog(t('log.graphLoadFailed', { error: err.message }))
} finally {
graphLoading.value = false
}
}
const refreshGraph = () => {
if (projectData.value?.graph_id) {
loadGraph(projectData.value.graph_id)
}
}
// Watch route params
watch(() => route.params.reportId, (newId) => {
if (newId && newId !== currentReportId.value) {
currentReportId.value = newId
loadReportData()
}
}, { immediate: true })
onMounted(() => {
addLog(t('log.reportViewInit'))
loadReportData()
})
</script>
<style scoped>
.main-view {
height: 100vh;
display: flex;
flex-direction: column;
background: #FFF;
overflow: hidden;
font-family: 'Space Grotesk', 'Noto Sans SC', system-ui, sans-serif;
}
/* Header */
.app-header {
height: 60px;
border-bottom: 1px solid #EAEAEA;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
background: #FFF;
z-index: 100;
position: relative;
}
.header-center {
position: absolute;
left: 50%;
transform: translateX(-50%);
}
.brand {
font-family: 'JetBrains Mono', monospace;
font-weight: 800;
font-size: 18px;
letter-spacing: 1px;
cursor: pointer;
}
.view-switcher {
display: flex;
background: #F5F5F5;
padding: 4px;
border-radius: 6px;
gap: 4px;
}
.switch-btn {
border: none;
background: transparent;
padding: 6px 16px;
font-size: 12px;
font-weight: 600;
color: #666;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
.switch-btn.active {
background: #FFF;
color: #000;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.header-right {
display: flex;
align-items: center;
gap: 16px;
}
.workflow-step {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
}
.step-num {
font-family: 'JetBrains Mono', monospace;
font-weight: 700;
color: #999;
}
.step-name {
font-weight: 700;
color: #000;
}
.step-divider {
width: 1px;
height: 14px;
background-color: #E0E0E0;
}
.status-indicator {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #666;
font-weight: 500;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #CCC;
}
.status-indicator.processing .dot { background: #FF9800; animation: pulse 1s infinite; }
.status-indicator.completed .dot { background: #4CAF50; }
.status-indicator.error .dot { background: #F44336; }
@keyframes pulse { 50% { opacity: 0.5; } }
/* Content */
.content-area {
flex: 1;
display: flex;
position: relative;
overflow: hidden;
}
.panel-wrapper {
height: 100%;
overflow: hidden;
transition: width 0.4s cubic-bezier(0.25, 0.8, 0.25, 1), opacity 0.3s ease, transform 0.3s ease;
will-change: width, opacity, transform;
}
.panel-wrapper.left {
border-right: 1px solid #EAEAEA;
}
</style>

View File

@ -0,0 +1,452 @@
<template>
<div class="main-view">
<!-- Header -->
<header class="app-header">
<div class="header-left">
<div class="brand" @click="router.push('/')">MIROFISH</div>
</div>
<div class="header-center">
<div class="view-switcher">
<button
v-for="mode in ['graph', 'split', 'workbench']"
:key="mode"
class="switch-btn"
:class="{ active: viewMode === mode }"
@click="viewMode = mode"
>
{{ { graph: $t('main.layoutGraph'), split: $t('main.layoutSplit'), workbench: $t('main.layoutWorkbench') }[mode] }}
</button>
</div>
</div>
<div class="header-right">
<LanguageSwitcher />
<div class="step-divider"></div>
<div class="workflow-step">
<span class="step-num">Step 3/5</span>
<span class="step-name">{{ $tm('main.stepNames')[2] }}</span>
</div>
<div class="step-divider"></div>
<span class="status-indicator" :class="statusClass">
<span class="dot"></span>
{{ statusText }}
</span>
</div>
</header>
<!-- Main Content Area -->
<main class="content-area">
<!-- Left Panel: Graph -->
<div class="panel-wrapper left" :style="leftPanelStyle">
<GraphPanel
:graphData="graphData"
:loading="graphLoading"
:currentPhase="3"
:isSimulating="isSimulating"
@refresh="refreshGraph"
@toggle-maximize="toggleMaximize('graph')"
/>
</div>
<!-- Right Panel: Step3 开始模拟 -->
<div class="panel-wrapper right" :style="rightPanelStyle">
<Step3Simulation
:simulationId="currentSimulationId"
:maxRounds="maxRounds"
:minutesPerRound="minutesPerRound"
:projectData="projectData"
:graphData="graphData"
:systemLogs="systemLogs"
@go-back="handleGoBack"
@next-step="handleNextStep"
@add-log="addLog"
@update-status="updateStatus"
/>
</div>
</main>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import GraphPanel from '../components/GraphPanel.vue'
import Step3Simulation from '../components/Step3Simulation.vue'
import { getProject, getGraphData } from '../api/graph'
import { getSimulation, getSimulationConfig, stopSimulation, closeSimulationEnv, getEnvStatus } from '../api/simulation'
import LanguageSwitcher from '../components/LanguageSwitcher.vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
// Props
const props = defineProps({
simulationId: String
})
// Layout State
const viewMode = ref('split')
// Data State
const currentSimulationId = ref(route.params.simulationId)
// query maxRounds
const maxRounds = ref(route.query.maxRounds ? parseInt(route.query.maxRounds) : null)
const minutesPerRound = ref(30) // 30
const projectData = ref(null)
const graphData = ref(null)
const graphLoading = ref(false)
const systemLogs = ref([])
const currentStatus = ref('processing') // processing | completed | error
// --- Computed Layout Styles ---
const leftPanelStyle = computed(() => {
if (viewMode.value === 'graph') return { width: '100%', opacity: 1, transform: 'translateX(0)' }
if (viewMode.value === 'workbench') return { width: '0%', opacity: 0, transform: 'translateX(-20px)' }
return { width: '50%', opacity: 1, transform: 'translateX(0)' }
})
const rightPanelStyle = computed(() => {
if (viewMode.value === 'workbench') return { width: '100%', opacity: 1, transform: 'translateX(0)' }
if (viewMode.value === 'graph') return { width: '0%', opacity: 0, transform: 'translateX(20px)' }
return { width: '50%', opacity: 1, transform: 'translateX(0)' }
})
// --- Status Computed ---
const statusClass = computed(() => {
return currentStatus.value
})
const statusText = computed(() => {
if (currentStatus.value === 'error') return 'Error'
if (currentStatus.value === 'completed') return 'Completed'
return 'Running'
})
const isSimulating = computed(() => currentStatus.value === 'processing')
// --- Helpers ---
const addLog = (msg) => {
const time = new Date().toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }) + '.' + new Date().getMilliseconds().toString().padStart(3, '0')
systemLogs.value.push({ time, msg })
if (systemLogs.value.length > 200) {
systemLogs.value.shift()
}
}
const updateStatus = (status) => {
currentStatus.value = status
}
// --- Layout Methods ---
const toggleMaximize = (target) => {
if (viewMode.value === target) {
viewMode.value = 'split'
} else {
viewMode.value = target
}
}
const handleGoBack = async () => {
// Step 2
addLog(t('log.preparingGoBack'))
//
stopGraphRefresh()
try {
//
const envStatusRes = await getEnvStatus({ simulation_id: currentSimulationId.value })
if (envStatusRes.success && envStatusRes.data?.env_alive) {
addLog(t('log.closingSimEnv'))
try {
await closeSimulationEnv({
simulation_id: currentSimulationId.value,
timeout: 10
})
addLog(t('log.simEnvClosed'))
} catch (closeErr) {
addLog(t('log.closeSimEnvFailed'))
try {
await stopSimulation({ simulation_id: currentSimulationId.value })
addLog(t('log.simForceStopSuccess'))
} catch (stopErr) {
addLog(t('log.forceStopFailed', { error: stopErr.message }))
}
}
} else {
//
if (isSimulating.value) {
addLog(t('log.stoppingSimProcess'))
try {
await stopSimulation({ simulation_id: currentSimulationId.value })
addLog(t('log.simStopped'))
} catch (err) {
addLog(t('log.stopSimFailed', { error: err.message }))
}
}
}
} catch (err) {
addLog(t('log.checkStatusFailed', { error: err.message }))
}
// Step 2 ()
router.push({ name: 'Simulation', params: { simulationId: currentSimulationId.value } })
}
const handleNextStep = () => {
// Step3Simulation
//
addLog(t('log.enterStep4'))
}
// --- Data Logic ---
const loadSimulationData = async () => {
try {
addLog(t('log.loadingSimData', { id: currentSimulationId.value }))
// simulation
const simRes = await getSimulation(currentSimulationId.value)
if (simRes.success && simRes.data) {
const simData = simRes.data
// simulation config minutes_per_round
try {
const configRes = await getSimulationConfig(currentSimulationId.value)
if (configRes.success && configRes.data?.time_config?.minutes_per_round) {
minutesPerRound.value = configRes.data.time_config.minutes_per_round
addLog(t('log.timeConfig', { minutes: minutesPerRound.value }))
}
} catch (configErr) {
addLog(t('log.timeConfigFetchFailed', { minutes: minutesPerRound.value }))
}
// project
if (simData.project_id) {
const projRes = await getProject(simData.project_id)
if (projRes.success && projRes.data) {
projectData.value = projRes.data
addLog(t('log.projectLoadSuccess', { id: projRes.data.project_id }))
// graph
if (projRes.data.graph_id) {
await loadGraph(projRes.data.graph_id)
}
}
}
} else {
addLog(t('log.loadSimDataFailed', { error: simRes.error || t('common.unknownError') }))
}
} catch (err) {
addLog(t('log.loadException', { error: err.message }))
}
}
const loadGraph = async (graphId) => {
// loading
// loading
if (!isSimulating.value) {
graphLoading.value = true
}
try {
const res = await getGraphData(graphId)
if (res.success) {
graphData.value = res.data
if (!isSimulating.value) {
addLog(t('log.graphDataLoadSuccess'))
}
}
} catch (err) {
addLog(t('log.graphLoadFailed', { error: err.message }))
} finally {
graphLoading.value = false
}
}
const refreshGraph = () => {
if (projectData.value?.graph_id) {
loadGraph(projectData.value.graph_id)
}
}
// --- Auto Refresh Logic ---
let graphRefreshTimer = null
const startGraphRefresh = () => {
if (graphRefreshTimer) return
addLog(t('log.graphRealtimeRefreshStart'))
// 30
graphRefreshTimer = setInterval(refreshGraph, 30000)
}
const stopGraphRefresh = () => {
if (graphRefreshTimer) {
clearInterval(graphRefreshTimer)
graphRefreshTimer = null
addLog(t('log.graphRealtimeRefreshStop'))
}
}
watch(isSimulating, (newValue) => {
if (newValue) {
startGraphRefresh()
} else {
stopGraphRefresh()
}
}, { immediate: true })
onMounted(() => {
addLog(t('log.simRunViewInit'))
// maxRounds query
if (maxRounds.value) {
addLog(t('log.customRounds', { rounds: maxRounds.value }))
}
loadSimulationData()
})
onUnmounted(() => {
stopGraphRefresh()
})
</script>
<style scoped>
.main-view {
height: 100vh;
display: flex;
flex-direction: column;
background: #FFF;
overflow: hidden;
font-family: 'Space Grotesk', 'Noto Sans SC', system-ui, sans-serif;
}
/* Header */
.app-header {
height: 60px;
border-bottom: 1px solid #EAEAEA;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
background: #FFF;
z-index: 100;
position: relative;
}
.header-center {
position: absolute;
left: 50%;
transform: translateX(-50%);
}
.brand {
font-family: 'JetBrains Mono', monospace;
font-weight: 800;
font-size: 18px;
letter-spacing: 1px;
cursor: pointer;
}
.view-switcher {
display: flex;
background: #F5F5F5;
padding: 4px;
border-radius: 6px;
gap: 4px;
}
.switch-btn {
border: none;
background: transparent;
padding: 6px 16px;
font-size: 12px;
font-weight: 600;
color: #666;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
.switch-btn.active {
background: #FFF;
color: #000;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.header-right {
display: flex;
align-items: center;
gap: 16px;
}
.workflow-step {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
}
.step-num {
font-family: 'JetBrains Mono', monospace;
font-weight: 700;
color: #999;
}
.step-name {
font-weight: 700;
color: #000;
}
.step-divider {
width: 1px;
height: 14px;
background-color: #E0E0E0;
}
.status-indicator {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #666;
font-weight: 500;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #CCC;
}
.status-indicator.processing .dot { background: #FF5722; animation: pulse 1s infinite; }
.status-indicator.completed .dot { background: #4CAF50; }
.status-indicator.error .dot { background: #F44336; }
@keyframes pulse { 50% { opacity: 0.5; } }
/* Content */
.content-area {
flex: 1;
display: flex;
position: relative;
overflow: hidden;
}
.panel-wrapper {
height: 100%;
overflow: hidden;
transition: width 0.4s cubic-bezier(0.25, 0.8, 0.25, 1), opacity 0.3s ease, transform 0.3s ease;
will-change: width, opacity, transform;
}
.panel-wrapper.left {
border-right: 1px solid #EAEAEA;
}
</style>

View File

@ -0,0 +1,439 @@
<template>
<div class="main-view">
<!-- Header -->
<header class="app-header">
<div class="header-left">
<div class="brand" @click="router.push('/')">MIROFISH</div>
</div>
<div class="header-center">
<div class="view-switcher">
<button
v-for="mode in ['graph', 'split', 'workbench']"
:key="mode"
class="switch-btn"
:class="{ active: viewMode === mode }"
@click="viewMode = mode"
>
{{ { graph: $t('main.layoutGraph'), split: $t('main.layoutSplit'), workbench: $t('main.layoutWorkbench') }[mode] }}
</button>
</div>
</div>
<div class="header-right">
<LanguageSwitcher />
<div class="step-divider"></div>
<div class="workflow-step">
<span class="step-num">Step 2/5</span>
<span class="step-name">{{ $tm('main.stepNames')[1] }}</span>
</div>
<div class="step-divider"></div>
<span class="status-indicator" :class="statusClass">
<span class="dot"></span>
{{ statusText }}
</span>
</div>
</header>
<!-- Main Content Area -->
<main class="content-area">
<!-- Left Panel: Graph -->
<div class="panel-wrapper left" :style="leftPanelStyle">
<GraphPanel
:graphData="graphData"
:loading="graphLoading"
:currentPhase="2"
@refresh="refreshGraph"
@toggle-maximize="toggleMaximize('graph')"
/>
</div>
<!-- Right Panel: Step2 环境搭建 -->
<div class="panel-wrapper right" :style="rightPanelStyle">
<Step2EnvSetup
:simulationId="currentSimulationId"
:projectData="projectData"
:graphData="graphData"
:systemLogs="systemLogs"
@go-back="handleGoBack"
@next-step="handleNextStep"
@add-log="addLog"
@update-status="updateStatus"
/>
</div>
</main>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import GraphPanel from '../components/GraphPanel.vue'
import Step2EnvSetup from '../components/Step2EnvSetup.vue'
import { getProject, getGraphData } from '../api/graph'
import { getSimulation, stopSimulation, getEnvStatus, closeSimulationEnv } from '../api/simulation'
import LanguageSwitcher from '../components/LanguageSwitcher.vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
// Props
const props = defineProps({
simulationId: String
})
// Layout State
const viewMode = ref('split')
// Data State
const currentSimulationId = ref(route.params.simulationId)
const projectData = ref(null)
const graphData = ref(null)
const graphLoading = ref(false)
const systemLogs = ref([])
const currentStatus = ref('processing') // processing | completed | error
// --- Computed Layout Styles ---
const leftPanelStyle = computed(() => {
if (viewMode.value === 'graph') return { width: '100%', opacity: 1, transform: 'translateX(0)' }
if (viewMode.value === 'workbench') return { width: '0%', opacity: 0, transform: 'translateX(-20px)' }
return { width: '50%', opacity: 1, transform: 'translateX(0)' }
})
const rightPanelStyle = computed(() => {
if (viewMode.value === 'workbench') return { width: '100%', opacity: 1, transform: 'translateX(0)' }
if (viewMode.value === 'graph') return { width: '0%', opacity: 0, transform: 'translateX(20px)' }
return { width: '50%', opacity: 1, transform: 'translateX(0)' }
})
// --- Status Computed ---
const statusClass = computed(() => {
return currentStatus.value
})
const statusText = computed(() => {
if (currentStatus.value === 'error') return 'Error'
if (currentStatus.value === 'completed') return 'Ready'
return 'Preparing'
})
// --- Helpers ---
const addLog = (msg) => {
const time = new Date().toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }) + '.' + new Date().getMilliseconds().toString().padStart(3, '0')
systemLogs.value.push({ time, msg })
if (systemLogs.value.length > 100) {
systemLogs.value.shift()
}
}
const updateStatus = (status) => {
currentStatus.value = status
}
// --- Layout Methods ---
const toggleMaximize = (target) => {
if (viewMode.value === target) {
viewMode.value = 'split'
} else {
viewMode.value = target
}
}
const handleGoBack = () => {
// process
if (projectData.value?.project_id) {
router.push({ name: 'Process', params: { projectId: projectData.value.project_id } })
} else {
router.push('/')
}
}
const handleNextStep = (params = {}) => {
addLog(t('log.enterStep3'))
//
if (params.maxRounds) {
addLog(t('log.customRoundsConfig', { rounds: params.maxRounds }))
} else {
addLog(t('log.useAutoRounds'))
}
//
const routeParams = {
name: 'SimulationRun',
params: { simulationId: currentSimulationId.value }
}
// query
if (params.maxRounds) {
routeParams.query = { maxRounds: params.maxRounds }
}
// Step 3
router.push(routeParams)
}
// --- Data Logic ---
/**
* 检查并关闭正在运行的模拟
* 当用户从 Step 3 返回到 Step 2 默认用户要退出模拟
*/
const checkAndStopRunningSimulation = async () => {
if (!currentSimulationId.value) return
try {
//
const envStatusRes = await getEnvStatus({ simulation_id: currentSimulationId.value })
if (envStatusRes.success && envStatusRes.data?.env_alive) {
addLog(t('log.detectedSimEnvRunning'))
//
try {
const closeRes = await closeSimulationEnv({
simulation_id: currentSimulationId.value,
timeout: 10 // 10
})
if (closeRes.success) {
addLog(t('log.simEnvClosed'))
} else {
addLog(t('log.closeSimEnvFailedWithError', { error: closeRes.error || t('common.unknownError') }))
//
await forceStopSimulation()
}
} catch (closeErr) {
addLog(t('log.closeSimEnvException', { error: closeErr.message }))
//
await forceStopSimulation()
}
} else {
//
const simRes = await getSimulation(currentSimulationId.value)
if (simRes.success && simRes.data?.status === 'running') {
addLog(t('log.detectedSimRunning'))
await forceStopSimulation()
}
}
} catch (err) {
//
console.warn('检查模拟状态失败:', err)
}
}
/**
* 强制停止模拟
*/
const forceStopSimulation = async () => {
try {
const stopRes = await stopSimulation({ simulation_id: currentSimulationId.value })
if (stopRes.success) {
addLog(t('log.simForceStopSuccess'))
} else {
addLog(t('log.forceStopSimFailed', { error: stopRes.error || t('common.unknownError') }))
}
} catch (err) {
addLog(t('log.forceStopSimException', { error: err.message }))
}
}
const loadSimulationData = async () => {
try {
addLog(t('log.loadingSimData', { id: currentSimulationId.value }))
// simulation
const simRes = await getSimulation(currentSimulationId.value)
if (simRes.success && simRes.data) {
const simData = simRes.data
// project
if (simData.project_id) {
const projRes = await getProject(simData.project_id)
if (projRes.success && projRes.data) {
projectData.value = projRes.data
addLog(t('log.projectLoadSuccess', { id: projRes.data.project_id }))
// graph
if (projRes.data.graph_id) {
await loadGraph(projRes.data.graph_id)
}
}
}
} else {
addLog(t('log.loadSimDataFailed', { error: simRes.error || t('common.unknownError') }))
}
} catch (err) {
addLog(t('log.loadException', { error: err.message }))
}
}
const loadGraph = async (graphId) => {
graphLoading.value = true
try {
const res = await getGraphData(graphId)
if (res.success) {
graphData.value = res.data
addLog(t('log.graphDataLoadSuccess'))
}
} catch (err) {
addLog(t('log.graphLoadFailed', { error: err.message }))
} finally {
graphLoading.value = false
}
}
const refreshGraph = () => {
if (projectData.value?.graph_id) {
loadGraph(projectData.value.graph_id)
}
}
onMounted(async () => {
addLog(t('log.simViewInit'))
// Step 3
await checkAndStopRunningSimulation()
//
loadSimulationData()
})
</script>
<style scoped>
.main-view {
height: 100vh;
display: flex;
flex-direction: column;
background: #FFF;
overflow: hidden;
font-family: 'Space Grotesk', 'Noto Sans SC', system-ui, sans-serif;
}
/* Header */
.app-header {
height: 60px;
border-bottom: 1px solid #EAEAEA;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
background: #FFF;
z-index: 100;
position: relative;
}
.brand {
font-family: 'JetBrains Mono', monospace;
font-weight: 800;
font-size: 18px;
letter-spacing: 1px;
cursor: pointer;
}
.header-center {
position: absolute;
left: 50%;
transform: translateX(-50%);
}
.view-switcher {
display: flex;
background: #F5F5F5;
padding: 4px;
border-radius: 6px;
gap: 4px;
}
.switch-btn {
border: none;
background: transparent;
padding: 6px 16px;
font-size: 12px;
font-weight: 600;
color: #666;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
.switch-btn.active {
background: #FFF;
color: #000;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.header-right {
display: flex;
align-items: center;
gap: 16px;
}
.workflow-step {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
}
.step-num {
font-family: 'JetBrains Mono', monospace;
font-weight: 700;
color: #999;
}
.step-name {
font-weight: 700;
color: #000;
}
.step-divider {
width: 1px;
height: 14px;
background-color: #E0E0E0;
}
.status-indicator {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #666;
font-weight: 500;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #CCC;
}
.status-indicator.processing .dot { background: #FF5722; animation: pulse 1s infinite; }
.status-indicator.completed .dot { background: #4CAF50; }
.status-indicator.error .dot { background: #F44336; }
@keyframes pulse { 50% { opacity: 0.5; } }
/* Content */
.content-area {
flex: 1;
display: flex;
position: relative;
overflow: hidden;
}
.panel-wrapper {
height: 100%;
overflow: hidden;
transition: width 0.4s cubic-bezier(0.25, 0.8, 0.25, 1), opacity 0.3s ease, transform 0.3s ease;
will-change: width, opacity, transform;
}
.panel-wrapper.left {
border-right: 1px solid #EAEAEA;
}
</style>

25
frontend/vite.config.js Normal file
View File

@ -0,0 +1,25 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
'@locales': path.resolve(__dirname, '../locales')
}
},
server: {
port: 3000,
open: true,
proxy: {
'/api': {
target: 'http://localhost:5001',
changeOrigin: true,
secure: false
}
}
}
})

665
locales/en.json Normal file
View File

@ -0,0 +1,665 @@
{
"common": {
"confirm": "Confirm",
"cancel": "Cancel",
"loading": "Loading...",
"error": "Error",
"success": "Success",
"completed": "Completed",
"processing": "Generating",
"pending": "Pending",
"ready": "Ready",
"running": "Running",
"failed": "Failed",
"unknown": "Unknown",
"unknownError": "Unknown error",
"none": "None",
"close": "Close",
"back": "Back",
"next": "Next",
"retry": "Retry",
"noData": "No data available",
"hours": "hours",
"minutes": "minutes",
"rounds": "rounds",
"items": "items",
"files": "files"
},
"meta": {
"title": "MiroFish - Predict Everything",
"description": "MiroFish - Social Media Opinion Simulation System"
},
"nav": {
"visitGithub": "Visit our Github page"
},
"home": {
"tagline": "Concise & Universal Swarm Intelligence Engine",
"version": "/ v0.1-Preview",
"heroTitle1": "Upload Reports,",
"heroTitle2": "Predict the Future",
"heroDesc": "From a single document, {brand} extracts reality seeds to auto-generate a parallel world with up to {agentScale}. Inject variables from a god's-eye view to find the {optimalSolution} in complex group dynamics.",
"heroDescBrand": "MiroFish",
"heroDescAgentScale": "million-scale Agents",
"heroDescOptimalSolution": "\"local optimum\"",
"slogan": "Let Agents rehearse the future, let decisions prevail",
"systemStatus": "System Status",
"systemReady": "Ready",
"systemReadyDesc": "Prediction engine on standby. Upload unstructured data to initialize a simulation sequence.",
"metricLowCost": "Low Cost",
"metricLowCostDesc": "Avg. $5/sim",
"metricHighAvail": "Scalable",
"metricHighAvailDesc": "Millions of Agents",
"workflowSequence": "Workflow",
"step01Title": "Graph Build",
"step01Desc": "Seed extraction & memory injection & GraphRAG construction",
"step02Title": "Env Setup",
"step02Desc": "Entity extraction & persona generation & Agent config injection",
"step03Title": "Simulation",
"step03Desc": "Dual-platform parallel sim & auto-parse requirements & temporal memory",
"step04Title": "Report",
"step04Desc": "ReportAgent interacts with the post-simulation environment via rich tools",
"step05Title": "Interaction",
"step05Desc": "Chat with any simulated individual & converse with ReportAgent",
"realitySeed": "01 / Reality Seed",
"supportedFormats": "Formats: PDF, MD, TXT",
"dragToUpload": "Drag files to upload",
"orBrowse": "or click to browse files",
"inputParams": "Input Parameters",
"simulationPrompt": ">_ 02 / Simulation Prompt",
"promptPlaceholder": "// Describe your simulation or prediction requirement in natural language",
"engineBadge": "Engine: MiroFish-V1.0",
"startEngine": "Start Engine",
"initializing": "Initializing..."
},
"main": {
"layoutGraph": "Graph",
"layoutSplit": "Split",
"layoutWorkbench": "Workbench",
"stepNames": ["Graph Build", "Env Setup", "Run Simulation", "Report Generation", "Deep Interaction"]
},
"step1": {
"ontologyGeneration": "Ontology Generation",
"ontologyCompleted": "Completed",
"ontologyGenerating": "Generating",
"ontologyPending": "Pending",
"ontologyDesc": "LLM analyzes document content and simulation requirements, extracts reality seeds, and auto-generates a suitable ontology structure",
"analyzingDocs": "Analyzing documents...",
"graphRagBuild": "GraphRAG Build",
"graphRagDesc": "Based on the generated ontology, documents are auto-chunked and sent to Zep to build a knowledge graph, extracting entities and relations, forming temporal memory and community summaries",
"entityNodes": "Entity Nodes",
"relationEdges": "Relation Edges",
"schemaTypes": "Schema Types",
"buildComplete": "Build Complete",
"buildCompleteDesc": "Graph build is complete. Proceed to the next step for simulation environment setup.",
"inProgress": "In Progress",
"creating": "Creating...",
"enterEnvSetup": "Enter Environment Setup",
"createSimulationFailed": "Failed to create simulation: {error}",
"createSimulationException": "Simulation creation error: {error}"
},
"step2": {
"simInstanceInit": "Simulation Instance Initialization",
"simInstanceDesc": "Create a new simulation instance and pull world parameter templates",
"asyncTaskDone": "Async task completed",
"generateAgentPersona": "Generate Agent Personas",
"generateAgentPersonaDesc": "Combine context to auto-extract entities and relations from the knowledge graph, initialize simulated individuals, and assign unique behaviors and memories based on reality seeds",
"currentAgentCount": "Current Agents",
"expectedAgentTotal": "Expected Total Agents",
"relatedTopicsCount": "Reality Seed Related Topics",
"generatedAgentPersonas": "Generated Agent Personas",
"unknownProfession": "Unknown profession",
"noBio": "No bio available",
"dualPlatformConfig": "Generate Dual-Platform Config",
"dualPlatformConfigDesc": "LLM intelligently sets world time flow, recommendation algorithms, each individual's active hours, posting frequency, event triggers, and more based on requirements and reality seeds",
"simulationDuration": "Simulation Duration",
"roundDuration": "Round Duration",
"totalRounds": "Total Rounds",
"activePerHour": "Active Per Hour",
"peakHours": "Peak Hours",
"workHours": "Work Hours",
"morningHours": "Morning Hours",
"offPeakHours": "Off-Peak Hours",
"agentConfig": "Agent Config",
"activeTimePeriod": "Active Hours",
"postsPerHour": "Posts/hr",
"commentsPerHour": "Comments/hr",
"responseDelay": "Response Delay",
"activityLevel": "Activity Level",
"sentimentBias": "Sentiment Bias",
"influenceWeight": "Influence",
"recommendAlgoConfig": "Recommendation Algorithm Config",
"platform1Name": "Platform 1: Plaza / Feed",
"platform2Name": "Platform 2: Topic / Community",
"recencyWeight": "Recency Weight",
"popularityWeight": "Popularity Weight",
"relevanceWeight": "Relevance Weight",
"viralThreshold": "Viral Threshold",
"echoChamberStrength": "Echo Chamber Strength",
"llmConfigReasoning": "LLM Config Reasoning",
"initialActivation": "Initial Activation Orchestration",
"initialActivationDesc": "Auto-generate initial activation events and hot topics based on narrative direction to guide the simulation world's initial state",
"orchestrating": "Orchestrating",
"narrativeDirection": "Narrative Direction",
"initialHotTopics": "Initial Hot Topics",
"initialActivationSeq": "Initial Activation Sequence ({count})",
"setupComplete": "Setup Complete",
"setupCompleteDesc": "Simulation environment is ready. You can now start the simulation.",
"roundsConfig": "Simulation Rounds Configuration",
"roundsConfigDesc": "MiroFish auto-plans to simulate {hours} real-world hours, each round representing {minutesPerRound} minutes of elapsed time",
"customToggle": "Custom",
"roundsUnit": "rounds",
"estimatedDuration": "For 100 Agents: est. ~{minutes} minutes",
"estimatedDurationFull": "For 100 Agents: est. {minutes} minutes",
"recommendedRounds": "{rounds} (recommended)",
"customTip": "For first-time runs, we strongly recommend switching to 'Custom Mode' to reduce rounds for a quick preview and lower error risk",
"backToGraphBuild": "Back to Graph Build",
"startDualWorldSim": "Start Dual-World Parallel Simulation",
"profileModalAge": "Apparent Age",
"profileModalGender": "Apparent Gender",
"profileModalCountry": "Country/Region",
"profileModalMbti": "Apparent MBTI",
"profileModalBio": "Persona Bio",
"profileModalTopics": "Reality Seed Related Topics",
"profileModalPersona": "Detailed Persona Background",
"personaDimExperience": "Full Event Experience",
"personaDimExperienceDesc": "Complete behavioral trajectory in this event",
"personaDimBehavior": "Behavioral Profile",
"personaDimBehaviorDesc": "Experience summary and behavioral preferences",
"personaDimMemory": "Unique Memory Imprint",
"personaDimMemoryDesc": "Memories formed from reality seeds",
"personaDimSocial": "Social Network",
"personaDimSocialDesc": "Individual connections and interaction graph",
"genderMale": "Male",
"genderFemale": "Female",
"genderOther": "Other",
"yearsOld": "years old",
"initializing": "Initializing",
"generating": "Generating"
},
"step3": {
"startGenerateReport": "Generate Report",
"generatingReport": "Starting...",
"waitingForActions": "Waiting for agent actions...",
"errorMissingSimId": "Error: missing simulationId",
"startingDualSim": "Starting dual-platform parallel simulation...",
"graphMemoryUpdateEnabled": "Dynamic graph memory update enabled",
"setMaxRounds": "Max simulation rounds set to: {rounds}",
"oldSimCleared": "Old simulation logs cleared, restarting simulation",
"engineStarted": "Simulation engine started successfully",
"startFailed": "Start failed: {error}",
"startException": "Start error: {error}",
"stoppingSim": "Stopping simulation...",
"simStopped": "Simulation stopped",
"stopFailed": "Stop failed: {error}",
"stopException": "Stop error: {error}",
"allPlatformsCompleted": "All platform simulations have ended",
"simCompleted": "Simulation completed",
"graphRealtimeRefresh": "Graph real-time refresh enabled (30s)",
"graphRefreshStopped": "Graph real-time refresh stopped",
"preparingGoBack": "Preparing to return to Step 2, closing simulation...",
"closingSimEnv": "Closing simulation environment...",
"simEnvClosed": "Simulation environment closed",
"closeFailed": "Failed to close simulation environment, attempting force stop...",
"stoppingProcess": "Stopping simulation process...",
"checkStatusFailed": "Failed to check simulation status: {error}",
"forceStopSuccess": "Simulation force stopped",
"forceStopFailed": "Force stop failed: {error}",
"startGenerateReportBtn": "Generate Report",
"generatingReportBtn": "Starting..."
},
"step4": {
"generatingSection": "Generating {title}...",
"goToInteraction": "Enter Deep Interaction",
"waitingForReportAgent": "Waiting for Report Agent...",
"collapse": "Collapse ▲",
"expandAll": "Show all {count} ▼",
"expandAllEntities": "Show all {count} ▼",
"scenarioLabel": "Scenario: ",
"tabKeyFacts": "Key Facts ({count})",
"tabCoreEntities": "Core Entities ({count})",
"tabRelationChains": "Relation Chains ({count})",
"tabSubQueries": "Sub-queries ({count})",
"panelKeyFacts": "Latest key facts from temporal memory",
"totalCount": "{count} total",
"totalEntityCount": "{count} total",
"panelCoreEntities": "Core Entities",
"factCount": "{count} facts",
"panelRelationChains": "Relation Chains",
"panelSubQueries": "Drift query analysis sub-questions",
"emptyKeyFacts": "No key facts available",
"emptyCoreEntities": "No core entities available",
"emptyRelationChains": "No relation chains available",
"tabActiveFacts": "Active Facts ({count})",
"tabHistoricalFacts": "Historical Facts ({count})",
"tabEntities": "Entities ({count})",
"panelActiveFacts": "Active Facts",
"emptyActiveFacts": "No active facts available",
"panelHistoricalFacts": "Historical Facts",
"emptyHistoricalFacts": "No historical facts available",
"panelEntities": "Entities",
"emptyEntities": "No entities available",
"searchLabel": "Search: ",
"tabFacts": "Facts ({count})",
"tabEdges": "Edges ({count})",
"tabNodes": "Nodes ({count})",
"panelSearchResults": "Search Results",
"emptySearchResults": "No results found",
"panelRelatedEdges": "Related Edges",
"panelRelatedNodes": "Related Nodes",
"world1": "World 1",
"world2": "World 2"
},
"step5": {
"interactiveTools": "Interactive Tools",
"agentsAvailable": "{count} agents available",
"chatWithReportAgent": "Chat with Report Agent",
"chatWithAgent": "Chat with any individual in the world",
"selectChatTarget": "Select chat target",
"sendSurvey": "Send survey to the world",
"reportAgentChat": "Report Agent - Chat",
"reportAgentDesc": "A conversational version of the report generation agent with access to 4 professional tools and MiroFish's complete memory",
"toolInsightForge": "InsightForge Deep Attribution",
"toolInsightForgeDesc": "Aligns real-world seed data with simulation state, combining Global/Local Memory for cross-temporal deep attribution analysis",
"toolPanoramaSearch": "PanoramaSearch Full Tracking",
"toolPanoramaSearchDesc": "Graph-based BFS algorithm that reconstructs event propagation paths, capturing the full topology of information flow",
"toolQuickSearch": "QuickSearch Fast Retrieval",
"toolQuickSearchDesc": "GraphRAG-based instant query interface with optimized indexing for fast extraction of node attributes and discrete facts",
"toolInterviewSubAgent": "InterviewSubAgent Virtual Interview",
"toolInterviewSubAgentDesc": "Autonomous interviews that conduct parallel multi-round dialogues with simulated individuals, collecting unstructured opinion data and psychological states",
"profileBio": "Bio",
"chatEmptyReportAgent": "Chat with Report Agent to explore report content in depth",
"chatEmptyAgent": "Chat with simulated individuals to understand their perspectives",
"chatInputPlaceholder": "Type your question...",
"selectSurveyTarget": "Select survey targets",
"selectedCount": "Selected {selected} / {total}",
"surveyQuestions": "Survey Questions",
"surveyInputPlaceholder": "Enter the question you want to ask all selected targets...",
"submitSurvey": "Send Survey",
"surveyResults": "Survey Results",
"surveyResultsCount": "{count} responses",
"selectAll": "Select All",
"clearSelection": "Clear",
"errorOccurred": "Sorry, an error occurred: {error}",
"noResponse": "No response",
"requestFailed": "Request failed",
"selectAgentFirst": "Please select a simulated individual first"
},
"graph": {
"panelTitle": "Graph Relationship Visualization",
"refreshGraph": "Refresh Graph",
"graphMemoryRealtime": "GraphRAG short/long-term memory updating in real-time",
"realtimeUpdating": "Updating in real-time...",
"pendingContentHint": "Some content is still processing. Consider refreshing the graph manually later.",
"nodeDetails": "Node Details",
"relationship": "Relationship",
"graphDataLoading": "Loading graph data...",
"waitingOntology": "Waiting for ontology generation...",
"toggleMaximize": "Maximize/Restore",
"closeHint": "Close hint"
},
"history": {
"title": "Simulation History",
"graphBuild": "Graph Build",
"envSetup": "Env Setup",
"analysisReport": "Analysis Report",
"moreFiles": "+{count} files",
"noFiles": "No files",
"loadingText": "Loading...",
"simRequirement": "Simulation Requirement",
"relatedFiles": "Related Files",
"noRelatedFiles": "No related files",
"replayTitle": "Simulation Replay",
"step1Button": "Graph Build",
"step2Button": "Env Setup",
"step4Button": "Analysis Report",
"replayHint": "Step 3 'Run Simulation' and Step 5 'Deep Interaction' must be started during runtime and do not support history replay",
"notStarted": "Not started",
"roundsProgress": "{current}/{total} rounds",
"untitledSimulation": "Untitled simulation",
"unknownFile": "Unknown file"
},
"api": {
"projectNotFound": "Project not found: {id}",
"projectDeleteFailed": "Project not found or deletion failed: {id}",
"projectDeleted": "Project deleted: {id}",
"projectReset": "Project reset: {id}",
"requireSimulationRequirement": "Please provide a simulation requirement (simulation_requirement)",
"requireFileUpload": "Please upload at least one document file",
"noDocProcessed": "No documents were processed successfully. Please check file formats.",
"requireProjectId": "Please provide project_id",
"configError": "Configuration error: {details}",
"zepApiKeyMissing": "ZEP_API_KEY not configured",
"ontologyNotGenerated": "Ontology not yet generated. Please call /ontology/generate first.",
"graphBuilding": "Graph build in progress. Do not resubmit. To force rebuild, add force: true.",
"textNotFound": "Extracted text content not found",
"ontologyNotFound": "Ontology definition not found",
"graphBuildStarted": "Graph build task started. Query progress via /task/{taskId}.",
"graphBuildComplete": "Graph build complete",
"buildFailed": "Build failed: {error}",
"taskNotFound": "Task not found: {id}",
"graphDeleted": "Graph deleted: {id}",
"entityNotFound": "Entity not found: {id}",
"graphNotBuilt": "Graph not yet built. Please call /api/graph/build first.",
"requireSimulationId": "Please provide simulation_id",
"simulationNotFound": "Simulation not found: {id}",
"projectMissingRequirement": "Project missing simulation requirement (simulation_requirement)",
"prepareStarted": "Preparation task started. Query progress via /api/simulation/prepare/status.",
"alreadyPrepared": "Preparation already complete. No need to regenerate.",
"notStartedPrepare": "Preparation not started. Please call /api/simulation/prepare.",
"taskCompletedPrepared": "Task completed (preparation already exists)",
"requireTaskOrSimId": "Please provide task_id or simulation_id",
"configNotFound": "Simulation config not found. Please call /prepare first.",
"configFileNotFound": "Config file not found. Please call /prepare first.",
"unknownScript": "Unknown script: {name}. Available: {allowed}",
"scriptFileNotFound": "Script file not found: {name}",
"requireGraphId": "Please provide graph_id",
"noMatchingEntities": "No matching entities found",
"maxRoundsPositive": "max_rounds must be a positive integer",
"maxRoundsInvalid": "max_rounds must be a valid integer",
"invalidPlatform": "Invalid platform type: {platform}. Options: twitter/reddit/parallel",
"simRunningForceHint": "Simulation is running. Stop it first via /stop, or use force=true to restart.",
"simNotReady": "Simulation not ready. Current status: {status}. Please call /prepare first.",
"graphIdRequiredForMemory": "Graph memory update requires a valid graph_id. Ensure the graph is built.",
"dbNotExist": "Database does not exist. The simulation may not have run yet.",
"requireMessage": "Please provide a message",
"missingGraphId": "Missing graph ID",
"missingGraphIdEnsure": "Missing graph ID. Please ensure the graph has been built.",
"missingSimRequirement": "Missing simulation requirement description",
"reportAlreadyExists": "Report already exists",
"reportGenerateStarted": "Report generation task started. Query progress via /api/report/generate/status.",
"reportGenerated": "Report generated",
"reportNotFound": "Report not found: {id}",
"noReportForSim": "No report found for this simulation: {id}",
"reportDeleted": "Report deleted: {id}",
"reportGenerateFailed": "Report generation failed",
"sectionNotFound": "Section not found: section_{index}.md",
"reportProgressNotAvail": "Report not found or progress unavailable: {id}",
"requireAgentId": "Please provide agent_id",
"requirePrompt": "Please provide a prompt (interview question)",
"invalidInterviewPlatform": "Platform must be either 'twitter' or 'reddit'",
"envNotRunning": "Simulation environment not running or closed. Ensure simulation is complete and in command-wait mode.",
"interviewTimeout": "Interview response timed out: {error}",
"requireInterviews": "Please provide interviews (interview list)",
"interviewListMissingAgentId": "Interview list item {index} missing agent_id",
"interviewListMissingPrompt": "Interview list item {index} missing prompt",
"interviewListInvalidPlatform": "Interview list item {index} platform must be 'twitter' or 'reddit'",
"batchInterviewTimeout": "Batch interview response timed out: {error}",
"globalInterviewTimeout": "Global interview response timed out: {error}",
"envRunning": "Environment is running and ready for Interview commands",
"envNotRunningShort": "Environment not running or closed",
"requireGraphIdAndQuery": "Please provide graph_id and query",
"initReportAgent": "Initializing Report Agent..."
},
"progress": {
"initGraphService": "Initializing graph build service...",
"textChunking": "Chunking text...",
"creatingZepGraph": "Creating Zep graph...",
"settingOntology": "Setting ontology definition...",
"addingChunks": "Adding {count} text chunks...",
"waitingZepProcess": "Waiting for Zep to process data...",
"fetchingGraphData": "Fetching graph data...",
"graphBuildComplete": "Graph build complete",
"buildFailed": "Build failed: {error}",
"startBuildingGraph": "Starting graph build...",
"graphCreated": "Graph created: {graphId}",
"ontologySet": "Ontology set",
"textSplit": "Text split into {count} chunks",
"fetchingGraphInfo": "Fetching graph info...",
"sendingBatch": "Sending batch {current}/{total} ({chunks} chunks)...",
"batchFailed": "Batch {batch} failed: {error}",
"noEpisodesWait": "No episodes to wait for",
"waitingEpisodes": "Waiting for {count} text chunks to process...",
"episodesTimeout": "Some chunks timed out, {completed}/{total} completed",
"zepProcessing": "Zep processing... {completed}/{total} done, {pending} pending ({elapsed}s)",
"processingComplete": "Processing complete: {completed}/{total}",
"taskComplete": "Task complete",
"taskFailed": "Task failed",
"startPreparingEnv": "Preparing simulation environment...",
"connectingZepGraph": "Connecting to Zep graph...",
"readingNodeData": "Reading node data...",
"readingComplete": "Done, {count} entities found",
"startGenerating": "Starting generation...",
"analyzingRequirements": "Analyzing simulation requirements...",
"generatingOutline": "Generating report outline...",
"parsingOutline": "Parsing outline structure...",
"outlinePlanComplete": "Outline planning complete",
"deepSearchAndWrite": "Deep search & writing ({current}/{max})",
"initReport": "Initializing report...",
"startPlanningOutline": "Planning report outline...",
"outlineDone": "Outline complete, {count} sections",
"generatingSection": "Generating section: {title} ({current}/{total})",
"sectionDone": "Section {title} complete",
"assemblingReport": "Assembling full report...",
"reportComplete": "Report generation complete",
"reportFailed": "Report generation failed: {error}",
"savingProfiles": "Saving profile files...",
"profilesComplete": "Done, {count} profiles generated",
"callingLLMConfig": "Calling LLM to generate config...",
"savingConfigFiles": "Saving config files...",
"configComplete": "Config generation complete",
"generatingTimeConfig": "Generating time config...",
"generatingEventConfig": "Generating event config and hot topics...",
"generatingAgentConfig": "Generating agent config ({start}-{end}/{total})...",
"generatingPlatformConfig": "Generating platform config...",
"zepSearchQuery": "All information, activities, events, relationships and background about {name}",
"timeConfigLabel": "Time Config",
"eventConfigLabel": "Event Config",
"agentConfigResult": "Agent Config: {count} generated",
"postAssignResult": "Post Assignment: {count} posts assigned",
"profileGenerated": "[Generated] {name} ({type})",
"readingGraphEntities": "Reading Graph Entities",
"generatingProfiles": "Generating Agent Profiles",
"generatingSimConfig": "Generating Simulation Config",
"preparingScripts": "Preparing Scripts"
},
"log": {
"preparingGoBack": "Preparing to return to Step 2, closing simulation...",
"closingSimEnv": "Closing simulation environment...",
"simEnvClosed": "✓ Simulation environment closed",
"closeSimEnvFailed": "Failed to close simulation environment, attempting force stop...",
"simForceStopSuccess": "✓ Simulation force stopped",
"forceStopFailed": "Force stop failed: {error}",
"stoppingSimProcess": "Stopping simulation process...",
"simStopped": "✓ Simulation stopped",
"stopSimFailed": "Failed to stop simulation: {error}",
"checkStatusFailed": "Failed to check simulation status: {error}",
"enterStep4": "Entering Step 4: Report Generation",
"loadingSimData": "Loading simulation data: {id}",
"timeConfig": "Time config: {minutes} minutes per round",
"timeConfigFetchFailed": "Failed to fetch time config, using default: {minutes} min/round",
"projectLoadSuccess": "Project loaded: {id}",
"loadSimDataFailed": "Failed to load simulation data: {error}",
"loadException": "Load error: {error}",
"graphDataLoadSuccess": "Graph data loaded successfully",
"graphLoadFailed": "Graph load failed: {error}",
"graphRealtimeRefreshStart": "Graph real-time refresh enabled (30s)",
"graphRealtimeRefreshStop": "Graph real-time refresh stopped",
"simRunViewInit": "SimulationRunView initialized",
"customRounds": "Custom simulation rounds: {rounds}",
"enterStep3": "Entering Step 3: Run Simulation",
"customRoundsConfig": "Custom simulation rounds: {rounds} rounds",
"useAutoRounds": "Using auto-configured simulation rounds",
"detectedSimEnvRunning": "Detected running simulation environment, closing...",
"closeSimEnvFailedWithError": "Failed to close simulation environment: {error}",
"closeSimEnvException": "Simulation environment close error: {error}",
"detectedSimRunning": "Detected simulation is running, stopping...",
"forceStopSimFailed": "Force stop simulation failed: {error}",
"forceStopSimException": "Force stop simulation error: {error}",
"simViewInit": "SimulationView initialized",
"errorMissingSimId": "Error: missing simulationId",
"simInstanceCreated": "Simulation instance created: {id}",
"preparingSimEnv": "Preparing simulation environment...",
"detectedExistingPrep": "Detected existing preparation, using it directly",
"prepareTaskStarted": "Preparation task started",
"prepareTaskId": " └─ Task ID: {taskId}",
"zepEntitiesFound": "Found {count} entities from Zep graph",
"entityTypes": " └─ Entity types: {types}",
"startPollingProgress": "Polling preparation progress...",
"prepareFailed": "Preparation failed: {error}",
"prepareException": "Preparation error: {error}",
"prepareComplete": "✓ Preparation complete",
"prepareFailedWithError": "✗ Preparation failed: {error}",
"startGeneratingConfig": "Generating dual-platform simulation config...",
"generatingAgentProfileConfig": "Generating agent persona config...",
"generatingLLMConfig": "Calling LLM to generate simulation config parameters...",
"configComplete": "✓ Simulation config generated",
"configSummaryAgents": " ├─ Agents: {count}",
"configSummaryHours": " ├─ Duration: {hours} hours",
"configSummaryPosts": " ├─ Initial posts: {count}",
"configSummaryTopics": " ├─ Hot topics: {count}",
"configSummaryPlatforms": " └─ Platforms: Twitter {twitter}, Reddit {reddit}",
"timeConfigDetail": "Time config: {minutes} min/round, {rounds} rounds total",
"narrativeDirection": "Narrative direction: {direction}",
"envSetupComplete": "✓ Environment setup complete, ready to simulate",
"startSimCustomRounds": "Starting simulation, custom rounds: {rounds}",
"startSimAutoRounds": "Starting simulation, auto-configured rounds: {rounds}",
"startGeneratingAgentProfiles": "Generating agent personas...",
"agentProfile": "→ Agent persona {current}/{total}: {name} ({profession})",
"allProfilesComplete": "✓ All {count} agent personas generated",
"loadingExistingConfig": "Loading existing config data...",
"loadedAgentProfiles": "Loaded {count} agent personas",
"configLoadSuccess": "✓ Simulation config loaded",
"configSummaryPostsAlt": " └─ Initial posts: {count}",
"configGenerating": "Config generating, polling...",
"loadConfigFailed": "Failed to load config: {error}",
"step2Init": "Step 2 environment setup initialized",
"step3Init": "Step 3 simulation run initialized",
"startingDualSim": "Starting dual-platform parallel simulation...",
"setMaxRounds": "Max simulation rounds set to: {rounds}",
"graphMemoryUpdateEnabled": "Dynamic graph memory update enabled",
"oldSimCleared": "✓ Old simulation logs cleared, restarting simulation",
"engineStarted": "✓ Simulation engine started successfully",
"startFailed": "✗ Start failed: {error}",
"startException": "✗ Start error: {error}",
"stoppingSim": "Stopping simulation...",
"simStoppedSuccess": "✓ Simulation stopped",
"stopFailed": "Stop failed: {error}",
"stopException": "Stop error: {error}",
"allPlatformsCompleted": "✓ All platform simulations have ended",
"simCompleted": "✓ Simulation completed",
"reportRequestSent": "Report generation request sent, please wait...",
"startingReportGen": "Starting report generation...",
"reportGenTaskStarted": "✓ Report generation task started: {reportId}",
"reportGenFailed": "✗ Failed to start report generation: {error}",
"reportGenException": "✗ Report generation error: {error}",
"step5Init": "Step 5 deep interaction initialized",
"selectChatTarget": "Selected chat target: {name}",
"sendFailed": "Send failed: {error}",
"sendToReportAgent": "Sent to Report Agent: {message}...",
"reportAgentReplied": "Report Agent replied",
"sendToAgent": "Sent to {name}: {message}...",
"agentReplied": "{name} replied",
"sendSurvey": "Sending survey to {count} targets...",
"receivedReplies": "Received {count} replies",
"surveySendFailed": "Survey send failed: {error}",
"loadReportData": "Loading report data: {id}",
"loadReportFailed": "Failed to load report: {error}",
"reportDataLoaded": "Report data loaded",
"loadReportLogFailed": "Failed to load report logs: {error}",
"loadedProfiles": "Loaded {count} simulated individuals",
"loadProfilesFailed": "Failed to load simulated individuals: {error}",
"interactionViewInit": "InteractionView initialized",
"reportViewInit": "ReportView initialized",
"getReportInfoFailed": "Failed to get report info: {error}",
"enterStep": "Entering Step {step}: {name}",
"returnToStep": "Returning to Step {step}: {name}",
"customSimRounds": "Custom simulation rounds: {rounds} rounds"
},
"report": {
"taskStarted": "Report generation task started",
"planningStart": "Starting report outline planning",
"fetchSimContext": "Fetching simulation context",
"planningComplete": "Outline planning complete",
"sectionStart": "Starting section generation: {title}",
"reactThought": "ReACT round {iteration} thinking",
"toolCall": "Calling tool: {toolName}",
"toolResult": "Tool {toolName} returned result",
"llmResponse": "LLM response (tool calls: {hasToolCalls}, final answer: {hasFinalAnswer})",
"sectionContentDone": "Section {title} content generation complete",
"sectionComplete": "Section {title} generation complete",
"reportComplete": "Report generation complete",
"errorOccurred": "Error occurred: {error}",
"agentInitDone": "ReportAgent initialized: graph_id={graphId}, simulation_id={simulationId}",
"executingTool": "Executing tool: {toolName}, params: {params}",
"toolExecFailed": "Tool execution failed: {toolName}, error: {error}",
"startPlanningOutline": "Starting report outline planning...",
"outlinePlanDone": "Outline planning complete: {count} sections",
"outlinePlanFailed": "Outline planning failed: {error}",
"reactGenerateSection": "ReACT generating section: {title}",
"sectionIterNone": "Section {title} iteration {iteration}: LLM returned None",
"sectionConflict": "Section {title} round {iteration}: LLM output both tool call and Final Answer (conflict #{conflictCount})",
"sectionConflictDowngrade": "Section {title}: {conflictCount} consecutive conflicts, downgrading to truncate and execute first tool call",
"sectionGenDone": "Section {title} generation complete (tool calls: {count})",
"multiToolOnlyFirst": "LLM attempted {total} tool calls, executing only the first: {toolName}",
"sectionNoPrefix": "Section {title} missing 'Final Answer:' prefix, adopting LLM output as final content (tool calls: {count})",
"sectionMaxIter": "Section {title} reached max iterations, forcing generation",
"sectionForceFailed": "Section {title} force-finish LLM returned None, using default error message",
"sectionGenFailedContent": "(This section failed to generate: LLM returned empty response, please retry later)",
"outlineSavedToFile": "Outline saved to file: {reportId}/outline.json",
"sectionSaved": "Section saved: {reportId}/section_{sectionNum}.md",
"reportGenDone": "Report generation complete: {reportId}",
"reportGenFailed": "Report generation failed: {error}",
"agentChat": "Report Agent chat: {message}...",
"fetchReportFailed": "Failed to fetch report content: {error}",
"outlineSaved": "Outline saved: {reportId}",
"sectionFileSaved": "Section saved: {reportId}/{fileSuffix}",
"fullReportAssembled": "Full report assembled: {reportId}",
"reportSaved": "Report saved: {reportId}",
"reportFolderDeleted": "Report folder deleted: {reportId}",
"redirectToQuickSearch": "search_graph redirected to quick_search",
"redirectToInsightForge": "get_simulation_context redirected to insight_forge"
},
"console": {
"zepToolsInitialized": "ZepToolsService initialized",
"zepRetryAttempt": "Zep {operation} attempt {attempt} failed: {error}, retrying in {delay}s...",
"zepAllRetriesFailed": "Zep {operation} failed after {retries} attempts: {error}",
"graphSearch": "Graph search: graph_id={graphId}, query={query}...",
"graphSearchOp": "Graph search (graph={graphId})",
"searchComplete": "Search complete: found {count} relevant facts",
"zepSearchApiFallback": "Zep Search API failed, falling back to local search: {error}",
"usingLocalSearch": "Using local search: query={query}...",
"localSearchComplete": "Local search complete: found {count} relevant facts",
"localSearchFailed": "Local search failed: {error}",
"fetchingAllNodes": "Fetching all nodes for graph {graphId}...",
"fetchedNodes": "Fetched {count} nodes",
"fetchingAllEdges": "Fetching all edges for graph {graphId}...",
"fetchedEdges": "Fetched {count} edges",
"fetchingNodeDetail": "Fetching node detail: {uuid}...",
"fetchNodeDetailOp": "Fetch node detail (uuid={uuid}...)",
"fetchNodeDetailFailed": "Failed to fetch node detail: {error}",
"fetchingNodeEdges": "Fetching edges for node {uuid}...",
"foundNodeEdges": "Found {count} edges related to node",
"fetchNodeEdgesFailed": "Failed to fetch node edges: {error}",
"fetchingEntitiesByType": "Fetching entities of type {type}...",
"foundEntitiesByType": "Found {count} entities of type {type}",
"fetchingEntitySummary": "Fetching relationship summary for entity {name}...",
"fetchingGraphStats": "Fetching statistics for graph {graphId}...",
"fetchingSimContext": "Fetching simulation context: {requirement}...",
"insightForgeStart": "InsightForge deep insight retrieval: {query}...",
"generatedSubQueries": "Generated {count} sub-queries",
"insightForgeComplete": "InsightForge complete: {facts} facts, {entities} entities, {relationships} relationships",
"generateSubQueriesFailed": "Failed to generate sub-queries: {error}, using defaults",
"panoramaSearchStart": "PanoramaSearch broad search: {query}...",
"panoramaSearchComplete": "PanoramaSearch complete: {active} active, {historical} historical",
"quickSearchStart": "QuickSearch simple search: {query}...",
"quickSearchComplete": "QuickSearch complete: {count} results",
"interviewAgentsStart": "InterviewAgents deep interview (real API): {requirement}...",
"profilesNotFound": "Profiles not found for simulation {simId}",
"loadedProfiles": "Loaded {count} agent profiles",
"selectedAgentsForInterview": "Selected {count} agents for interview: {indices}",
"generatedInterviewQuestions": "Generated {count} interview questions",
"callingBatchInterviewApi": "Calling batch interview API (dual platform): {count} agents",
"interviewApiReturned": "Interview API returned: {count} results, success={success}",
"interviewApiReturnedFailure": "Interview API returned failure: {error}",
"interviewApiCallFailed": "Interview API call failed (env not running?): {error}",
"interviewApiCallException": "Interview API call exception: {error}",
"interviewAgentsComplete": "InterviewAgents complete: interviewed {count} agents (dual platform)",
"loadedRedditProfiles": "Loaded {count} profiles from reddit_profiles.json",
"readRedditProfilesFailed": "Failed to read reddit_profiles.json: {error}",
"loadedTwitterProfiles": "Loaded {count} profiles from twitter_profiles.csv",
"readTwitterProfilesFailed": "Failed to read twitter_profiles.csv: {error}",
"llmSelectAgentFailed": "LLM agent selection failed, using default selection: {error}",
"generateInterviewQuestionsFailed": "Failed to generate interview questions: {error}",
"generateInterviewSummaryFailed": "Failed to generate interview summary: {error}"
}
}

30
locales/languages.json Normal file
View File

@ -0,0 +1,30 @@
{
"zh": {
"label": "中文",
"llmInstruction": "请使用中文回答。"
},
"en": {
"label": "English",
"llmInstruction": "Please respond in English."
},
"es": {
"label": "Español",
"llmInstruction": "Por favor, responde en español."
},
"fr": {
"label": "Français",
"llmInstruction": "Veuillez répondre en français."
},
"pt": {
"label": "Português",
"llmInstruction": "Por favor, responda em português."
},
"ru": {
"label": "Русский",
"llmInstruction": "Пожалуйста, отвечайте на русском языке."
},
"de": {
"label": "Deutsch",
"llmInstruction": "Bitte antworten Sie auf Deutsch."
}
}

665
locales/zh.json Normal file
View File

@ -0,0 +1,665 @@
{
"common": {
"confirm": "确认",
"cancel": "取消",
"loading": "加载中...",
"error": "错误",
"success": "成功",
"completed": "已完成",
"processing": "生成中",
"pending": "等待",
"ready": "就绪",
"running": "运行中",
"failed": "失败",
"unknown": "未知",
"unknownError": "未知错误",
"none": "无",
"close": "关闭",
"back": "返回",
"next": "下一步",
"retry": "重试",
"noData": "暂无数据",
"hours": "小时",
"minutes": "分钟",
"rounds": "轮",
"items": "个",
"files": "个文件"
},
"meta": {
"title": "MiroFish - 预测万物",
"description": "MiroFish - 社交媒体舆论模拟系统"
},
"nav": {
"visitGithub": "访问我们的Github主页"
},
"home": {
"tagline": "简洁通用的群体智能引擎",
"version": "/ v0.1-预览版",
"heroTitle1": "上传任意报告",
"heroTitle2": "即刻推演未来",
"heroDesc": "即使只有一段文字,{brand} 也能基于其中的现实种子,全自动生成与之对应的至多{agentScale}构成的平行世界。通过上帝视角注入变量,在复杂的群体交互中寻找动态环境下的{optimalSolution}",
"heroDescBrand": "MiroFish",
"heroDescAgentScale": "百万级Agent",
"heroDescOptimalSolution": "\"局部最优解\"",
"slogan": "让未来在 Agent 群中预演,让决策在百战后胜出",
"systemStatus": "系统状态",
"systemReady": "准备就绪",
"systemReadyDesc": "预测引擎待命中,可上传多份非结构化数据以初始化模拟序列",
"metricLowCost": "低成本",
"metricLowCostDesc": "常规模拟平均5$/次",
"metricHighAvail": "高可用",
"metricHighAvailDesc": "最多百万级Agent模拟",
"workflowSequence": "工作流序列",
"step01Title": "图谱构建",
"step01Desc": "现实种子提取 & 个体与群体记忆注入 & GraphRAG构建",
"step02Title": "环境搭建",
"step02Desc": "实体关系抽取 & 人设生成 & 环境配置Agent注入仿真参数",
"step03Title": "开始模拟",
"step03Desc": "双平台并行模拟 & 自动解析预测需求 & 动态更新时序记忆",
"step04Title": "报告生成",
"step04Desc": "ReportAgent拥有丰富的工具集与模拟后环境进行深度交互",
"step05Title": "深度互动",
"step05Desc": "与模拟世界中的任意一位进行对话 & 与ReportAgent进行对话",
"realitySeed": "01 / 现实种子",
"supportedFormats": "支持格式: PDF, MD, TXT",
"dragToUpload": "拖拽文件上传",
"orBrowse": "或点击浏览文件系统",
"inputParams": "输入参数",
"simulationPrompt": ">_ 02 / 模拟提示词",
"promptPlaceholder": "// 用自然语言输入模拟或预测需求(例.武大若发布撤销肖某处分的公告,会引发什么舆情走向)",
"engineBadge": "引擎: MiroFish-V1.0",
"startEngine": "启动引擎",
"initializing": "初始化中..."
},
"main": {
"layoutGraph": "图谱",
"layoutSplit": "双栏",
"layoutWorkbench": "工作台",
"stepNames": ["图谱构建", "环境搭建", "开始模拟", "报告生成", "深度互动"]
},
"step1": {
"ontologyGeneration": "本体生成",
"ontologyCompleted": "已完成",
"ontologyGenerating": "生成中",
"ontologyPending": "等待",
"ontologyDesc": "LLM分析文档内容与模拟需求提取出现实种子自动生成合适的本体结构",
"analyzingDocs": "正在分析文档...",
"graphRagBuild": "GraphRAG构建",
"graphRagDesc": "基于生成的本体,将文档自动分块后调用 Zep 构建知识图谱,提取实体和关系,并形成时序记忆与社区摘要",
"entityNodes": "实体节点",
"relationEdges": "关系边",
"schemaTypes": "SCHEMA类型",
"buildComplete": "构建完成",
"buildCompleteDesc": "图谱构建已完成,请进入下一步进行模拟环境搭建",
"inProgress": "进行中",
"creating": "创建中...",
"enterEnvSetup": "进入环境搭建",
"createSimulationFailed": "创建模拟失败: {error}",
"createSimulationException": "创建模拟异常: {error}"
},
"step2": {
"simInstanceInit": "模拟实例初始化",
"simInstanceDesc": "新建simulation实例拉取模拟世界参数模版",
"asyncTaskDone": "异步任务已完成",
"generateAgentPersona": "生成 Agent 人设",
"generateAgentPersonaDesc": "结合上下文,自动调用工具从知识图谱梳理实体与关系,初始化模拟个体,并基于现实种子赋予他们独特的行为与记忆",
"currentAgentCount": "当前Agent数",
"expectedAgentTotal": "预期Agent总数",
"relatedTopicsCount": "现实种子当前关联话题数",
"generatedAgentPersonas": "已生成的 Agent 人设",
"unknownProfession": "未知职业",
"noBio": "暂无简介",
"dualPlatformConfig": "生成双平台模拟配置",
"dualPlatformConfigDesc": "LLM 根据模拟需求与现实种子,智能设置世界时间流速、推荐算法、每个个体的活跃时间段、发言频率、事件触发等参数",
"simulationDuration": "模拟时长",
"roundDuration": "每轮时长",
"totalRounds": "总轮次",
"activePerHour": "每小时活跃",
"peakHours": "高峰时段",
"workHours": "工作时段",
"morningHours": "早间时段",
"offPeakHours": "低谷时段",
"agentConfig": "Agent 配置",
"activeTimePeriod": "活跃时段",
"postsPerHour": "发帖/时",
"commentsPerHour": "评论/时",
"responseDelay": "响应延迟",
"activityLevel": "活跃度",
"sentimentBias": "情感倾向",
"influenceWeight": "影响力",
"recommendAlgoConfig": "推荐算法配置",
"platform1Name": "平台 1广场 / 信息流",
"platform2Name": "平台 2话题 / 社区",
"recencyWeight": "时效权重",
"popularityWeight": "热度权重",
"relevanceWeight": "相关性权重",
"viralThreshold": "病毒阈值",
"echoChamberStrength": "回音室强度",
"llmConfigReasoning": "LLM 配置推理",
"initialActivation": "初始激活编排",
"initialActivationDesc": "基于叙事方向,自动生成初始激活事件与热点话题,引导模拟世界的初始状态",
"orchestrating": "编排中",
"narrativeDirection": "叙事引导方向",
"initialHotTopics": "初始热点话题",
"initialActivationSeq": "初始激活序列 ({count})",
"setupComplete": "准备完成",
"setupCompleteDesc": "模拟环境已准备完成,可以开始运行模拟",
"roundsConfig": "模拟轮数设定",
"roundsConfigDesc": "MiroFish 自动规划推演现实 {hours} 小时,每轮代表现实 {minutesPerRound} 分钟时间流逝",
"customToggle": "自定义",
"roundsUnit": "轮",
"estimatedDuration": "若Agent规模为100预计耗时约 {minutes} 分钟",
"estimatedDurationFull": "若Agent规模为100预计耗时 {minutes} 分钟",
"recommendedRounds": "{rounds} (推荐)",
"customTip": "若首次运行,强烈建议切换至'自定义模式'减少模拟轮数,以便快速预览效果并降低报错风险",
"backToGraphBuild": "返回图谱构建",
"startDualWorldSim": "开始双世界并行模拟",
"profileModalAge": "事件外显年龄",
"profileModalGender": "事件外显性别",
"profileModalCountry": "国家/地区",
"profileModalMbti": "事件外显MBTI",
"profileModalBio": "人设简介",
"profileModalTopics": "现实种子关联话题",
"profileModalPersona": "详细人设背景",
"personaDimExperience": "事件全景经历",
"personaDimExperienceDesc": "在此事件中的完整行为轨迹",
"personaDimBehavior": "行为模式侧写",
"personaDimBehaviorDesc": "经验总结与行事风格偏好",
"personaDimMemory": "独特记忆印记",
"personaDimMemoryDesc": "基于现实种子形成的记忆",
"personaDimSocial": "社会关系网络",
"personaDimSocialDesc": "个体链接与交互图谱",
"genderMale": "男",
"genderFemale": "女",
"genderOther": "其他",
"yearsOld": "岁",
"initializing": "初始化",
"generating": "生成中"
},
"step3": {
"startGenerateReport": "开始生成结果报告",
"generatingReport": "启动中...",
"waitingForActions": "Waiting for agent actions...",
"errorMissingSimId": "错误:缺少 simulationId",
"startingDualSim": "正在启动双平台并行模拟...",
"graphMemoryUpdateEnabled": "已开启动态图谱更新模式",
"setMaxRounds": "设置最大模拟轮数: {rounds}",
"oldSimCleared": "已清理旧的模拟日志,重新开始模拟",
"engineStarted": "模拟引擎启动成功",
"startFailed": "启动失败: {error}",
"startException": "启动异常: {error}",
"stoppingSim": "正在停止模拟...",
"simStopped": "模拟已停止",
"stopFailed": "停止失败: {error}",
"stopException": "停止异常: {error}",
"allPlatformsCompleted": "检测到所有平台模拟已结束",
"simCompleted": "模拟已完成",
"graphRealtimeRefresh": "开启图谱实时刷新 (30s)",
"graphRefreshStopped": "停止图谱实时刷新",
"preparingGoBack": "准备返回 Step 2正在关闭模拟...",
"closingSimEnv": "正在关闭模拟环境...",
"simEnvClosed": "模拟环境已关闭",
"closeFailed": "关闭模拟环境失败,尝试强制停止...",
"stoppingProcess": "正在停止模拟进程...",
"checkStatusFailed": "检查模拟状态失败: {error}",
"forceStopSuccess": "模拟已强制停止",
"forceStopFailed": "强制停止失败: {error}",
"startGenerateReportBtn": "开始生成结果报告",
"generatingReportBtn": "启动中..."
},
"step4": {
"generatingSection": "正在生成{title}...",
"goToInteraction": "进入深度互动",
"waitingForReportAgent": "Waiting for Report Agent...",
"collapse": "收起 ▲",
"expandAll": "展开全部 {count} 条 ▼",
"expandAllEntities": "展开全部 {count} 个 ▼",
"scenarioLabel": "预测场景: ",
"tabKeyFacts": "当前关键记忆 ({count})",
"tabCoreEntities": "核心实体 ({count})",
"tabRelationChains": "关系链 ({count})",
"tabSubQueries": "子问题 ({count})",
"panelKeyFacts": "时序记忆中所关联的最新关键事实",
"totalCount": "共 {count} 条",
"totalEntityCount": "共 {count} 个",
"panelCoreEntities": "核心实体",
"factCount": "{count}条",
"panelRelationChains": "关系链",
"panelSubQueries": "漂移查询生成分析子问题",
"emptyKeyFacts": "暂无当前关键记忆",
"emptyCoreEntities": "暂无核心实体",
"emptyRelationChains": "暂无关系链",
"tabActiveFacts": "当前有效记忆 ({count})",
"tabHistoricalFacts": "历史记忆 ({count})",
"tabEntities": "涉及实体 ({count})",
"panelActiveFacts": "当前有效记忆",
"emptyActiveFacts": "暂无当前有效记忆",
"panelHistoricalFacts": "历史记忆",
"emptyHistoricalFacts": "暂无历史记忆",
"panelEntities": "涉及实体",
"emptyEntities": "暂无涉及实体",
"searchLabel": "搜索: ",
"tabFacts": "事实 ({count})",
"tabEdges": "关系 ({count})",
"tabNodes": "节点 ({count})",
"panelSearchResults": "搜索结果",
"emptySearchResults": "未找到相关结果",
"panelRelatedEdges": "相关关系",
"panelRelatedNodes": "相关节点",
"world1": "世界1",
"world2": "世界2"
},
"step5": {
"interactiveTools": "Interactive Tools",
"agentsAvailable": "{count} agents available",
"chatWithReportAgent": "与Report Agent对话",
"chatWithAgent": "与世界中任意个体对话",
"selectChatTarget": "选择对话对象",
"sendSurvey": "发送问卷调查到世界中",
"reportAgentChat": "Report Agent - Chat",
"reportAgentDesc": "报告生成智能体的快速对话版本,可调用 4 种专业工具拥有MiroFish的完整记忆",
"toolInsightForge": "InsightForge 深度归因",
"toolInsightForgeDesc": "对齐现实世界种子数据与模拟环境状态结合Global/Local Memory机制提供跨时空的深度归因分析",
"toolPanoramaSearch": "PanoramaSearch 全景追踪",
"toolPanoramaSearchDesc": "基于图结构的广度遍历算法,重构事件传播路径,捕获全量信息流动的拓扑结构",
"toolQuickSearch": "QuickSearch 快速检索",
"toolQuickSearchDesc": "基于 GraphRAG 的即时查询接口,优化索引效率,用于快速提取具体的节点属性与离散事实",
"toolInterviewSubAgent": "InterviewSubAgent 虚拟访谈",
"toolInterviewSubAgentDesc": "自主式访谈,能够并行与模拟世界中个体进行多轮对话,采集非结构化的观点数据与心理状态",
"profileBio": "简介",
"chatEmptyReportAgent": "与 Report Agent 对话,深入了解报告内容",
"chatEmptyAgent": "与模拟个体对话,了解他们的观点",
"chatInputPlaceholder": "输入您的问题...",
"selectSurveyTarget": "选择调查对象",
"selectedCount": "已选 {selected} / {total}",
"surveyQuestions": "问卷问题",
"surveyInputPlaceholder": "输入您想问所有被选中对象的问题...",
"submitSurvey": "发送问卷",
"surveyResults": "调查结果",
"surveyResultsCount": "{count} 条回复",
"selectAll": "全选",
"clearSelection": "清空",
"errorOccurred": "抱歉,发生了错误: {error}",
"noResponse": "无响应",
"requestFailed": "请求失败",
"selectAgentFirst": "请先选择一个模拟个体"
},
"graph": {
"panelTitle": "Graph Relationship Visualization",
"refreshGraph": "刷新图谱",
"graphMemoryRealtime": "GraphRAG长短期记忆实时更新中",
"realtimeUpdating": "实时更新中...",
"pendingContentHint": "还有少量内容处理中,建议稍后手动刷新图谱",
"nodeDetails": "Node Details",
"relationship": "Relationship",
"graphDataLoading": "图谱数据加载中...",
"waitingOntology": "等待本体生成...",
"toggleMaximize": "最大化/还原",
"closeHint": "关闭提示"
},
"history": {
"title": "推演记录",
"graphBuild": "图谱构建",
"envSetup": "环境搭建",
"analysisReport": "分析报告",
"moreFiles": "+{count} 个文件",
"noFiles": "暂无文件",
"loadingText": "加载中...",
"simRequirement": "模拟需求",
"relatedFiles": "关联文件",
"noRelatedFiles": "暂无关联文件",
"replayTitle": "推演回放",
"step1Button": "图谱构建",
"step2Button": "环境搭建",
"step4Button": "分析报告",
"replayHint": "Step3「开始模拟」与 Step5「深度互动」需在运行中启动不支持历史回放",
"notStarted": "未开始",
"roundsProgress": "{current}/{total} 轮",
"untitledSimulation": "未命名模拟",
"unknownFile": "未知文件"
},
"api": {
"projectNotFound": "项目不存在: {id}",
"projectDeleteFailed": "项目不存在或删除失败: {id}",
"projectDeleted": "项目已删除: {id}",
"projectReset": "项目已重置: {id}",
"requireSimulationRequirement": "请提供模拟需求描述 (simulation_requirement)",
"requireFileUpload": "请至少上传一个文档文件",
"noDocProcessed": "没有成功处理任何文档,请检查文件格式",
"requireProjectId": "请提供 project_id",
"configError": "配置错误: {details}",
"zepApiKeyMissing": "ZEP_API_KEY未配置",
"ontologyNotGenerated": "项目尚未生成本体,请先调用 /ontology/generate",
"graphBuilding": "图谱正在构建中,请勿重复提交。如需强制重建,请添加 force: true",
"textNotFound": "未找到提取的文本内容",
"ontologyNotFound": "未找到本体定义",
"graphBuildStarted": "图谱构建任务已启动,请通过 /task/{taskId} 查询进度",
"graphBuildComplete": "图谱构建完成",
"buildFailed": "构建失败: {error}",
"taskNotFound": "任务不存在: {id}",
"graphDeleted": "图谱已删除: {id}",
"entityNotFound": "实体不存在: {id}",
"graphNotBuilt": "项目尚未构建图谱,请先调用 /api/graph/build",
"requireSimulationId": "请提供 simulation_id",
"simulationNotFound": "模拟不存在: {id}",
"projectMissingRequirement": "项目缺少模拟需求描述 (simulation_requirement)",
"prepareStarted": "准备任务已启动,请通过 /api/simulation/prepare/status 查询进度",
"alreadyPrepared": "已有完成的准备工作,无需重复生成",
"notStartedPrepare": "尚未开始准备,请调用 /api/simulation/prepare 开始",
"taskCompletedPrepared": "任务已完成(准备工作已存在)",
"requireTaskOrSimId": "请提供 task_id 或 simulation_id",
"configNotFound": "模拟配置不存在,请先调用 /prepare 接口",
"configFileNotFound": "配置文件不存在,请先调用 /prepare 接口",
"unknownScript": "未知脚本: {name},可选: {allowed}",
"scriptFileNotFound": "脚本文件不存在: {name}",
"requireGraphId": "请提供 graph_id",
"noMatchingEntities": "没有找到符合条件的实体",
"maxRoundsPositive": "max_rounds 必须是正整数",
"maxRoundsInvalid": "max_rounds 必须是有效的整数",
"invalidPlatform": "无效的平台类型: {platform},可选: twitter/reddit/parallel",
"simRunningForceHint": "模拟正在运行中,请先调用 /stop 接口停止,或使用 force=true 强制重新开始",
"simNotReady": "模拟未准备好,当前状态: {status},请先调用 /prepare 接口",
"graphIdRequiredForMemory": "启用图谱记忆更新需要有效的 graph_id请确保项目已构建图谱",
"dbNotExist": "数据库不存在,模拟可能尚未运行",
"requireMessage": "请提供 message",
"missingGraphId": "缺少图谱ID",
"missingGraphIdEnsure": "缺少图谱ID请确保已构建图谱",
"missingSimRequirement": "缺少模拟需求描述",
"reportAlreadyExists": "报告已存在",
"reportGenerateStarted": "报告生成任务已启动,请通过 /api/report/generate/status 查询进度",
"reportGenerated": "报告已生成",
"reportNotFound": "报告不存在: {id}",
"noReportForSim": "该模拟暂无报告: {id}",
"reportDeleted": "报告已删除: {id}",
"reportGenerateFailed": "报告生成失败",
"sectionNotFound": "章节不存在: section_{index}.md",
"reportProgressNotAvail": "报告不存在或进度信息不可用: {id}",
"requireAgentId": "请提供 agent_id",
"requirePrompt": "请提供 prompt采访问题",
"invalidInterviewPlatform": "platform 参数只能是 'twitter' 或 'reddit'",
"envNotRunning": "模拟环境未运行或已关闭。请确保模拟已完成并进入等待命令模式。",
"interviewTimeout": "等待Interview响应超时: {error}",
"requireInterviews": "请提供 interviews采访列表",
"interviewListMissingAgentId": "采访列表第{index}项缺少 agent_id",
"interviewListMissingPrompt": "采访列表第{index}项缺少 prompt",
"interviewListInvalidPlatform": "采访列表第{index}项的platform只能是 'twitter' 或 'reddit'",
"batchInterviewTimeout": "等待批量Interview响应超时: {error}",
"globalInterviewTimeout": "等待全局Interview响应超时: {error}",
"envRunning": "环境正在运行可以接收Interview命令",
"envNotRunningShort": "环境未运行或已关闭",
"requireGraphIdAndQuery": "请提供 graph_id 和 query",
"initReportAgent": "初始化Report Agent..."
},
"progress": {
"initGraphService": "初始化图谱构建服务...",
"textChunking": "文本分块中...",
"creatingZepGraph": "创建Zep图谱...",
"settingOntology": "设置本体定义...",
"addingChunks": "开始添加 {count} 个文本块...",
"waitingZepProcess": "等待Zep处理数据...",
"fetchingGraphData": "获取图谱数据...",
"graphBuildComplete": "图谱构建完成",
"buildFailed": "构建失败: {error}",
"startBuildingGraph": "开始构建图谱...",
"graphCreated": "图谱已创建: {graphId}",
"ontologySet": "本体已设置",
"textSplit": "文本已分割为 {count} 个块",
"fetchingGraphInfo": "获取图谱信息...",
"sendingBatch": "发送第 {current}/{total} 批数据 ({chunks} 块)...",
"batchFailed": "批次 {batch} 发送失败: {error}",
"noEpisodesWait": "无需等待(没有 episode",
"waitingEpisodes": "开始等待 {count} 个文本块处理...",
"episodesTimeout": "部分文本块超时,已完成 {completed}/{total}",
"zepProcessing": "Zep处理中... {completed}/{total} 完成, {pending} 待处理 ({elapsed}秒)",
"processingComplete": "处理完成: {completed}/{total}",
"taskComplete": "任务完成",
"taskFailed": "任务失败",
"startPreparingEnv": "开始准备模拟环境...",
"connectingZepGraph": "正在连接Zep图谱...",
"readingNodeData": "正在读取节点数据...",
"readingComplete": "完成,共 {count} 个实体",
"startGenerating": "开始生成...",
"analyzingRequirements": "正在分析模拟需求...",
"generatingOutline": "正在生成报告大纲...",
"parsingOutline": "正在解析大纲结构...",
"outlinePlanComplete": "大纲规划完成",
"deepSearchAndWrite": "深度检索与撰写中 ({current}/{max})",
"initReport": "初始化报告...",
"startPlanningOutline": "开始规划报告大纲...",
"outlineDone": "大纲规划完成,共{count}个章节",
"generatingSection": "正在生成章节: {title} ({current}/{total})",
"sectionDone": "章节 {title} 已完成",
"assemblingReport": "正在组装完整报告...",
"reportComplete": "报告生成完成",
"reportFailed": "报告生成失败: {error}",
"savingProfiles": "保存Profile文件...",
"profilesComplete": "完成,共 {count} 个Profile",
"callingLLMConfig": "正在调用LLM生成配置...",
"savingConfigFiles": "正在保存配置文件...",
"configComplete": "配置生成完成",
"generatingTimeConfig": "生成时间配置...",
"generatingEventConfig": "生成事件配置和热点话题...",
"generatingAgentConfig": "生成Agent配置 ({start}-{end}/{total})...",
"generatingPlatformConfig": "生成平台配置...",
"zepSearchQuery": "关于{name}的所有信息、活动、事件、关系和背景",
"timeConfigLabel": "时间配置",
"eventConfigLabel": "事件配置",
"agentConfigResult": "Agent配置: 成功生成 {count} 个",
"postAssignResult": "初始帖子分配: {count} 个帖子已分配发布者",
"profileGenerated": "[已生成] {name} ({type})",
"readingGraphEntities": "读取图谱实体",
"generatingProfiles": "生成Agent人设",
"generatingSimConfig": "生成模拟配置",
"preparingScripts": "准备模拟脚本"
},
"log": {
"preparingGoBack": "准备返回 Step 2正在关闭模拟...",
"closingSimEnv": "正在关闭模拟环境...",
"simEnvClosed": "✓ 模拟环境已关闭",
"closeSimEnvFailed": "关闭模拟环境失败,尝试强制停止...",
"simForceStopSuccess": "✓ 模拟已强制停止",
"forceStopFailed": "强制停止失败: {error}",
"stoppingSimProcess": "正在停止模拟进程...",
"simStopped": "✓ 模拟已停止",
"stopSimFailed": "停止模拟失败: {error}",
"checkStatusFailed": "检查模拟状态失败: {error}",
"enterStep4": "进入 Step 4: 报告生成",
"loadingSimData": "加载模拟数据: {id}",
"timeConfig": "时间配置: 每轮 {minutes} 分钟",
"timeConfigFetchFailed": "获取时间配置失败,使用默认值: {minutes}分钟/轮",
"projectLoadSuccess": "项目加载成功: {id}",
"loadSimDataFailed": "加载模拟数据失败: {error}",
"loadException": "加载异常: {error}",
"graphDataLoadSuccess": "图谱数据加载成功",
"graphLoadFailed": "图谱加载失败: {error}",
"graphRealtimeRefreshStart": "开启图谱实时刷新 (30s)",
"graphRealtimeRefreshStop": "停止图谱实时刷新",
"simRunViewInit": "SimulationRunView 初始化",
"customRounds": "自定义模拟轮数: {rounds}",
"enterStep3": "进入 Step 3: 开始模拟",
"customRoundsConfig": "自定义模拟轮数: {rounds} 轮",
"useAutoRounds": "使用自动配置的模拟轮数",
"detectedSimEnvRunning": "检测到模拟环境正在运行,正在关闭...",
"closeSimEnvFailedWithError": "关闭模拟环境失败: {error}",
"closeSimEnvException": "关闭模拟环境异常: {error}",
"detectedSimRunning": "检测到模拟状态为运行中,正在停止...",
"forceStopSimFailed": "强制停止模拟失败: {error}",
"forceStopSimException": "强制停止模拟异常: {error}",
"simViewInit": "SimulationView 初始化",
"errorMissingSimId": "错误:缺少 simulationId",
"simInstanceCreated": "模拟实例已创建: {id}",
"preparingSimEnv": "正在准备模拟环境...",
"detectedExistingPrep": "检测到已有完成的准备工作,直接使用",
"prepareTaskStarted": "准备任务已启动",
"prepareTaskId": " └─ Task ID: {taskId}",
"zepEntitiesFound": "从Zep图谱读取到 {count} 个实体",
"entityTypes": " └─ 实体类型: {types}",
"startPollingProgress": "开始轮询准备进度...",
"prepareFailed": "准备失败: {error}",
"prepareException": "准备异常: {error}",
"prepareComplete": "✓ 准备工作已完成",
"prepareFailedWithError": "✗ 准备失败: {error}",
"startGeneratingConfig": "开始生成双平台模拟配置...",
"generatingAgentProfileConfig": "正在生成Agent人设配置...",
"generatingLLMConfig": "正在调用LLM生成模拟配置参数...",
"configComplete": "✓ 模拟配置生成完成",
"configSummaryAgents": " ├─ Agent数量: {count}个",
"configSummaryHours": " ├─ 模拟时长: {hours}小时",
"configSummaryPosts": " ├─ 初始帖子: {count}条",
"configSummaryTopics": " ├─ 热点话题: {count}个",
"configSummaryPlatforms": " └─ 平台配置: Twitter {twitter}, Reddit {reddit}",
"timeConfigDetail": "时间配置: 每轮{minutes}分钟, 共{rounds}轮",
"narrativeDirection": "叙事方向: {direction}",
"envSetupComplete": "✓ 环境搭建完成,可以开始模拟",
"startSimCustomRounds": "开始模拟,自定义轮数: {rounds} 轮",
"startSimAutoRounds": "开始模拟,使用自动配置轮数: {rounds} 轮",
"startGeneratingAgentProfiles": "开始生成Agent人设...",
"agentProfile": "→ Agent人设 {current}/{total}: {name} ({profession})",
"allProfilesComplete": "✓ 全部 {count} 个Agent人设生成完成",
"loadingExistingConfig": "正在加载已有配置数据...",
"loadedAgentProfiles": "已加载 {count} 个Agent人设",
"configLoadSuccess": "✓ 模拟配置加载成功",
"configSummaryPostsAlt": " └─ 初始帖子: {count}条",
"configGenerating": "配置生成中,开始轮询等待...",
"loadConfigFailed": "加载配置失败: {error}",
"step2Init": "Step2 环境搭建初始化",
"step3Init": "Step3 模拟运行初始化",
"startingDualSim": "正在启动双平台并行模拟...",
"setMaxRounds": "设置最大模拟轮数: {rounds}",
"graphMemoryUpdateEnabled": "已开启动态图谱更新模式",
"oldSimCleared": "✓ 已清理旧的模拟日志,重新开始模拟",
"engineStarted": "✓ 模拟引擎启动成功",
"startFailed": "✗ 启动失败: {error}",
"startException": "✗ 启动异常: {error}",
"stoppingSim": "正在停止模拟...",
"simStoppedSuccess": "✓ 模拟已停止",
"stopFailed": "停止失败: {error}",
"stopException": "停止异常: {error}",
"allPlatformsCompleted": "✓ 检测到所有平台模拟已结束",
"simCompleted": "✓ 模拟已完成",
"reportRequestSent": "报告生成请求已发送,请稍候...",
"startingReportGen": "正在启动报告生成...",
"reportGenTaskStarted": "✓ 报告生成任务已启动: {reportId}",
"reportGenFailed": "✗ 启动报告生成失败: {error}",
"reportGenException": "✗ 启动报告生成异常: {error}",
"step5Init": "Step5 深度互动初始化",
"selectChatTarget": "选择对话对象: {name}",
"sendFailed": "发送失败: {error}",
"sendToReportAgent": "向 Report Agent 发送: {message}...",
"reportAgentReplied": "Report Agent 已回复",
"sendToAgent": "向 {name} 发送: {message}...",
"agentReplied": "{name} 已回复",
"sendSurvey": "发送问卷给 {count} 个对象...",
"receivedReplies": "收到 {count} 条回复",
"surveySendFailed": "问卷发送失败: {error}",
"loadReportData": "加载报告数据: {id}",
"loadReportFailed": "加载报告失败: {error}",
"reportDataLoaded": "报告数据加载完成",
"loadReportLogFailed": "加载报告日志失败: {error}",
"loadedProfiles": "加载了 {count} 个模拟个体",
"loadProfilesFailed": "加载模拟个体失败: {error}",
"interactionViewInit": "InteractionView 初始化",
"reportViewInit": "ReportView 初始化",
"getReportInfoFailed": "获取报告信息失败: {error}",
"enterStep": "进入 Step {step}: {name}",
"returnToStep": "返回 Step {step}: {name}",
"customSimRounds": "自定义模拟轮数: {rounds} 轮"
},
"report": {
"taskStarted": "报告生成任务开始",
"planningStart": "开始规划报告大纲",
"fetchSimContext": "获取模拟上下文信息",
"planningComplete": "大纲规划完成",
"sectionStart": "开始生成章节: {title}",
"reactThought": "ReACT 第{iteration}轮思考",
"toolCall": "调用工具: {toolName}",
"toolResult": "工具 {toolName} 返回结果",
"llmResponse": "LLM 响应 (工具调用: {hasToolCalls}, 最终答案: {hasFinalAnswer})",
"sectionContentDone": "章节 {title} 内容生成完成",
"sectionComplete": "章节 {title} 生成完成",
"reportComplete": "报告生成完成",
"errorOccurred": "发生错误: {error}",
"agentInitDone": "ReportAgent 初始化完成: graph_id={graphId}, simulation_id={simulationId}",
"executingTool": "执行工具: {toolName}, 参数: {params}",
"toolExecFailed": "工具执行失败: {toolName}, 错误: {error}",
"startPlanningOutline": "开始规划报告大纲...",
"outlinePlanDone": "大纲规划完成: {count} 个章节",
"outlinePlanFailed": "大纲规划失败: {error}",
"reactGenerateSection": "ReACT生成章节: {title}",
"sectionIterNone": "章节 {title} 第 {iteration} 次迭代: LLM 返回 None",
"sectionConflict": "章节 {title} 第 {iteration} 轮: LLM 同时输出工具调用和 Final Answer第 {conflictCount} 次冲突)",
"sectionConflictDowngrade": "章节 {title}: 连续 {conflictCount} 次冲突,降级为截断执行第一个工具调用",
"sectionGenDone": "章节 {title} 生成完成(工具调用: {count}次)",
"multiToolOnlyFirst": "LLM 尝试调用 {total} 个工具,只执行第一个: {toolName}",
"sectionNoPrefix": "章节 {title} 未检测到 'Final Answer:' 前缀直接采纳LLM输出作为最终内容工具调用: {count}次)",
"sectionMaxIter": "章节 {title} 达到最大迭代次数,强制生成",
"sectionForceFailed": "章节 {title} 强制收尾时 LLM 返回 None使用默认错误提示",
"sectionGenFailedContent": "本章节生成失败LLM 返回空响应,请稍后重试)",
"outlineSavedToFile": "大纲已保存到文件: {reportId}/outline.json",
"sectionSaved": "章节已保存: {reportId}/section_{sectionNum}.md",
"reportGenDone": "报告生成完成: {reportId}",
"reportGenFailed": "报告生成失败: {error}",
"agentChat": "Report Agent对话: {message}...",
"fetchReportFailed": "获取报告内容失败: {error}",
"outlineSaved": "大纲已保存: {reportId}",
"sectionFileSaved": "章节已保存: {reportId}/{fileSuffix}",
"fullReportAssembled": "完整报告已组装: {reportId}",
"reportSaved": "报告已保存: {reportId}",
"reportFolderDeleted": "报告文件夹已删除: {reportId}",
"redirectToQuickSearch": "search_graph 已重定向到 quick_search",
"redirectToInsightForge": "get_simulation_context 已重定向到 insight_forge"
},
"console": {
"zepToolsInitialized": "ZepToolsService 初始化完成",
"zepRetryAttempt": "Zep {operation} 第 {attempt} 次尝试失败: {error}, {delay}秒后重试...",
"zepAllRetriesFailed": "Zep {operation} 在 {retries} 次尝试后仍失败: {error}",
"graphSearch": "图谱搜索: graph_id={graphId}, query={query}...",
"graphSearchOp": "图谱搜索(graph={graphId})",
"searchComplete": "搜索完成: 找到 {count} 条相关事实",
"zepSearchApiFallback": "Zep Search API失败降级为本地搜索: {error}",
"usingLocalSearch": "使用本地搜索: query={query}...",
"localSearchComplete": "本地搜索完成: 找到 {count} 条相关事实",
"localSearchFailed": "本地搜索失败: {error}",
"fetchingAllNodes": "获取图谱 {graphId} 的所有节点...",
"fetchedNodes": "获取到 {count} 个节点",
"fetchingAllEdges": "获取图谱 {graphId} 的所有边...",
"fetchedEdges": "获取到 {count} 条边",
"fetchingNodeDetail": "获取节点详情: {uuid}...",
"fetchNodeDetailOp": "获取节点详情(uuid={uuid}...)",
"fetchNodeDetailFailed": "获取节点详情失败: {error}",
"fetchingNodeEdges": "获取节点 {uuid}... 的相关边",
"foundNodeEdges": "找到 {count} 条与节点相关的边",
"fetchNodeEdgesFailed": "获取节点边失败: {error}",
"fetchingEntitiesByType": "获取类型为 {type} 的实体...",
"foundEntitiesByType": "找到 {count} 个 {type} 类型的实体",
"fetchingEntitySummary": "获取实体 {name} 的关系摘要...",
"fetchingGraphStats": "获取图谱 {graphId} 的统计信息...",
"fetchingSimContext": "获取模拟上下文: {requirement}...",
"insightForgeStart": "InsightForge 深度洞察检索: {query}...",
"generatedSubQueries": "生成 {count} 个子问题",
"insightForgeComplete": "InsightForge完成: {facts}条事实, {entities}个实体, {relationships}条关系",
"generateSubQueriesFailed": "生成子问题失败: {error},使用默认子问题",
"panoramaSearchStart": "PanoramaSearch 广度搜索: {query}...",
"panoramaSearchComplete": "PanoramaSearch完成: {active}条有效, {historical}条历史",
"quickSearchStart": "QuickSearch 简单搜索: {query}...",
"quickSearchComplete": "QuickSearch完成: {count}条结果",
"interviewAgentsStart": "InterviewAgents 深度采访真实API: {requirement}...",
"profilesNotFound": "未找到模拟 {simId} 的人设文件",
"loadedProfiles": "加载到 {count} 个Agent人设",
"selectedAgentsForInterview": "选择了 {count} 个Agent进行采访: {indices}",
"generatedInterviewQuestions": "生成了 {count} 个采访问题",
"callingBatchInterviewApi": "调用批量采访API双平台: {count} 个Agent",
"interviewApiReturned": "采访API返回: {count} 个结果, success={success}",
"interviewApiReturnedFailure": "采访API返回失败: {error}",
"interviewApiCallFailed": "采访API调用失败环境未运行: {error}",
"interviewApiCallException": "采访API调用异常: {error}",
"interviewAgentsComplete": "InterviewAgents完成: 采访了 {count} 个Agent双平台",
"loadedRedditProfiles": "从 reddit_profiles.json 加载了 {count} 个人设",
"readRedditProfilesFailed": "读取 reddit_profiles.json 失败: {error}",
"loadedTwitterProfiles": "从 twitter_profiles.csv 加载了 {count} 个人设",
"readTwitterProfilesFailed": "读取 twitter_profiles.csv 失败: {error}",
"llmSelectAgentFailed": "LLM选择Agent失败使用默认选择: {error}",
"generateInterviewQuestionsFailed": "生成采访问题失败: {error}",
"generateInterviewSummaryFailed": "生成采访摘要失败: {error}"
}
}

333
package-lock.json generated Normal file
View File

@ -0,0 +1,333 @@
{
"name": "mirofish",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mirofish",
"version": "0.1.0",
"license": "AGPL-3.0",
"devDependencies": {
"concurrently": "^9.1.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chalk/node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.1",
"wrap-ansi": "^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/concurrently": {
"version": "9.2.1",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
"integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==",
"dev": true,
"license": "MIT",
"dependencies": {
"chalk": "4.1.2",
"rxjs": "7.8.2",
"shell-quote": "1.8.3",
"supports-color": "8.1.1",
"tree-kill": "1.2.2",
"yargs": "17.7.2"
},
"bin": {
"conc": "dist/bin/concurrently.js",
"concurrently": "dist/bin/concurrently.js"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
}
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/shell-quote": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/supports-color": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/tree-kill": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
"dev": true,
"license": "MIT",
"bin": {
"tree-kill": "cli.js"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^8.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"require-directory": "^2.1.1",
"string-width": "^4.2.3",
"y18n": "^5.0.5",
"yargs-parser": "^21.1.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/yargs-parser": {
"version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=12"
}
}
}
}

21
package.json Normal file
View File

@ -0,0 +1,21 @@
{
"name": "mirofish",
"version": "0.1.0",
"description": "MiroFish - 简洁通用的群体智能引擎,预测万物",
"scripts": {
"setup": "npm install && cd frontend && npm install",
"setup:backend": "cd backend && uv sync",
"setup:all": "npm run setup && npm run setup:backend",
"dev": "concurrently --kill-others -n \"backend,frontend\" -c \"green,cyan\" \"npm run backend\" \"npm run frontend\"",
"backend": "cd backend && uv run python run.py",
"frontend": "cd frontend && npm run dev",
"build": "cd frontend && npm run build"
},
"devDependencies": {
"concurrently": "^9.1.2"
},
"engines": {
"node": ">=18.0.0"
},
"license": "AGPL-3.0"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

BIN
static/image/QQ群.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 454 KiB