fix: strip hallucinated <tool_result> blocks in ReACT loop
Signed-off-by: god032396-del <god032396@gmail.com>
|
|
@ -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
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
# 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
|
||||
|
|
@ -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 }}
|
||||
|
|
@ -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/
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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/>.
|
||||
|
|
@ -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>
|
||||
|
||||
[](https://github.com/666ghj/MiroFish/stargazers)
|
||||
[](https://github.com/666ghj/MiroFish/watchers)
|
||||
[](https://github.com/666ghj/MiroFish/network)
|
||||
[](https://hub.docker.com/)
|
||||
[](https://deepwiki.com/666ghj/MiroFish)
|
||||
|
||||
[](http://discord.gg/ePf5aPaHnA)
|
||||
[](https://x.com/mirofish_ai)
|
||||
[](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>
|
||||
|
||||
|
||||
|
||||
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>
|
||||
|
|
@ -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>
|
||||
|
||||
[](https://github.com/666ghj/MiroFish/stargazers)
|
||||
[](https://github.com/666ghj/MiroFish/watchers)
|
||||
[](https://github.com/666ghj/MiroFish/network)
|
||||
[](https://hub.docker.com/)
|
||||
[](https://deepwiki.com/666ghj/MiroFish)
|
||||
|
||||
[](http://discord.gg/ePf5aPaHnA)
|
||||
[](https://x.com/mirofish_ai)
|
||||
[](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
|
||||
```
|
||||
|
||||
#### 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>
|
||||
|
||||
|
||||
|
||||
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>
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
"""
|
||||
数据模型模块
|
||||
"""
|
||||
|
||||
from .task import TaskManager, TaskStatus
|
||||
from .project import Project, ProjectStatus, ProjectManager
|
||||
|
||||
__all__ = ['TaskManager', 'TaskStatus', 'Project', 'ProjectStatus', 'ProjectManager']
|
||||
|
||||
|
|
@ -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))
|
||||
]
|
||||
|
||||
|
|
@ -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]
|
||||
|
||||
|
|
@ -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',
|
||||
]
|
||||
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
@ -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. 属性名不能使用 name、uuid、group_id 等保留字,用 full_name、org_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 转为 PascalCase(Zep 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_CASE(Zep 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)
|
||||
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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:
|
||||
IPCResponse,result字段包含采访结果
|
||||
"""
|
||||
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:
|
||||
IPCResponse,result字段包含所有采访结果
|
||||
"""
|
||||
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
|
||||
))
|
||||
|
|
@ -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}"
|
||||
)
|
||||
}
|
||||
|
|
@ -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()),
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
@ -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。
|
||||
|
||||
所有有意义的行为都会被更新到Zep,action_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()
|
||||
}
|
||||
|
|
@ -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']
|
||||
|
||||
|
|
@ -0,0 +1,203 @@
|
|||
"""
|
||||
文件解析工具
|
||||
支持PDF、Markdown、TXT文件的文本提取
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
|
|
@ -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}")
|
||||
|
||||
|
|
@ -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', '请使用中文回答。')
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -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可用动作(不包含INTERVIEW,INTERVIEW只能通过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("模拟进程已退出")
|
||||
|
||||
|
|
@ -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可用动作(不包含INTERVIEW,INTERVIEW只能通过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("模拟进程已退出")
|
||||
|
|
@ -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()
|
||||
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -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?
|
||||
|
|
@ -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>
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 30 KiB |
|
|
@ -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>
|
||||
|
|
@ -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'
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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 } })
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 587 KiB |
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
@ -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')
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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'
|
||||
})
|
||||
}
|
||||
|
||||
// 开始模拟 - 立即跳转,API调用在Process页面进行
|
||||
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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
@ -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}"
|
||||
}
|
||||
}
|
||||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
|
|
@ -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}"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
After Width: | Height: | Size: 3.5 MiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 246 KiB |
|
After Width: | Height: | Size: 255 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 450 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 199 KiB |
|
After Width: | Height: | Size: 454 KiB |