[
  {
    "path": ".dockerignore",
    "content": ".git\n.github\n.gitignore\n.cursor\n.DS_Store\n.env\n\nnode_modules\nfrontend/node_modules\nbackend/.venv\n.venv\n.python-version\n\n__pycache__\n*.pyc\n.pytest_cache\n.mypy_cache\n.ruff_cache\n\nfrontend/dist\nfrontend/.vite\n\nbackend/uploads\n"
  },
  {
    "path": ".github/workflows/docker-image.yml",
    "content": "name: Build and push Docker image\n\non:\n  push:\n    tags: [\"*\"]\n  workflow_dispatch:\n\npermissions:\n  contents: read\n  packages: write\n\njobs:\n  build-and-push:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v3\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Log in to GHCR\n        uses: docker/login-action@v3\n        with:\n          registry: ghcr.io\n          username: ${{ github.repository_owner }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Extract metadata\n        id: meta\n        uses: docker/metadata-action@v5\n        with:\n          images: ghcr.io/${{ github.repository_owner }}/mirofish\n          tags: |\n            type=ref,event=tag\n            type=sha\n            type=raw,value=latest\n\n      - name: Build and push\n        uses: docker/build-push-action@v5\n        with:\n          context: .\n          file: ./Dockerfile\n          push: true\n          tags: ${{ steps.meta.outputs.tags }}\n          labels: ${{ steps.meta.outputs.labels }}\n"
  },
  {
    "path": ".gitignore",
    "content": "# OS\n.DS_Store\nThumbs.db\n\n# 环境变量（保护敏感信息）\n.env\n.env.local\n.env.*.local\n.env.development\n.env.test\n.env.production\n\n# Python\n__pycache__/\n*.py[cod]\n*$py.class\n*.so\n.Python\n.venv/\nvenv/\nENV/\n.eggs/\n*.egg-info/\ndist/\nbuild/\n\n# Node.js\nnode_modules/\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n\n# IDE\n.vscode/\n.idea/\n*.swp\n*.swo\n\n# 测试\n.pytest_cache/\n.coverage\nhtmlcov/\n\n# Cursor\n.cursor/\n.claude/\n\n# 文档与测试程序\nmydoc/\nmytest/\n\n# 日志文件\nbackend/logs/\n*.log\n\n# 上传文件\nbackend/uploads/\n\n# Docker 数据\ndata/"
  },
  {
    "path": "Dockerfile",
    "content": "FROM python:3.11\n\n# 安装 Node.js （满足 >=18）及必要工具\nRUN apt-get update \\\n  && apt-get install -y --no-install-recommends nodejs npm \\\n  && rm -rf /var/lib/apt/lists/*\n\n# 从 uv 官方镜像复制 uv\nCOPY --from=ghcr.io/astral-sh/uv:0.9.26 /uv /uvx /bin/\n\nWORKDIR /app\n\n# 先复制依赖描述文件以利用缓存\nCOPY package.json package-lock.json ./\nCOPY frontend/package.json frontend/package-lock.json ./frontend/\nCOPY backend/pyproject.toml backend/uv.lock ./backend/\n\n# 安装依赖（Node + Python）\nRUN npm ci \\\n  && npm ci --prefix frontend \\\n  && cd backend && uv sync --frozen\n\n# 复制项目源码\nCOPY . .\n\nEXPOSE 3000 5001\n\n# 同时启动前后端（开发模式）\nCMD [\"npm\", \"run\", \"dev\"]"
  },
  {
    "path": "LICENSE",
    "content": "                    GNU AFFERO GENERAL PUBLIC LICENSE\n                       Version 3, 19 November 2007\n\n Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>\n Everyone is permitted to copy and distribute verbatim copies\n of this license document, but changing it is not allowed.\n\n                            Preamble\n\n  The GNU Affero General Public License is a free, copyleft license for\nsoftware and other kinds of works, specifically designed to ensure\ncooperation with the community in the case of network server software.\n\n  The licenses for most software and other practical works are designed\nto take away your freedom to share and change the works.  By contrast,\nour General Public Licenses are intended to guarantee your freedom to\nshare and change all versions of a program--to make sure it remains free\nsoftware for all its users.\n\n  When we speak of free software, we are referring to freedom, not\nprice.  Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthem if you wish), that you receive source code or can get it if you\nwant it, that you can change the software or use pieces of it in new\nfree programs, and that you know you can do these things.\n\n  Developers that use our General Public Licenses protect your rights\nwith two steps: (1) assert copyright on the software, and (2) offer\nyou this License which gives you legal permission to copy, distribute\nand/or modify the software.\n\n  A secondary benefit of defending all users' freedom is that\nimprovements made in alternate versions of the program, if they\nreceive widespread use, become available for other developers to\nincorporate.  Many developers of free software are heartened and\nencouraged by the resulting cooperation.  However, in the case of\nsoftware used on network servers, this result may fail to come about.\nThe GNU General Public License permits making a modified version and\nletting the public access it on a server without ever releasing its\nsource code to the public.\n\n  The GNU Affero General Public License is designed specifically to\nensure that, in such cases, the modified source code becomes available\nto the community.  It requires the operator of a network server to\nprovide the source code of the modified version running there to the\nusers of that server.  Therefore, public use of a modified version, on\na publicly accessible server, gives the public access to the source\ncode of the modified version.\n\n  An older license, called the Affero General Public License and\npublished by Affero, was designed to accomplish similar goals.  This is\na different license, not a version of the Affero GPL, but Affero has\nreleased a new version of the Affero GPL which permits relicensing under\nthis license.\n\n  The precise terms and conditions for copying, distribution and\nmodification follow.\n\n                       TERMS AND CONDITIONS\n\n  0. Definitions.\n\n  \"This License\" refers to version 3 of the GNU Affero General Public License.\n\n  \"Copyright\" also means copyright-like laws that apply to other kinds of\nworks, such as semiconductor masks.\n\n  \"The Program\" refers to any copyrightable work licensed under this\nLicense.  Each licensee is addressed as \"you\".  \"Licensees\" and\n\"recipients\" may be individuals or organizations.\n\n  To \"modify\" a work means to copy from or adapt all or part of the work\nin a fashion requiring copyright permission, other than the making of an\nexact copy.  The resulting work is called a \"modified version\" of the\nearlier work or a work \"based on\" the earlier work.\n\n  A \"covered work\" means either the unmodified Program or a work based\non the Program.\n\n  To \"propagate\" a work means to do anything with it that, without\npermission, would make you directly or secondarily liable for\ninfringement under applicable copyright law, except executing it on a\ncomputer or modifying a private copy.  Propagation includes copying,\ndistribution (with or without modification), making available to the\npublic, and in some countries other activities as well.\n\n  To \"convey\" a work means any kind of propagation that enables other\nparties to make or receive copies.  Mere interaction with a user through\na computer network, with no transfer of a copy, is not conveying.\n\n  An interactive user interface displays \"Appropriate Legal Notices\"\nto the extent that it includes a convenient and prominently visible\nfeature that (1) displays an appropriate copyright notice, and (2)\ntells the user that there is no warranty for the work (except to the\nextent that warranties are provided), that licensees may convey the\nwork under this License, and how to view a copy of this License.  If\nthe interface presents a list of user commands or options, such as a\nmenu, a prominent item in the list meets this criterion.\n\n  1. Source Code.\n\n  The \"source code\" for a work means the preferred form of the work\nfor making modifications to it.  \"Object code\" means any non-source\nform of a work.\n\n  A \"Standard Interface\" means an interface that either is an official\nstandard defined by a recognized standards body, or, in the case of\ninterfaces specified for a particular programming language, one that\nis widely used among developers working in that language.\n\n  The \"System Libraries\" of an executable work include anything, other\nthan the work as a whole, that (a) is included in the normal form of\npackaging a Major Component, but which is not part of that Major\nComponent, and (b) serves only to enable use of the work with that\nMajor Component, or to implement a Standard Interface for which an\nimplementation is available to the public in source code form.  A\n\"Major Component\", in this context, means a major essential component\n(kernel, window system, and so on) of the specific operating system\n(if any) on which the executable work runs, or a compiler used to\nproduce the work, or an object code interpreter used to run it.\n\n  The \"Corresponding Source\" for a work in object code form means all\nthe source code needed to generate, install, and (for an executable\nwork) run the object code and to modify the work, including scripts to\ncontrol those activities.  However, it does not include the work's\nSystem Libraries, or general-purpose tools or generally available free\nprograms which are used unmodified in performing those activities but\nwhich are not part of the work.  For example, Corresponding Source\nincludes interface definition files associated with source files for\nthe work, and the source code for shared libraries and dynamically\nlinked subprograms that the work is specifically designed to require,\nsuch as by intimate data communication or control flow between those\nsubprograms and other parts of the work.\n\n  The Corresponding Source need not include anything that users\ncan regenerate automatically from other parts of the Corresponding\nSource.\n\n  The Corresponding Source for a work in source code form is that\nsame work.\n\n  2. Basic Permissions.\n\n  All rights granted under this License are granted for the term of\ncopyright on the Program, and are irrevocable provided the stated\nconditions are met.  This License explicitly affirms your unlimited\npermission to run the unmodified Program.  The output from running a\ncovered work is covered by this License only if the output, given its\ncontent, constitutes a covered work.  This License acknowledges your\nrights of fair use or other equivalent, as provided by copyright law.\n\n  You may make, run and propagate covered works that you do not\nconvey, without conditions so long as your license otherwise remains\nin force.  You may convey covered works to others for the sole purpose\nof having them make modifications exclusively for you, or provide you\nwith facilities for running those works, provided that you comply with\nthe terms of this License in conveying all material for which you do\nnot control copyright.  Those thus making or running the covered works\nfor you must do so exclusively on your behalf, under your direction\nand control, on terms that prohibit them from making any copies of\nyour copyrighted material outside their relationship with you.\n\n  Conveying under any other circumstances is permitted solely under\nthe conditions stated below.  Sublicensing is not allowed; section 10\nmakes it unnecessary.\n\n  3. Protecting Users' Legal Rights From Anti-Circumvention Law.\n\n  No covered work shall be deemed part of an effective technological\nmeasure under any applicable law fulfilling obligations under article\n11 of the WIPO copyright treaty adopted on 20 December 1996, or\nsimilar laws prohibiting or restricting circumvention of such\nmeasures.\n\n  When you convey a covered work, you waive any legal power to forbid\ncircumvention of technological measures to the extent such circumvention\nis effected by exercising rights under this License with respect to\nthe covered work, and you disclaim any intention to limit operation or\nmodification of the work as a means of enforcing, against the work's\nusers, your or third parties' legal rights to forbid circumvention of\ntechnological measures.\n\n  4. Conveying Verbatim Copies.\n\n  You may convey verbatim copies of the Program's source code as you\nreceive it, in any medium, provided that you conspicuously and\nappropriately publish on each copy an appropriate copyright notice;\nkeep intact all notices stating that this License and any\nnon-permissive terms added in accord with section 7 apply to the code;\nkeep intact all notices of the absence of any warranty; and give all\nrecipients a copy of this License along with the Program.\n\n  You may charge any price or no price for each copy that you convey,\nand you may offer support or warranty protection for a fee.\n\n  5. Conveying Modified Source Versions.\n\n  You may convey a work based on the Program, or the modifications to\nproduce it from the Program, in the form of source code under the\nterms of section 4, provided that you also meet all of these conditions:\n\n    a) The work must carry prominent notices stating that you modified\n    it, and giving a relevant date.\n\n    b) The work must carry prominent notices stating that it is\n    released under this License and any conditions added under section\n    7.  This requirement modifies the requirement in section 4 to\n    \"keep intact all notices\".\n\n    c) You must license the entire work, as a whole, under this\n    License to anyone who comes into possession of a copy.  This\n    License will therefore apply, along with any applicable section 7\n    additional terms, to the whole of the work, and all its parts,\n    regardless of how they are packaged.  This License gives no\n    permission to license the work in any other way, but it does not\n    invalidate such permission if you have separately received it.\n\n    d) If the work has interactive user interfaces, each must display\n    Appropriate Legal Notices; however, if the Program has interactive\n    interfaces that do not display Appropriate Legal Notices, your\n    work need not make them do so.\n\n  A compilation of a covered work with other separate and independent\nworks, which are not by their nature extensions of the covered work,\nand which are not combined with it such as to form a larger program,\nin or on a volume of a storage or distribution medium, is called an\n\"aggregate\" if the compilation and its resulting copyright are not\nused to limit the access or legal rights of the compilation's users\nbeyond what the individual works permit.  Inclusion of a covered work\nin an aggregate does not cause this License to apply to the other\nparts of the aggregate.\n\n  6. Conveying Non-Source Forms.\n\n  You may convey a covered work in object code form under the terms\nof sections 4 and 5, provided that you also convey the\nmachine-readable Corresponding Source under the terms of this License,\nin one of these ways:\n\n    a) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by the\n    Corresponding Source fixed on a durable physical medium\n    customarily used for software interchange.\n\n    b) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by a\n    written offer, valid for at least three years and valid for as\n    long as you offer spare parts or customer support for that product\n    model, to give anyone who possesses the object code either (1) a\n    copy of the Corresponding Source for all the software in the\n    product that is covered by this License, on a durable physical\n    medium customarily used for software interchange, for a price no\n    more than your reasonable cost of physically performing this\n    conveying of source, or (2) access to copy the\n    Corresponding Source from a network server at no charge.\n\n    c) Convey individual copies of the object code with a copy of the\n    written offer to provide the Corresponding Source.  This\n    alternative is allowed only occasionally and noncommercially, and\n    only if you received the object code with such an offer, in accord\n    with subsection 6b.\n\n    d) Convey the object code by offering access from a designated\n    place (gratis or for a charge), and offer equivalent access to the\n    Corresponding Source in the same way through the same place at no\n    further charge.  You need not require recipients to copy the\n    Corresponding Source along with the object code.  If the place to\n    copy the object code is a network server, the Corresponding Source\n    may be on a different server (operated by you or a third party)\n    that supports equivalent copying facilities, provided you maintain\n    clear directions next to the object code saying where to find the\n    Corresponding Source.  Regardless of what server hosts the\n    Corresponding Source, you remain obligated to ensure that it is\n    available for as long as needed to satisfy these requirements.\n\n    e) Convey the object code using peer-to-peer transmission, provided\n    you inform other peers where the object code and Corresponding\n    Source of the work are being offered to the general public at no\n    charge under subsection 6d.\n\n  A separable portion of the object code, whose source code is excluded\nfrom the Corresponding Source as a System Library, need not be\nincluded in conveying the object code work.\n\n  A \"User Product\" is either (1) a \"consumer product\", which means any\ntangible personal property which is normally used for personal, family,\nor household purposes, or (2) anything designed or sold for incorporation\ninto a dwelling.  In determining whether a product is a consumer product,\ndoubtful cases shall be resolved in favor of coverage.  For a particular\nproduct received by a particular user, \"normally used\" refers to a\ntypical or common use of that class of product, regardless of the status\nof the particular user or of the way in which the particular user\nactually uses, or expects or is expected to use, the product.  A product\nis a consumer product regardless of whether the product has substantial\ncommercial, industrial or non-consumer uses, unless such uses represent\nthe only significant mode of use of the product.\n\n  \"Installation Information\" for a User Product means any methods,\nprocedures, authorization keys, or other information required to install\nand execute modified versions of a covered work in that User Product from\na modified version of its Corresponding Source.  The information must\nsuffice to ensure that the continued functioning of the modified object\ncode is in no case prevented or interfered with solely because\nmodification has been made.\n\n  If you convey an object code work under this section in, or with, or\nspecifically for use in, a User Product, and the conveying occurs as\npart of a transaction in which the right of possession and use of the\nUser Product is transferred to the recipient in perpetuity or for a\nfixed term (regardless of how the transaction is characterized), the\nCorresponding Source conveyed under this section must be accompanied\nby the Installation Information.  But this requirement does not apply\nif neither you nor any third party retains the ability to install\nmodified object code on the User Product (for example, the work has\nbeen installed in ROM).\n\n  The requirement to provide Installation Information does not include a\nrequirement to continue to provide support service, warranty, or updates\nfor a work that has been modified or installed by the recipient, or for\nthe User Product in which it has been modified or installed.  Access to a\nnetwork may be denied when the modification itself materially and\nadversely affects the operation of the network or violates the rules and\nprotocols for communication across the network.\n\n  Corresponding Source conveyed, and Installation Information provided,\nin accord with this section must be in a format that is publicly\ndocumented (and with an implementation available to the public in\nsource code form), and must require no special password or key for\nunpacking, reading or copying.\n\n  7. Additional Terms.\n\n  \"Additional permissions\" are terms that supplement the terms of this\nLicense by making exceptions from one or more of its conditions.\nAdditional permissions that are applicable to the entire Program shall\nbe treated as though they were included in this License, to the extent\nthat they are valid under applicable law.  If additional permissions\napply only to part of the Program, that part may be used separately\nunder those permissions, but the entire Program remains governed by\nthis License without regard to the additional permissions.\n\n  When you convey a copy of a covered work, you may at your option\nremove any additional permissions from that copy, or from any part of\nit.  (Additional permissions may be written to require their own\nremoval in certain cases when you modify the work.)  You may place\nadditional permissions on material, added by you to a covered work,\nfor which you have or can give appropriate copyright permission.\n\n  Notwithstanding any other provision of this License, for material you\nadd to a covered work, you may (if authorized by the copyright holders of\nthat material) supplement the terms of this License with terms:\n\n    a) Disclaiming warranty or limiting liability differently from the\n    terms of sections 15 and 16 of this License; or\n\n    b) Requiring preservation of specified reasonable legal notices or\n    author attributions in that material or in the Appropriate Legal\n    Notices displayed by works containing it; or\n\n    c) Prohibiting misrepresentation of the origin of that material, or\n    requiring that modified versions of such material be marked in\n    reasonable ways as different from the original version; or\n\n    d) Limiting the use for publicity purposes of names of licensors or\n    authors of the material; or\n\n    e) Declining to grant rights under trademark law for use of some\n    trade names, trademarks, or service marks; or\n\n    f) Requiring indemnification of licensors and authors of that\n    material by anyone who conveys the material (or modified versions of\n    it) with contractual assumptions of liability to the recipient, for\n    any liability that these contractual assumptions directly impose on\n    those licensors and authors.\n\n  All other non-permissive additional terms are considered \"further\nrestrictions\" within the meaning of section 10.  If the Program as you\nreceived it, or any part of it, contains a notice stating that it is\ngoverned by this License along with a term that is a further\nrestriction, you may remove that term.  If a license document contains\na further restriction but permits relicensing or conveying under this\nLicense, you may add to a covered work material governed by the terms\nof that license document, provided that the further restriction does\nnot survive such relicensing or conveying.\n\n  If you add terms to a covered work in accord with this section, you\nmust place, in the relevant source files, a statement of the\nadditional terms that apply to those files, or a notice indicating\nwhere to find the applicable terms.\n\n  Additional terms, permissive or non-permissive, may be stated in the\nform of a separately written license, or stated as exceptions;\nthe above requirements apply either way.\n\n  8. Termination.\n\n  You may not propagate or modify a covered work except as expressly\nprovided under this License.  Any attempt otherwise to propagate or\nmodify it is void, and will automatically terminate your rights under\nthis License (including any patent licenses granted under the third\nparagraph of section 11).\n\n  However, if you cease all violation of this License, then your\nlicense from a particular copyright holder is reinstated (a)\nprovisionally, unless and until the copyright holder explicitly and\nfinally terminates your license, and (b) permanently, if the copyright\nholder fails to notify you of the violation by some reasonable means\nprior to 60 days after the cessation.\n\n  Moreover, your license from a particular copyright holder is\nreinstated permanently if the copyright holder notifies you of the\nviolation by some reasonable means, this is the first time you have\nreceived notice of violation of this License (for any work) from that\ncopyright holder, and you cure the violation prior to 30 days after\nyour receipt of the notice.\n\n  Termination of your rights under this section does not terminate the\nlicenses of parties who have received copies or rights from you under\nthis License.  If your rights have been terminated and not permanently\nreinstated, you do not qualify to receive new licenses for the same\nmaterial under section 10.\n\n  9. Acceptance Not Required for Having Copies.\n\n  You are not required to accept this License in order to receive or\nrun a copy of the Program.  Ancillary propagation of a covered work\noccurring solely as a consequence of using peer-to-peer transmission\nto receive a copy likewise does not require acceptance.  However,\nnothing other than this License grants you permission to propagate or\nmodify any covered work.  These actions infringe copyright if you do\nnot accept this License.  Therefore, by modifying or propagating a\ncovered work, you indicate your acceptance of this License to do so.\n\n  10. Automatic Licensing of Downstream Recipients.\n\n  Each time you convey a covered work, the recipient automatically\nreceives a license from the original licensors, to run, modify and\npropagate that work, subject to this License.  You are not responsible\nfor enforcing compliance by third parties with this License.\n\n  An \"entity transaction\" is a transaction transferring control of an\norganization, or substantially all assets of one, or subdividing an\norganization, or merging organizations.  If propagation of a covered\nwork results from an entity transaction, each party to that\ntransaction who receives a copy of the work also receives whatever\nlicenses to the work the party's predecessor in interest had or could\ngive under the previous paragraph, plus a right to possession of the\nCorresponding Source of the work from the predecessor in interest, if\nthe predecessor has it or can get it with reasonable efforts.\n\n  You may not impose any further restrictions on the exercise of the\nrights granted or affirmed under this License.  For example, you may\nnot impose a license fee, royalty, or other charge for exercise of\nrights granted under this License, and you may not initiate litigation\n(including a cross-claim or counterclaim in a lawsuit) alleging that\nany patent claim is infringed by making, using, selling, offering for\nsale, or importing the Program or any portion of it.\n\n  11. Patents.\n\n  A \"contributor\" is a copyright holder who authorizes use under this\nLicense of the Program or a work on which the Program is based.  The\nwork thus licensed is called the contributor's \"contributor version\".\n\n  A contributor's \"essential patent claims\" are all patent claims\nowned or controlled by the contributor, whether already acquired or\nhereafter acquired, that would be infringed by some manner, permitted\nby this License, of making, using, or selling its contributor version,\nbut do not include claims that would be infringed only as a\nconsequence of further modification of the contributor version.  For\npurposes of this definition, \"control\" includes the right to grant\npatent sublicenses in a manner consistent with the requirements of\nthis License.\n\n  Each contributor grants you a non-exclusive, worldwide, royalty-free\npatent license under the contributor's essential patent claims, to\nmake, use, sell, offer for sale, import and otherwise run, modify and\npropagate the contents of its contributor version.\n\n  In the following three paragraphs, a \"patent license\" is any express\nagreement or commitment, however denominated, not to enforce a patent\n(such as an express permission to practice a patent or covenant not to\nsue for patent infringement).  To \"grant\" such a patent license to a\nparty means to make such an agreement or commitment not to enforce a\npatent against the party.\n\n  If you convey a covered work, knowingly relying on a patent license,\nand the Corresponding Source of the work is not available for anyone\nto copy, free of charge and under the terms of this License, through a\npublicly available network server or other readily accessible means,\nthen you must either (1) cause the Corresponding Source to be so\navailable, or (2) arrange to deprive yourself of the benefit of the\npatent license for this particular work, or (3) arrange, in a manner\nconsistent with the requirements of this License, to extend the patent\nlicense to downstream recipients.  \"Knowingly relying\" means you have\nactual knowledge that, but for the patent license, your conveying the\ncovered work in a country, or your recipient's use of the covered work\nin a country, would infringe one or more identifiable patents in that\ncountry that you have reason to believe are valid.\n\n  If, pursuant to or in connection with a single transaction or\narrangement, you convey, or propagate by procuring conveyance of, a\ncovered work, and grant a patent license to some of the parties\nreceiving the covered work authorizing them to use, propagate, modify\nor convey a specific copy of the covered work, then the patent license\nyou grant is automatically extended to all recipients of the covered\nwork and works based on it.\n\n  A patent license is \"discriminatory\" if it does not include within\nthe scope of its coverage, prohibits the exercise of, or is\nconditioned on the non-exercise of one or more of the rights that are\nspecifically granted under this License.  You may not convey a covered\nwork if you are a party to an arrangement with a third party that is\nin the business of distributing software, under which you make payment\nto the third party based on the extent of your activity of conveying\nthe work, and under which the third party grants, to any of the\nparties who would receive the covered work from you, a discriminatory\npatent license (a) in connection with copies of the covered work\nconveyed by you (or copies made from those copies), or (b) primarily\nfor and in connection with specific products or compilations that\ncontain the covered work, unless you entered into that arrangement,\nor that patent license was granted, prior to 28 March 2007.\n\n  Nothing in this License shall be construed as excluding or limiting\nany implied license or other defenses to infringement that may\notherwise be available to you under applicable patent law.\n\n  12. No Surrender of Others' Freedom.\n\n  If conditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License.  If you cannot convey a\ncovered work so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you may\nnot convey it at all.  For example, if you agree to terms that obligate you\nto collect a royalty for further conveying from those to whom you convey\nthe Program, the only way you could satisfy both those terms and this\nLicense would be to refrain entirely from conveying the Program.\n\n  13. Remote Network Interaction; Use with the GNU General Public License.\n\n  Notwithstanding any other provision of this License, if you modify the\nProgram, your modified version must prominently offer all users\ninteracting with it remotely through a computer network (if your version\nsupports such interaction) an opportunity to receive the Corresponding\nSource of your version by providing access to the Corresponding Source\nfrom a network server at no charge, through some standard or customary\nmeans of facilitating copying of software.  This Corresponding Source\nshall include the Corresponding Source for any work covered by version 3\nof the GNU General Public License that is incorporated pursuant to the\nfollowing paragraph.\n\n  Notwithstanding any other provision of this License, you have\npermission to link or combine any covered work with a work licensed\nunder version 3 of the GNU General Public License into a single\ncombined work, and to convey the resulting work.  The terms of this\nLicense will continue to apply to the part which is the covered work,\nbut the work with which it is combined will remain governed by version\n3 of the GNU General Public License.\n\n  14. Revised Versions of this License.\n\n  The Free Software Foundation may publish revised and/or new versions of\nthe GNU Affero General Public License from time to time.  Such new versions\nwill be similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\n  Each version is given a distinguishing version number.  If the\nProgram specifies that a certain numbered version of the GNU Affero General\nPublic License \"or any later version\" applies to it, you have the\noption of following the terms and conditions either of that numbered\nversion or of any later version published by the Free Software\nFoundation.  If the Program does not specify a version number of the\nGNU Affero General Public License, you may choose any version ever published\nby the Free Software Foundation.\n\n  If the Program specifies that a proxy can decide which future\nversions of the GNU Affero General Public License can be used, that proxy's\npublic statement of acceptance of a version permanently authorizes you\nto choose that version for the Program.\n\n  Later license versions may give you additional or different\npermissions.  However, no additional obligations are imposed on any\nauthor or copyright holder as a result of your choosing to follow a\nlater version.\n\n  15. Disclaimer of Warranty.\n\n  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY\nAPPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT\nHOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY\nOF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,\nTHE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\nPURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM\nIS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF\nALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n  16. Limitation of Liability.\n\n  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS\nTHE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY\nGENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE\nUSE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF\nDATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD\nPARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),\nEVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF\nSUCH DAMAGES.\n\n  17. Interpretation of Sections 15 and 16.\n\n  If the disclaimer of warranty and limitation of liability provided\nabove cannot be given local legal effect according to their terms,\nreviewing courts shall apply local law that most closely approximates\nan absolute waiver of all civil liability in connection with the\nProgram, unless a warranty or assumption of liability accompanies a\ncopy of the Program in return for a fee.\n\n                     END OF TERMS AND CONDITIONS\n\n            How to Apply These Terms to Your New Programs\n\n  If you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\n  To do so, attach the following notices to the program.  It is safest\nto attach them to the start of each source file to most effectively\nstate the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n    <one line to give the program's name and a brief idea of what it does.>\n    Copyright (C) <year>  <name of author>\n\n    This program is free software: you can redistribute it and/or modify\n    it under the terms of the GNU Affero General Public License as published\n    by the Free Software Foundation, either version 3 of the License, or\n    (at your option) any later version.\n\n    This program is distributed in the hope that it will be useful,\n    but WITHOUT ANY WARRANTY; without even the implied warranty of\n    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n    GNU Affero General Public License for more details.\n\n    You should have received a copy of the GNU Affero General Public License\n    along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\nAlso add information on how to contact you by electronic and paper mail.\n\n  If your software can interact with users remotely through a computer\nnetwork, you should also make sure that it provides a way for users to\nget its source.  For example, if your program is a web application, its\ninterface could display a \"Source\" link that leads users to an archive\nof the code.  There are many ways you could offer source, and different\nsolutions will be better for different programs; see section 13 for the\nspecific requirements.\n\n  You should also get your employer (if you work as a programmer) or school,\nif any, to sign a \"copyright disclaimer\" for the program, if necessary.\nFor more information on this, and how to apply and follow the GNU AGPL, see\n<https://www.gnu.org/licenses/>.\n"
  },
  {
    "path": "README-EN.md",
    "content": "<div align=\"center\">\n\n<img src=\"./static/image/MiroFish_logo_compressed.jpeg\" alt=\"MiroFish Logo\" width=\"75%\"/>\n\n<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>\n\n简洁通用的群体智能引擎，预测万物\n</br>\n<em>A Simple and Universal Swarm Intelligence Engine, Predicting Anything</em>\n\n<a href=\"https://www.shanda.com/\" target=\"_blank\"><img src=\"./static/image/shanda_logo.png\" alt=\"666ghj%2MiroFish | Shanda\" height=\"40\"/></a>\n\n[![GitHub Stars](https://img.shields.io/github/stars/666ghj/MiroFish?style=flat-square&color=DAA520)](https://github.com/666ghj/MiroFish/stargazers)\n[![GitHub Watchers](https://img.shields.io/github/watchers/666ghj/MiroFish?style=flat-square)](https://github.com/666ghj/MiroFish/watchers)\n[![GitHub Forks](https://img.shields.io/github/forks/666ghj/MiroFish?style=flat-square)](https://github.com/666ghj/MiroFish/network)\n[![Docker](https://img.shields.io/badge/Docker-Build-2496ED?style=flat-square&logo=docker&logoColor=white)](https://hub.docker.com/)\n[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/666ghj/MiroFish)\n\n[![Discord](https://img.shields.io/badge/Discord-Join-5865F2?style=flat-square&logo=discord&logoColor=white)](http://discord.gg/ePf5aPaHnA)\n[![X](https://img.shields.io/badge/X-Follow-000000?style=flat-square&logo=x&logoColor=white)](https://x.com/mirofish_ai)\n[![Instagram](https://img.shields.io/badge/Instagram-Follow-E4405F?style=flat-square&logo=instagram&logoColor=white)](https://www.instagram.com/mirofish_ai/)\n\n[English](./README-EN.md) | [中文文档](./README.md)\n\n</div>\n\n## ⚡ Overview\n\n**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**.\n\n> You only need to: Upload seed materials (data analysis reports or interesting novel stories) and describe your prediction requirements in natural language</br>\n> MiroFish will return: A detailed prediction report and a deeply interactive high-fidelity digital world\n\n### Our Vision\n\nMiroFish 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:\n\n- **At the Macro Level**: We are a rehearsal laboratory for decision-makers, allowing policies and public relations to be tested at zero risk\n- **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\n\nFrom serious predictions to playful simulations, we let every \"what if\" see its outcome, making it possible to predict anything.\n\n## 🌐 Live Demo\n\nWelcome 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/)\n\n## 📸 Screenshots\n\n<div align=\"center\">\n<table>\n<tr>\n<td><img src=\"./static/image/Screenshot/运行截图1.png\" alt=\"Screenshot 1\" width=\"100%\"/></td>\n<td><img src=\"./static/image/Screenshot/运行截图2.png\" alt=\"Screenshot 2\" width=\"100%\"/></td>\n</tr>\n<tr>\n<td><img src=\"./static/image/Screenshot/运行截图3.png\" alt=\"Screenshot 3\" width=\"100%\"/></td>\n<td><img src=\"./static/image/Screenshot/运行截图4.png\" alt=\"Screenshot 4\" width=\"100%\"/></td>\n</tr>\n<tr>\n<td><img src=\"./static/image/Screenshot/运行截图5.png\" alt=\"Screenshot 5\" width=\"100%\"/></td>\n<td><img src=\"./static/image/Screenshot/运行截图6.png\" alt=\"Screenshot 6\" width=\"100%\"/></td>\n</tr>\n</table>\n</div>\n\n## 🎬 Demo Videos\n\n### 1. Wuhan University Public Opinion Simulation + MiroFish Project Introduction\n\n<div align=\"center\">\n<a href=\"https://www.bilibili.com/video/BV1VYBsBHEMY/\" target=\"_blank\"><img src=\"./static/image/武大模拟演示封面.png\" alt=\"MiroFish Demo Video\" width=\"75%\"/></a>\n\nClick the image to watch the complete demo video for prediction using BettaFish-generated \"Wuhan University Public Opinion Report\"\n</div>\n\n### 2. Dream of the Red Chamber Lost Ending Simulation\n\n<div align=\"center\">\n<a href=\"https://www.bilibili.com/video/BV1cPk3BBExq\" target=\"_blank\"><img src=\"./static/image/红楼梦模拟推演封面.jpg\" alt=\"MiroFish Demo Video\" width=\"75%\"/></a>\n\nClick 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\"\n</div>\n\n> **Financial Prediction**, **Political News Prediction** and more examples coming soon...\n\n## 🔄 Workflow\n\n1. **Graph Building**: Seed extraction & Individual/collective memory injection & GraphRAG construction\n2. **Environment Setup**: Entity relationship extraction & Persona generation & Agent configuration injection\n3. **Simulation**: Dual-platform parallel simulation & Auto-parse prediction requirements & Dynamic temporal memory updates\n4. **Report Generation**: ReportAgent with rich toolset for deep interaction with post-simulation environment\n5. **Deep Interaction**: Chat with any agent in the simulated world & Interact with ReportAgent\n\n## 🚀 Quick Start\n\n### Option 1: Source Code Deployment (Recommended)\n\n#### Prerequisites\n\n| Tool | Version | Description | Check Installation |\n|------|---------|-------------|-------------------|\n| **Node.js** | 18+ | Frontend runtime, includes npm | `node -v` |\n| **Python** | ≥3.11, ≤3.12 | Backend runtime | `python --version` |\n| **uv** | Latest | Python package manager | `uv --version` |\n\n#### 1. Configure Environment Variables\n\n```bash\n# Copy the example configuration file\ncp .env.example .env\n\n# Edit the .env file and fill in the required API keys\n```\n\n**Required Environment Variables:**\n\n```env\n# LLM API Configuration (supports any LLM API with OpenAI SDK format)\n# Recommended: Alibaba Qwen-plus model via Bailian Platform: https://bailian.console.aliyun.com/\n# High consumption, try simulations with fewer than 40 rounds first\nLLM_API_KEY=your_api_key\nLLM_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1\nLLM_MODEL_NAME=qwen-plus\n\n# Zep Cloud Configuration\n# Free monthly quota is sufficient for simple usage: https://app.getzep.com/\nZEP_API_KEY=your_zep_api_key\n```\n\n#### 2. Install Dependencies\n\n```bash\n# One-click installation of all dependencies (root + frontend + backend)\nnpm run setup:all\n```\n\nOr install step by step:\n\n```bash\n# Install Node dependencies (root + frontend)\nnpm run setup\n\n# Install Python dependencies (backend, auto-creates virtual environment)\nnpm run setup:backend\n```\n\n#### 3. Start Services\n\n```bash\n# Start both frontend and backend (run from project root)\nnpm run dev\n```\n\n**Service URLs:**\n- Frontend: `http://localhost:3000`\n- Backend API: `http://localhost:5001`\n\n**Start Individually:**\n\n```bash\nnpm run backend   # Start backend only\nnpm run frontend  # Start frontend only\n```\n\n### Option 2: Docker Deployment\n\n```bash\n# 1. Configure environment variables (same as source deployment)\ncp .env.example .env\n\n# 2. Pull image and start\ndocker compose up -d\n```\n\nReads `.env` from root directory by default, maps ports `3000 (frontend) / 5001 (backend)`\n\n> Mirror address for faster pulling is provided as comments in `docker-compose.yml`, replace if needed.\n\n## 📬 Join the Conversation\n\n<div align=\"center\">\n<img src=\"./static/image/QQ群.png\" alt=\"QQ Group\" width=\"60%\"/>\n</div>\n\n&nbsp;\n\nThe 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**\n\n## 📄 Acknowledgments\n\n**MiroFish has received strategic support and incubation from Shanda Group!**\n\nMiroFish'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!\n\n## 📈 Project Statistics\n\n<a href=\"https://www.star-history.com/#666ghj/MiroFish&type=date&legend=top-left\">\n <picture>\n   <source media=\"(prefers-color-scheme: dark)\" srcset=\"https://api.star-history.com/svg?repos=666ghj/MiroFish&type=date&theme=dark&legend=top-left\" />\n   <source media=\"(prefers-color-scheme: light)\" srcset=\"https://api.star-history.com/svg?repos=666ghj/MiroFish&type=date&legend=top-left\" />\n   <img alt=\"Star History Chart\" src=\"https://api.star-history.com/svg?repos=666ghj/MiroFish&type=date&legend=top-left\" />\n </picture>\n</a>"
  },
  {
    "path": "README.md",
    "content": "<div align=\"center\">\n\n<img src=\"./static/image/MiroFish_logo_compressed.jpeg\" alt=\"MiroFish Logo\" width=\"75%\"/>\n\n<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>\n\n简洁通用的群体智能引擎，预测万物\n</br>\n<em>A Simple and Universal Swarm Intelligence Engine, Predicting Anything</em>\n\n<a href=\"https://www.shanda.com/\" target=\"_blank\"><img src=\"./static/image/shanda_logo.png\" alt=\"666ghj%2MiroFish | Shanda\" height=\"40\"/></a>\n\n[![GitHub Stars](https://img.shields.io/github/stars/666ghj/MiroFish?style=flat-square&color=DAA520)](https://github.com/666ghj/MiroFish/stargazers)\n[![GitHub Watchers](https://img.shields.io/github/watchers/666ghj/MiroFish?style=flat-square)](https://github.com/666ghj/MiroFish/watchers)\n[![GitHub Forks](https://img.shields.io/github/forks/666ghj/MiroFish?style=flat-square)](https://github.com/666ghj/MiroFish/network)\n[![Docker](https://img.shields.io/badge/Docker-Build-2496ED?style=flat-square&logo=docker&logoColor=white)](https://hub.docker.com/)\n[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/666ghj/MiroFish)\n\n[![Discord](https://img.shields.io/badge/Discord-Join-5865F2?style=flat-square&logo=discord&logoColor=white)](http://discord.gg/ePf5aPaHnA)\n[![X](https://img.shields.io/badge/X-Follow-000000?style=flat-square&logo=x&logoColor=white)](https://x.com/mirofish_ai)\n[![Instagram](https://img.shields.io/badge/Instagram-Follow-E4405F?style=flat-square&logo=instagram&logoColor=white)](https://www.instagram.com/mirofish_ai/)\n\n[English](./README-EN.md) | [中文文档](./README.md)\n\n</div>\n\n## ⚡ 项目概述\n\n**MiroFish** 是一款基于多智能体技术的新一代 AI 预测引擎。通过提取现实世界的种子信息（如突发新闻、政策草案、金融信号），自动构建出高保真的平行数字世界。在此空间内，成千上万个具备独立人格、长期记忆与行为逻辑的智能体进行自由交互与社会演化。你可透过「上帝视角」动态注入变量，精准推演未来走向——**让未来在数字沙盘中预演，助决策在百战模拟后胜出**。\n\n> 你只需：上传种子材料（数据分析报告或者有趣的小说故事），并用自然语言描述预测需求</br>\n> MiroFish 将返回：一份详尽的预测报告，以及一个可深度交互的高保真数字世界\n\n### 我们的愿景\n\nMiroFish 致力于打造映射现实的群体智能镜像，通过捕捉个体互动引发的群体涌现，突破传统预测的局限：\n\n- **于宏观**：我们是决策者的预演实验室，让政策与公关在零风险中试错\n- **于微观**：我们是个人用户的创意沙盘，无论是推演小说结局还是探索脑洞，皆可有趣、好玩、触手可及\n\n从严肃预测到趣味仿真，我们让每一个如果都能看见结果，让预测万物成为可能。\n\n## 🌐 在线体验\n\n欢迎访问在线 Demo 演示环境，体验我们为你准备的一次关于热点舆情事件的推演预测：[mirofish-live-demo](https://666ghj.github.io/mirofish-demo/)\n\n## 📸 系统截图\n\n<div align=\"center\">\n<table>\n<tr>\n<td><img src=\"./static/image/Screenshot/运行截图1.png\" alt=\"截图1\" width=\"100%\"/></td>\n<td><img src=\"./static/image/Screenshot/运行截图2.png\" alt=\"截图2\" width=\"100%\"/></td>\n</tr>\n<tr>\n<td><img src=\"./static/image/Screenshot/运行截图3.png\" alt=\"截图3\" width=\"100%\"/></td>\n<td><img src=\"./static/image/Screenshot/运行截图4.png\" alt=\"截图4\" width=\"100%\"/></td>\n</tr>\n<tr>\n<td><img src=\"./static/image/Screenshot/运行截图5.png\" alt=\"截图5\" width=\"100%\"/></td>\n<td><img src=\"./static/image/Screenshot/运行截图6.png\" alt=\"截图6\" width=\"100%\"/></td>\n</tr>\n</table>\n</div>\n\n## 🎬 演示视频\n\n### 1. 武汉大学舆情推演预测 + MiroFish项目讲解\n\n<div align=\"center\">\n<a href=\"https://www.bilibili.com/video/BV1VYBsBHEMY/\" target=\"_blank\"><img src=\"./static/image/武大模拟演示封面.png\" alt=\"MiroFish Demo Video\" width=\"75%\"/></a>\n\n点击图片查看使用微舆BettaFish生成的《武大舆情报告》进行预测的完整演示视频\n</div>\n\n### 2. 《红楼梦》失传结局推演预测\n\n<div align=\"center\">\n<a href=\"https://www.bilibili.com/video/BV1cPk3BBExq\" target=\"_blank\"><img src=\"./static/image/红楼梦模拟推演封面.jpg\" alt=\"MiroFish Demo Video\" width=\"75%\"/></a>\n\n点击图片查看基于《红楼梦》前80回数十万字，MiroFish深度预测失传结局\n</div>\n\n> **金融方向推演预测**、**时政要闻推演预测**等示例陆续更新中...\n\n## 🔄 工作流程\n\n1. **图谱构建**：现实种子提取 & 个体与群体记忆注入 & GraphRAG构建\n2. **环境搭建**：实体关系抽取 & 人设生成 & 环境配置Agent注入仿真参数\n3. **开始模拟**：双平台并行模拟 & 自动解析预测需求 & 动态更新时序记忆\n4. **报告生成**：ReportAgent拥有丰富的工具集与模拟后环境进行深度交互\n5. **深度互动**：与模拟世界中的任意一位进行对话 & 与ReportAgent进行对话\n\n## 🚀 快速开始\n\n### 一、源码部署（推荐）\n\n#### 前置要求\n\n| 工具 | 版本要求 | 说明 | 安装检查 |\n|------|---------|------|---------|\n| **Node.js** | 18+ | 前端运行环境，包含 npm | `node -v` |\n| **Python** | ≥3.11, ≤3.12 | 后端运行环境 | `python --version` |\n| **uv** | 最新版 | Python 包管理器 | `uv --version` |\n\n#### 1. 配置环境变量\n\n```bash\n# 复制示例配置文件\ncp .env.example .env\n\n# 编辑 .env 文件，填入必要的 API 密钥\n```\n\n**必需的环境变量：**\n\n```env\n# LLM API配置（支持 OpenAI SDK 格式的任意 LLM API）\n# 推荐使用阿里百炼平台qwen-plus模型：https://bailian.console.aliyun.com/\n# 注意消耗较大，可先进行小于40轮的模拟尝试\nLLM_API_KEY=your_api_key\nLLM_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1\nLLM_MODEL_NAME=qwen-plus\n\n# Zep Cloud 配置\n# 每月免费额度即可支撑简单使用：https://app.getzep.com/\nZEP_API_KEY=your_zep_api_key\n```\n\n#### 2. 安装依赖\n\n```bash\n# 一键安装所有依赖（根目录 + 前端 + 后端）\nnpm run setup:all\n```\n\n或者分步安装：\n\n```bash\n# 安装 Node 依赖（根目录 + 前端）\nnpm run setup\n\n# 安装 Python 依赖（后端，自动创建虚拟环境）\nnpm run setup:backend\n```\n\n#### 3. 启动服务\n\n```bash\n# 同时启动前后端（在项目根目录执行）\nnpm run dev\n```\n\n**服务地址：**\n- 前端：`http://localhost:3000`\n- 后端 API：`http://localhost:5001`\n\n**单独启动：**\n\n```bash\nnpm run backend   # 仅启动后端\nnpm run frontend  # 仅启动前端\n```\n\n### 二、Docker 部署\n\n```bash\n# 1. 配置环境变量（同源码部署）\ncp .env.example .env\n\n# 2. 拉取镜像并启动\ndocker compose up -d\n```\n\n默认会读取根目录下的 `.env`，并映射端口 `3000（前端）/5001（后端）`\n\n> 在 `docker-compose.yml` 中已通过注释提供加速镜像地址，可按需替换\n\n## 📬 更多交流\n\n<div align=\"center\">\n<img src=\"./static/image/QQ群.png\" alt=\"QQ交流群\" width=\"60%\"/>\n</div>\n\n&nbsp;\n\nMiroFish团队长期招募全职/实习，如果你对多Agent应用感兴趣，欢迎投递简历至：**mirofish@shanda.com**\n\n## 📄 致谢\n\n**MiroFish 得到了盛大集团的战略支持和孵化！**\n\nMiroFish 的仿真引擎由 **[OASIS](https://github.com/camel-ai/oasis)** 驱动，我们衷心感谢 CAMEL-AI 团队的开源贡献！\n\n## 📈 项目统计\n\n<a href=\"https://www.star-history.com/#666ghj/MiroFish&type=date&legend=top-left\">\n <picture>\n   <source media=\"(prefers-color-scheme: dark)\" srcset=\"https://api.star-history.com/svg?repos=666ghj/MiroFish&type=date&theme=dark&legend=top-left\" />\n   <source media=\"(prefers-color-scheme: light)\" srcset=\"https://api.star-history.com/svg?repos=666ghj/MiroFish&type=date&legend=top-left\" />\n   <img alt=\"Star History Chart\" src=\"https://api.star-history.com/svg?repos=666ghj/MiroFish&type=date&legend=top-left\" />\n </picture>\n</a>\n"
  },
  {
    "path": "backend/app/__init__.py",
    "content": "\"\"\"\nMiroFish Backend - Flask应用工厂\n\"\"\"\n\nimport os\nimport warnings\n\n# 抑制 multiprocessing resource_tracker 的警告（来自第三方库如 transformers）\n# 需要在所有其他导入之前设置\nwarnings.filterwarnings(\"ignore\", message=\".*resource_tracker.*\")\n\nfrom flask import Flask, request\nfrom flask_cors import CORS\n\nfrom .config import Config\nfrom .utils.logger import setup_logger, get_logger\n\n\ndef create_app(config_class=Config):\n    \"\"\"Flask应用工厂函数\"\"\"\n    app = Flask(__name__)\n    app.config.from_object(config_class)\n    \n    # 设置JSON编码：确保中文直接显示（而不是 \\uXXXX 格式）\n    # Flask >= 2.3 使用 app.json.ensure_ascii，旧版本使用 JSON_AS_ASCII 配置\n    if hasattr(app, 'json') and hasattr(app.json, 'ensure_ascii'):\n        app.json.ensure_ascii = False\n    \n    # 设置日志\n    logger = setup_logger('mirofish')\n    \n    # 只在 reloader 子进程中打印启动信息（避免 debug 模式下打印两次）\n    is_reloader_process = os.environ.get('WERKZEUG_RUN_MAIN') == 'true'\n    debug_mode = app.config.get('DEBUG', False)\n    should_log_startup = not debug_mode or is_reloader_process\n    \n    if should_log_startup:\n        logger.info(\"=\" * 50)\n        logger.info(\"MiroFish Backend 启动中...\")\n        logger.info(\"=\" * 50)\n    \n    # 启用CORS\n    CORS(app, resources={r\"/api/*\": {\"origins\": \"*\"}})\n    \n    # 注册模拟进程清理函数（确保服务器关闭时终止所有模拟进程）\n    from .services.simulation_runner import SimulationRunner\n    SimulationRunner.register_cleanup()\n    if should_log_startup:\n        logger.info(\"已注册模拟进程清理函数\")\n    \n    # 请求日志中间件\n    @app.before_request\n    def log_request():\n        logger = get_logger('mirofish.request')\n        logger.debug(f\"请求: {request.method} {request.path}\")\n        if request.content_type and 'json' in request.content_type:\n            logger.debug(f\"请求体: {request.get_json(silent=True)}\")\n    \n    @app.after_request\n    def log_response(response):\n        logger = get_logger('mirofish.request')\n        logger.debug(f\"响应: {response.status_code}\")\n        return response\n    \n    # 注册蓝图\n    from .api import graph_bp, simulation_bp, report_bp\n    app.register_blueprint(graph_bp, url_prefix='/api/graph')\n    app.register_blueprint(simulation_bp, url_prefix='/api/simulation')\n    app.register_blueprint(report_bp, url_prefix='/api/report')\n    \n    # 健康检查\n    @app.route('/health')\n    def health():\n        return {'status': 'ok', 'service': 'MiroFish Backend'}\n    \n    if should_log_startup:\n        logger.info(\"MiroFish Backend 启动完成\")\n    \n    return app\n\n"
  },
  {
    "path": "backend/app/api/__init__.py",
    "content": "\"\"\"\nAPI路由模块\n\"\"\"\n\nfrom flask import Blueprint\n\ngraph_bp = Blueprint('graph', __name__)\nsimulation_bp = Blueprint('simulation', __name__)\nreport_bp = Blueprint('report', __name__)\n\nfrom . import graph  # noqa: E402, F401\nfrom . import simulation  # noqa: E402, F401\nfrom . import report  # noqa: E402, F401\n\n"
  },
  {
    "path": "backend/app/api/graph.py",
    "content": "\"\"\"\n图谱相关API路由\n采用项目上下文机制，服务端持久化状态\n\"\"\"\n\nimport os\nimport traceback\nimport threading\nfrom flask import request, jsonify\n\nfrom . import graph_bp\nfrom ..config import Config\nfrom ..services.ontology_generator import OntologyGenerator\nfrom ..services.graph_builder import GraphBuilderService\nfrom ..services.text_processor import TextProcessor\nfrom ..utils.file_parser import FileParser\nfrom ..utils.logger import get_logger\nfrom ..models.task import TaskManager, TaskStatus\nfrom ..models.project import ProjectManager, ProjectStatus\n\n# 获取日志器\nlogger = get_logger('mirofish.api')\n\n\ndef allowed_file(filename: str) -> bool:\n    \"\"\"检查文件扩展名是否允许\"\"\"\n    if not filename or '.' not in filename:\n        return False\n    ext = os.path.splitext(filename)[1].lower().lstrip('.')\n    return ext in Config.ALLOWED_EXTENSIONS\n\n\n# ============== 项目管理接口 ==============\n\n@graph_bp.route('/project/<project_id>', methods=['GET'])\ndef get_project(project_id: str):\n    \"\"\"\n    获取项目详情\n    \"\"\"\n    project = ProjectManager.get_project(project_id)\n    \n    if not project:\n        return jsonify({\n            \"success\": False,\n            \"error\": f\"项目不存在: {project_id}\"\n        }), 404\n    \n    return jsonify({\n        \"success\": True,\n        \"data\": project.to_dict()\n    })\n\n\n@graph_bp.route('/project/list', methods=['GET'])\ndef list_projects():\n    \"\"\"\n    列出所有项目\n    \"\"\"\n    limit = request.args.get('limit', 50, type=int)\n    projects = ProjectManager.list_projects(limit=limit)\n    \n    return jsonify({\n        \"success\": True,\n        \"data\": [p.to_dict() for p in projects],\n        \"count\": len(projects)\n    })\n\n\n@graph_bp.route('/project/<project_id>', methods=['DELETE'])\ndef delete_project(project_id: str):\n    \"\"\"\n    删除项目\n    \"\"\"\n    success = ProjectManager.delete_project(project_id)\n    \n    if not success:\n        return jsonify({\n            \"success\": False,\n            \"error\": f\"项目不存在或删除失败: {project_id}\"\n        }), 404\n    \n    return jsonify({\n        \"success\": True,\n        \"message\": f\"项目已删除: {project_id}\"\n    })\n\n\n@graph_bp.route('/project/<project_id>/reset', methods=['POST'])\ndef reset_project(project_id: str):\n    \"\"\"\n    重置项目状态（用于重新构建图谱）\n    \"\"\"\n    project = ProjectManager.get_project(project_id)\n    \n    if not project:\n        return jsonify({\n            \"success\": False,\n            \"error\": f\"项目不存在: {project_id}\"\n        }), 404\n    \n    # 重置到本体已生成状态\n    if project.ontology:\n        project.status = ProjectStatus.ONTOLOGY_GENERATED\n    else:\n        project.status = ProjectStatus.CREATED\n    \n    project.graph_id = None\n    project.graph_build_task_id = None\n    project.error = None\n    ProjectManager.save_project(project)\n    \n    return jsonify({\n        \"success\": True,\n        \"message\": f\"项目已重置: {project_id}\",\n        \"data\": project.to_dict()\n    })\n\n\n# ============== 接口1：上传文件并生成本体 ==============\n\n@graph_bp.route('/ontology/generate', methods=['POST'])\ndef generate_ontology():\n    \"\"\"\n    接口1：上传文件，分析生成本体定义\n    \n    请求方式：multipart/form-data\n    \n    参数：\n        files: 上传的文件（PDF/MD/TXT），可多个\n        simulation_requirement: 模拟需求描述（必填）\n        project_name: 项目名称（可选）\n        additional_context: 额外说明（可选）\n        \n    返回：\n        {\n            \"success\": true,\n            \"data\": {\n                \"project_id\": \"proj_xxxx\",\n                \"ontology\": {\n                    \"entity_types\": [...],\n                    \"edge_types\": [...],\n                    \"analysis_summary\": \"...\"\n                },\n                \"files\": [...],\n                \"total_text_length\": 12345\n            }\n        }\n    \"\"\"\n    try:\n        logger.info(\"=== 开始生成本体定义 ===\")\n        \n        # 获取参数\n        simulation_requirement = request.form.get('simulation_requirement', '')\n        project_name = request.form.get('project_name', 'Unnamed Project')\n        additional_context = request.form.get('additional_context', '')\n        \n        logger.debug(f\"项目名称: {project_name}\")\n        logger.debug(f\"模拟需求: {simulation_requirement[:100]}...\")\n        \n        if not simulation_requirement:\n            return jsonify({\n                \"success\": False,\n                \"error\": \"请提供模拟需求描述 (simulation_requirement)\"\n            }), 400\n        \n        # 获取上传的文件\n        uploaded_files = request.files.getlist('files')\n        if not uploaded_files or all(not f.filename for f in uploaded_files):\n            return jsonify({\n                \"success\": False,\n                \"error\": \"请至少上传一个文档文件\"\n            }), 400\n        \n        # 创建项目\n        project = ProjectManager.create_project(name=project_name)\n        project.simulation_requirement = simulation_requirement\n        logger.info(f\"创建项目: {project.project_id}\")\n        \n        # 保存文件并提取文本\n        document_texts = []\n        all_text = \"\"\n        \n        for file in uploaded_files:\n            if file and file.filename and allowed_file(file.filename):\n                # 保存文件到项目目录\n                file_info = ProjectManager.save_file_to_project(\n                    project.project_id, \n                    file, \n                    file.filename\n                )\n                project.files.append({\n                    \"filename\": file_info[\"original_filename\"],\n                    \"size\": file_info[\"size\"]\n                })\n                \n                # 提取文本\n                text = FileParser.extract_text(file_info[\"path\"])\n                text = TextProcessor.preprocess_text(text)\n                document_texts.append(text)\n                all_text += f\"\\n\\n=== {file_info['original_filename']} ===\\n{text}\"\n        \n        if not document_texts:\n            ProjectManager.delete_project(project.project_id)\n            return jsonify({\n                \"success\": False,\n                \"error\": \"没有成功处理任何文档，请检查文件格式\"\n            }), 400\n        \n        # 保存提取的文本\n        project.total_text_length = len(all_text)\n        ProjectManager.save_extracted_text(project.project_id, all_text)\n        logger.info(f\"文本提取完成，共 {len(all_text)} 字符\")\n        \n        # 生成本体\n        logger.info(\"调用 LLM 生成本体定义...\")\n        generator = OntologyGenerator()\n        ontology = generator.generate(\n            document_texts=document_texts,\n            simulation_requirement=simulation_requirement,\n            additional_context=additional_context if additional_context else None\n        )\n        \n        # 保存本体到项目\n        entity_count = len(ontology.get(\"entity_types\", []))\n        edge_count = len(ontology.get(\"edge_types\", []))\n        logger.info(f\"本体生成完成: {entity_count} 个实体类型, {edge_count} 个关系类型\")\n        \n        project.ontology = {\n            \"entity_types\": ontology.get(\"entity_types\", []),\n            \"edge_types\": ontology.get(\"edge_types\", [])\n        }\n        project.analysis_summary = ontology.get(\"analysis_summary\", \"\")\n        project.status = ProjectStatus.ONTOLOGY_GENERATED\n        ProjectManager.save_project(project)\n        logger.info(f\"=== 本体生成完成 === 项目ID: {project.project_id}\")\n        \n        return jsonify({\n            \"success\": True,\n            \"data\": {\n                \"project_id\": project.project_id,\n                \"project_name\": project.name,\n                \"ontology\": project.ontology,\n                \"analysis_summary\": project.analysis_summary,\n                \"files\": project.files,\n                \"total_text_length\": project.total_text_length\n            }\n        })\n        \n    except Exception as e:\n        return jsonify({\n            \"success\": False,\n            \"error\": str(e),\n            \"traceback\": traceback.format_exc()\n        }), 500\n\n\n# ============== 接口2：构建图谱 ==============\n\n@graph_bp.route('/build', methods=['POST'])\ndef build_graph():\n    \"\"\"\n    接口2：根据project_id构建图谱\n    \n    请求（JSON）：\n        {\n            \"project_id\": \"proj_xxxx\",  // 必填，来自接口1\n            \"graph_name\": \"图谱名称\",    // 可选\n            \"chunk_size\": 500,          // 可选，默认500\n            \"chunk_overlap\": 50         // 可选，默认50\n        }\n        \n    返回：\n        {\n            \"success\": true,\n            \"data\": {\n                \"project_id\": \"proj_xxxx\",\n                \"task_id\": \"task_xxxx\",\n                \"message\": \"图谱构建任务已启动\"\n            }\n        }\n    \"\"\"\n    try:\n        logger.info(\"=== 开始构建图谱 ===\")\n        \n        # 检查配置\n        errors = []\n        if not Config.ZEP_API_KEY:\n            errors.append(\"ZEP_API_KEY未配置\")\n        if errors:\n            logger.error(f\"配置错误: {errors}\")\n            return jsonify({\n                \"success\": False,\n                \"error\": \"配置错误: \" + \"; \".join(errors)\n            }), 500\n        \n        # 解析请求\n        data = request.get_json() or {}\n        project_id = data.get('project_id')\n        logger.debug(f\"请求参数: project_id={project_id}\")\n        \n        if not project_id:\n            return jsonify({\n                \"success\": False,\n                \"error\": \"请提供 project_id\"\n            }), 400\n        \n        # 获取项目\n        project = ProjectManager.get_project(project_id)\n        if not project:\n            return jsonify({\n                \"success\": False,\n                \"error\": f\"项目不存在: {project_id}\"\n            }), 404\n        \n        # 检查项目状态\n        force = data.get('force', False)  # 强制重新构建\n        \n        if project.status == ProjectStatus.CREATED:\n            return jsonify({\n                \"success\": False,\n                \"error\": \"项目尚未生成本体，请先调用 /ontology/generate\"\n            }), 400\n        \n        if project.status == ProjectStatus.GRAPH_BUILDING and not force:\n            return jsonify({\n                \"success\": False,\n                \"error\": \"图谱正在构建中，请勿重复提交。如需强制重建，请添加 force: true\",\n                \"task_id\": project.graph_build_task_id\n            }), 400\n        \n        # 如果强制重建，重置状态\n        if force and project.status in [ProjectStatus.GRAPH_BUILDING, ProjectStatus.FAILED, ProjectStatus.GRAPH_COMPLETED]:\n            project.status = ProjectStatus.ONTOLOGY_GENERATED\n            project.graph_id = None\n            project.graph_build_task_id = None\n            project.error = None\n        \n        # 获取配置\n        graph_name = data.get('graph_name', project.name or 'MiroFish Graph')\n        chunk_size = data.get('chunk_size', project.chunk_size or Config.DEFAULT_CHUNK_SIZE)\n        chunk_overlap = data.get('chunk_overlap', project.chunk_overlap or Config.DEFAULT_CHUNK_OVERLAP)\n        \n        # 更新项目配置\n        project.chunk_size = chunk_size\n        project.chunk_overlap = chunk_overlap\n        \n        # 获取提取的文本\n        text = ProjectManager.get_extracted_text(project_id)\n        if not text:\n            return jsonify({\n                \"success\": False,\n                \"error\": \"未找到提取的文本内容\"\n            }), 400\n        \n        # 获取本体\n        ontology = project.ontology\n        if not ontology:\n            return jsonify({\n                \"success\": False,\n                \"error\": \"未找到本体定义\"\n            }), 400\n        \n        # 创建异步任务\n        task_manager = TaskManager()\n        task_id = task_manager.create_task(f\"构建图谱: {graph_name}\")\n        logger.info(f\"创建图谱构建任务: task_id={task_id}, project_id={project_id}\")\n        \n        # 更新项目状态\n        project.status = ProjectStatus.GRAPH_BUILDING\n        project.graph_build_task_id = task_id\n        ProjectManager.save_project(project)\n        \n        # 启动后台任务\n        def build_task():\n            build_logger = get_logger('mirofish.build')\n            try:\n                build_logger.info(f\"[{task_id}] 开始构建图谱...\")\n                task_manager.update_task(\n                    task_id, \n                    status=TaskStatus.PROCESSING,\n                    message=\"初始化图谱构建服务...\"\n                )\n                \n                # 创建图谱构建服务\n                builder = GraphBuilderService(api_key=Config.ZEP_API_KEY)\n                \n                # 分块\n                task_manager.update_task(\n                    task_id,\n                    message=\"文本分块中...\",\n                    progress=5\n                )\n                chunks = TextProcessor.split_text(\n                    text, \n                    chunk_size=chunk_size, \n                    overlap=chunk_overlap\n                )\n                total_chunks = len(chunks)\n                \n                # 创建图谱\n                task_manager.update_task(\n                    task_id,\n                    message=\"创建Zep图谱...\",\n                    progress=10\n                )\n                graph_id = builder.create_graph(name=graph_name)\n                \n                # 更新项目的graph_id\n                project.graph_id = graph_id\n                ProjectManager.save_project(project)\n                \n                # 设置本体\n                task_manager.update_task(\n                    task_id,\n                    message=\"设置本体定义...\",\n                    progress=15\n                )\n                builder.set_ontology(graph_id, ontology)\n                \n                # 添加文本（progress_callback 签名是 (msg, progress_ratio)）\n                def add_progress_callback(msg, progress_ratio):\n                    progress = 15 + int(progress_ratio * 40)  # 15% - 55%\n                    task_manager.update_task(\n                        task_id,\n                        message=msg,\n                        progress=progress\n                    )\n                \n                task_manager.update_task(\n                    task_id,\n                    message=f\"开始添加 {total_chunks} 个文本块...\",\n                    progress=15\n                )\n                \n                episode_uuids = builder.add_text_batches(\n                    graph_id, \n                    chunks,\n                    batch_size=3,\n                    progress_callback=add_progress_callback\n                )\n                \n                # 等待Zep处理完成（查询每个episode的processed状态）\n                task_manager.update_task(\n                    task_id,\n                    message=\"等待Zep处理数据...\",\n                    progress=55\n                )\n                \n                def wait_progress_callback(msg, progress_ratio):\n                    progress = 55 + int(progress_ratio * 35)  # 55% - 90%\n                    task_manager.update_task(\n                        task_id,\n                        message=msg,\n                        progress=progress\n                    )\n                \n                builder._wait_for_episodes(episode_uuids, wait_progress_callback)\n                \n                # 获取图谱数据\n                task_manager.update_task(\n                    task_id,\n                    message=\"获取图谱数据...\",\n                    progress=95\n                )\n                graph_data = builder.get_graph_data(graph_id)\n                \n                # 更新项目状态\n                project.status = ProjectStatus.GRAPH_COMPLETED\n                ProjectManager.save_project(project)\n                \n                node_count = graph_data.get(\"node_count\", 0)\n                edge_count = graph_data.get(\"edge_count\", 0)\n                build_logger.info(f\"[{task_id}] 图谱构建完成: graph_id={graph_id}, 节点={node_count}, 边={edge_count}\")\n                \n                # 完成\n                task_manager.update_task(\n                    task_id,\n                    status=TaskStatus.COMPLETED,\n                    message=\"图谱构建完成\",\n                    progress=100,\n                    result={\n                        \"project_id\": project_id,\n                        \"graph_id\": graph_id,\n                        \"node_count\": node_count,\n                        \"edge_count\": edge_count,\n                        \"chunk_count\": total_chunks\n                    }\n                )\n                \n            except Exception as e:\n                # 更新项目状态为失败\n                build_logger.error(f\"[{task_id}] 图谱构建失败: {str(e)}\")\n                build_logger.debug(traceback.format_exc())\n                \n                project.status = ProjectStatus.FAILED\n                project.error = str(e)\n                ProjectManager.save_project(project)\n                \n                task_manager.update_task(\n                    task_id,\n                    status=TaskStatus.FAILED,\n                    message=f\"构建失败: {str(e)}\",\n                    error=traceback.format_exc()\n                )\n        \n        # 启动后台线程\n        thread = threading.Thread(target=build_task, daemon=True)\n        thread.start()\n        \n        return jsonify({\n            \"success\": True,\n            \"data\": {\n                \"project_id\": project_id,\n                \"task_id\": task_id,\n                \"message\": \"图谱构建任务已启动，请通过 /task/{task_id} 查询进度\"\n            }\n        })\n        \n    except Exception as e:\n        return jsonify({\n            \"success\": False,\n            \"error\": str(e),\n            \"traceback\": traceback.format_exc()\n        }), 500\n\n\n# ============== 任务查询接口 ==============\n\n@graph_bp.route('/task/<task_id>', methods=['GET'])\ndef get_task(task_id: str):\n    \"\"\"\n    查询任务状态\n    \"\"\"\n    task = TaskManager().get_task(task_id)\n    \n    if not task:\n        return jsonify({\n            \"success\": False,\n            \"error\": f\"任务不存在: {task_id}\"\n        }), 404\n    \n    return jsonify({\n        \"success\": True,\n        \"data\": task.to_dict()\n    })\n\n\n@graph_bp.route('/tasks', methods=['GET'])\ndef list_tasks():\n    \"\"\"\n    列出所有任务\n    \"\"\"\n    tasks = TaskManager().list_tasks()\n    \n    return jsonify({\n        \"success\": True,\n        \"data\": [t.to_dict() for t in tasks],\n        \"count\": len(tasks)\n    })\n\n\n# ============== 图谱数据接口 ==============\n\n@graph_bp.route('/data/<graph_id>', methods=['GET'])\ndef get_graph_data(graph_id: str):\n    \"\"\"\n    获取图谱数据（节点和边）\n    \"\"\"\n    try:\n        if not Config.ZEP_API_KEY:\n            return jsonify({\n                \"success\": False,\n                \"error\": \"ZEP_API_KEY未配置\"\n            }), 500\n        \n        builder = GraphBuilderService(api_key=Config.ZEP_API_KEY)\n        graph_data = builder.get_graph_data(graph_id)\n        \n        return jsonify({\n            \"success\": True,\n            \"data\": graph_data\n        })\n        \n    except Exception as e:\n        return jsonify({\n            \"success\": False,\n            \"error\": str(e),\n            \"traceback\": traceback.format_exc()\n        }), 500\n\n\n@graph_bp.route('/delete/<graph_id>', methods=['DELETE'])\ndef delete_graph(graph_id: str):\n    \"\"\"\n    删除Zep图谱\n    \"\"\"\n    try:\n        if not Config.ZEP_API_KEY:\n            return jsonify({\n                \"success\": False,\n                \"error\": \"ZEP_API_KEY未配置\"\n            }), 500\n        \n        builder = GraphBuilderService(api_key=Config.ZEP_API_KEY)\n        builder.delete_graph(graph_id)\n        \n        return jsonify({\n            \"success\": True,\n            \"message\": f\"图谱已删除: {graph_id}\"\n        })\n        \n    except Exception as e:\n        return jsonify({\n            \"success\": False,\n            \"error\": str(e),\n            \"traceback\": traceback.format_exc()\n        }), 500\n"
  },
  {
    "path": "backend/app/api/report.py",
    "content": "\"\"\"\nReport API路由\n提供模拟报告生成、获取、对话等接口\n\"\"\"\n\nimport os\nimport traceback\nimport threading\nfrom flask import request, jsonify, send_file\n\nfrom . import report_bp\nfrom ..config import Config\nfrom ..services.report_agent import ReportAgent, ReportManager, ReportStatus\nfrom ..services.simulation_manager import SimulationManager\nfrom ..models.project import ProjectManager\nfrom ..models.task import TaskManager, TaskStatus\nfrom ..utils.logger import get_logger\n\nlogger = get_logger('mirofish.api.report')\n\n\n# ============== 报告生成接口 ==============\n\n@report_bp.route('/generate', methods=['POST'])\ndef generate_report():\n    \"\"\"\n    生成模拟分析报告（异步任务）\n    \n    这是一个耗时操作，接口会立即返回task_id，\n    使用 GET /api/report/generate/status 查询进度\n    \n    请求（JSON）：\n        {\n            \"simulation_id\": \"sim_xxxx\",    // 必填，模拟ID\n            \"force_regenerate\": false        // 可选，强制重新生成\n        }\n    \n    返回：\n        {\n            \"success\": true,\n            \"data\": {\n                \"simulation_id\": \"sim_xxxx\",\n                \"task_id\": \"task_xxxx\",\n                \"status\": \"generating\",\n                \"message\": \"报告生成任务已启动\"\n            }\n        }\n    \"\"\"\n    try:\n        data = request.get_json() or {}\n        \n        simulation_id = data.get('simulation_id')\n        if not simulation_id:\n            return jsonify({\n                \"success\": False,\n                \"error\": \"请提供 simulation_id\"\n            }), 400\n        \n        force_regenerate = data.get('force_regenerate', False)\n        \n        # 获取模拟信息\n        manager = SimulationManager()\n        state = manager.get_simulation(simulation_id)\n        \n        if not state:\n            return jsonify({\n                \"success\": False,\n                \"error\": f\"模拟不存在: {simulation_id}\"\n            }), 404\n        \n        # 检查是否已有报告\n        if not force_regenerate:\n            existing_report = ReportManager.get_report_by_simulation(simulation_id)\n            if existing_report and existing_report.status == ReportStatus.COMPLETED:\n                return jsonify({\n                    \"success\": True,\n                    \"data\": {\n                        \"simulation_id\": simulation_id,\n                        \"report_id\": existing_report.report_id,\n                        \"status\": \"completed\",\n                        \"message\": \"报告已存在\",\n                        \"already_generated\": True\n                    }\n                })\n        \n        # 获取项目信息\n        project = ProjectManager.get_project(state.project_id)\n        if not project:\n            return jsonify({\n                \"success\": False,\n                \"error\": f\"项目不存在: {state.project_id}\"\n            }), 404\n        \n        graph_id = state.graph_id or project.graph_id\n        if not graph_id:\n            return jsonify({\n                \"success\": False,\n                \"error\": \"缺少图谱ID，请确保已构建图谱\"\n            }), 400\n        \n        simulation_requirement = project.simulation_requirement\n        if not simulation_requirement:\n            return jsonify({\n                \"success\": False,\n                \"error\": \"缺少模拟需求描述\"\n            }), 400\n        \n        # 提前生成 report_id，以便立即返回给前端\n        import uuid\n        report_id = f\"report_{uuid.uuid4().hex[:12]}\"\n        \n        # 创建异步任务\n        task_manager = TaskManager()\n        task_id = task_manager.create_task(\n            task_type=\"report_generate\",\n            metadata={\n                \"simulation_id\": simulation_id,\n                \"graph_id\": graph_id,\n                \"report_id\": report_id\n            }\n        )\n        \n        # 定义后台任务\n        def run_generate():\n            try:\n                task_manager.update_task(\n                    task_id,\n                    status=TaskStatus.PROCESSING,\n                    progress=0,\n                    message=\"初始化Report Agent...\"\n                )\n                \n                # 创建Report Agent\n                agent = ReportAgent(\n                    graph_id=graph_id,\n                    simulation_id=simulation_id,\n                    simulation_requirement=simulation_requirement\n                )\n                \n                # 进度回调\n                def progress_callback(stage, progress, message):\n                    task_manager.update_task(\n                        task_id,\n                        progress=progress,\n                        message=f\"[{stage}] {message}\"\n                    )\n                \n                # 生成报告（传入预先生成的 report_id）\n                report = agent.generate_report(\n                    progress_callback=progress_callback,\n                    report_id=report_id\n                )\n                \n                # 保存报告\n                ReportManager.save_report(report)\n                \n                if report.status == ReportStatus.COMPLETED:\n                    task_manager.complete_task(\n                        task_id,\n                        result={\n                            \"report_id\": report.report_id,\n                            \"simulation_id\": simulation_id,\n                            \"status\": \"completed\"\n                        }\n                    )\n                else:\n                    task_manager.fail_task(task_id, report.error or \"报告生成失败\")\n                \n            except Exception as e:\n                logger.error(f\"报告生成失败: {str(e)}\")\n                task_manager.fail_task(task_id, str(e))\n        \n        # 启动后台线程\n        thread = threading.Thread(target=run_generate, daemon=True)\n        thread.start()\n        \n        return jsonify({\n            \"success\": True,\n            \"data\": {\n                \"simulation_id\": simulation_id,\n                \"report_id\": report_id,\n                \"task_id\": task_id,\n                \"status\": \"generating\",\n                \"message\": \"报告生成任务已启动，请通过 /api/report/generate/status 查询进度\",\n                \"already_generated\": False\n            }\n        })\n        \n    except Exception as e:\n        logger.error(f\"启动报告生成任务失败: {str(e)}\")\n        return jsonify({\n            \"success\": False,\n            \"error\": str(e),\n            \"traceback\": traceback.format_exc()\n        }), 500\n\n\n@report_bp.route('/generate/status', methods=['POST'])\ndef get_generate_status():\n    \"\"\"\n    查询报告生成任务进度\n    \n    请求（JSON）：\n        {\n            \"task_id\": \"task_xxxx\",         // 可选，generate返回的task_id\n            \"simulation_id\": \"sim_xxxx\"     // 可选，模拟ID\n        }\n    \n    返回：\n        {\n            \"success\": true,\n            \"data\": {\n                \"task_id\": \"task_xxxx\",\n                \"status\": \"processing|completed|failed\",\n                \"progress\": 45,\n                \"message\": \"...\"\n            }\n        }\n    \"\"\"\n    try:\n        data = request.get_json() or {}\n        \n        task_id = data.get('task_id')\n        simulation_id = data.get('simulation_id')\n        \n        # 如果提供了simulation_id，先检查是否已有完成的报告\n        if simulation_id:\n            existing_report = ReportManager.get_report_by_simulation(simulation_id)\n            if existing_report and existing_report.status == ReportStatus.COMPLETED:\n                return jsonify({\n                    \"success\": True,\n                    \"data\": {\n                        \"simulation_id\": simulation_id,\n                        \"report_id\": existing_report.report_id,\n                        \"status\": \"completed\",\n                        \"progress\": 100,\n                        \"message\": \"报告已生成\",\n                        \"already_completed\": True\n                    }\n                })\n        \n        if not task_id:\n            return jsonify({\n                \"success\": False,\n                \"error\": \"请提供 task_id 或 simulation_id\"\n            }), 400\n        \n        task_manager = TaskManager()\n        task = task_manager.get_task(task_id)\n        \n        if not task:\n            return jsonify({\n                \"success\": False,\n                \"error\": f\"任务不存在: {task_id}\"\n            }), 404\n        \n        return jsonify({\n            \"success\": True,\n            \"data\": task.to_dict()\n        })\n        \n    except Exception as e:\n        logger.error(f\"查询任务状态失败: {str(e)}\")\n        return jsonify({\n            \"success\": False,\n            \"error\": str(e)\n        }), 500\n\n\n# ============== 报告获取接口 ==============\n\n@report_bp.route('/<report_id>', methods=['GET'])\ndef get_report(report_id: str):\n    \"\"\"\n    获取报告详情\n    \n    返回：\n        {\n            \"success\": true,\n            \"data\": {\n                \"report_id\": \"report_xxxx\",\n                \"simulation_id\": \"sim_xxxx\",\n                \"status\": \"completed\",\n                \"outline\": {...},\n                \"markdown_content\": \"...\",\n                \"created_at\": \"...\",\n                \"completed_at\": \"...\"\n            }\n        }\n    \"\"\"\n    try:\n        report = ReportManager.get_report(report_id)\n        \n        if not report:\n            return jsonify({\n                \"success\": False,\n                \"error\": f\"报告不存在: {report_id}\"\n            }), 404\n        \n        return jsonify({\n            \"success\": True,\n            \"data\": report.to_dict()\n        })\n        \n    except Exception as e:\n        logger.error(f\"获取报告失败: {str(e)}\")\n        return jsonify({\n            \"success\": False,\n            \"error\": str(e),\n            \"traceback\": traceback.format_exc()\n        }), 500\n\n\n@report_bp.route('/by-simulation/<simulation_id>', methods=['GET'])\ndef get_report_by_simulation(simulation_id: str):\n    \"\"\"\n    根据模拟ID获取报告\n    \n    返回：\n        {\n            \"success\": true,\n            \"data\": {\n                \"report_id\": \"report_xxxx\",\n                ...\n            }\n        }\n    \"\"\"\n    try:\n        report = ReportManager.get_report_by_simulation(simulation_id)\n        \n        if not report:\n            return jsonify({\n                \"success\": False,\n                \"error\": f\"该模拟暂无报告: {simulation_id}\",\n                \"has_report\": False\n            }), 404\n        \n        return jsonify({\n            \"success\": True,\n            \"data\": report.to_dict(),\n            \"has_report\": True\n        })\n        \n    except Exception as e:\n        logger.error(f\"获取报告失败: {str(e)}\")\n        return jsonify({\n            \"success\": False,\n            \"error\": str(e),\n            \"traceback\": traceback.format_exc()\n        }), 500\n\n\n@report_bp.route('/list', methods=['GET'])\ndef list_reports():\n    \"\"\"\n    列出所有报告\n    \n    Query参数：\n        simulation_id: 按模拟ID过滤（可选）\n        limit: 返回数量限制（默认50）\n    \n    返回：\n        {\n            \"success\": true,\n            \"data\": [...],\n            \"count\": 10\n        }\n    \"\"\"\n    try:\n        simulation_id = request.args.get('simulation_id')\n        limit = request.args.get('limit', 50, type=int)\n        \n        reports = ReportManager.list_reports(\n            simulation_id=simulation_id,\n            limit=limit\n        )\n        \n        return jsonify({\n            \"success\": True,\n            \"data\": [r.to_dict() for r in reports],\n            \"count\": len(reports)\n        })\n        \n    except Exception as e:\n        logger.error(f\"列出报告失败: {str(e)}\")\n        return jsonify({\n            \"success\": False,\n            \"error\": str(e),\n            \"traceback\": traceback.format_exc()\n        }), 500\n\n\n@report_bp.route('/<report_id>/download', methods=['GET'])\ndef download_report(report_id: str):\n    \"\"\"\n    下载报告（Markdown格式）\n    \n    返回Markdown文件\n    \"\"\"\n    try:\n        report = ReportManager.get_report(report_id)\n        \n        if not report:\n            return jsonify({\n                \"success\": False,\n                \"error\": f\"报告不存在: {report_id}\"\n            }), 404\n        \n        md_path = ReportManager._get_report_markdown_path(report_id)\n        \n        if not os.path.exists(md_path):\n            # 如果MD文件不存在，生成一个临时文件\n            import tempfile\n            with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:\n                f.write(report.markdown_content)\n                temp_path = f.name\n            \n            return send_file(\n                temp_path,\n                as_attachment=True,\n                download_name=f\"{report_id}.md\"\n            )\n        \n        return send_file(\n            md_path,\n            as_attachment=True,\n            download_name=f\"{report_id}.md\"\n        )\n        \n    except Exception as e:\n        logger.error(f\"下载报告失败: {str(e)}\")\n        return jsonify({\n            \"success\": False,\n            \"error\": str(e),\n            \"traceback\": traceback.format_exc()\n        }), 500\n\n\n@report_bp.route('/<report_id>', methods=['DELETE'])\ndef delete_report(report_id: str):\n    \"\"\"删除报告\"\"\"\n    try:\n        success = ReportManager.delete_report(report_id)\n        \n        if not success:\n            return jsonify({\n                \"success\": False,\n                \"error\": f\"报告不存在: {report_id}\"\n            }), 404\n        \n        return jsonify({\n            \"success\": True,\n            \"message\": f\"报告已删除: {report_id}\"\n        })\n        \n    except Exception as e:\n        logger.error(f\"删除报告失败: {str(e)}\")\n        return jsonify({\n            \"success\": False,\n            \"error\": str(e),\n            \"traceback\": traceback.format_exc()\n        }), 500\n\n\n# ============== Report Agent对话接口 ==============\n\n@report_bp.route('/chat', methods=['POST'])\ndef chat_with_report_agent():\n    \"\"\"\n    与Report Agent对话\n    \n    Report Agent可以在对话中自主调用检索工具来回答问题\n    \n    请求（JSON）：\n        {\n            \"simulation_id\": \"sim_xxxx\",        // 必填，模拟ID\n            \"message\": \"请解释一下舆情走向\",    // 必填，用户消息\n            \"chat_history\": [                   // 可选，对话历史\n                {\"role\": \"user\", \"content\": \"...\"},\n                {\"role\": \"assistant\", \"content\": \"...\"}\n            ]\n        }\n    \n    返回：\n        {\n            \"success\": true,\n            \"data\": {\n                \"response\": \"Agent回复...\",\n                \"tool_calls\": [调用的工具列表],\n                \"sources\": [信息来源]\n            }\n        }\n    \"\"\"\n    try:\n        data = request.get_json() or {}\n        \n        simulation_id = data.get('simulation_id')\n        message = data.get('message')\n        chat_history = data.get('chat_history', [])\n        \n        if not simulation_id:\n            return jsonify({\n                \"success\": False,\n                \"error\": \"请提供 simulation_id\"\n            }), 400\n        \n        if not message:\n            return jsonify({\n                \"success\": False,\n                \"error\": \"请提供 message\"\n            }), 400\n        \n        # 获取模拟和项目信息\n        manager = SimulationManager()\n        state = manager.get_simulation(simulation_id)\n        \n        if not state:\n            return jsonify({\n                \"success\": False,\n                \"error\": f\"模拟不存在: {simulation_id}\"\n            }), 404\n        \n        project = ProjectManager.get_project(state.project_id)\n        if not project:\n            return jsonify({\n                \"success\": False,\n                \"error\": f\"项目不存在: {state.project_id}\"\n            }), 404\n        \n        graph_id = state.graph_id or project.graph_id\n        if not graph_id:\n            return jsonify({\n                \"success\": False,\n                \"error\": \"缺少图谱ID\"\n            }), 400\n        \n        simulation_requirement = project.simulation_requirement or \"\"\n        \n        # 创建Agent并进行对话\n        agent = ReportAgent(\n            graph_id=graph_id,\n            simulation_id=simulation_id,\n            simulation_requirement=simulation_requirement\n        )\n        \n        result = agent.chat(message=message, chat_history=chat_history)\n        \n        return jsonify({\n            \"success\": True,\n            \"data\": result\n        })\n        \n    except Exception as e:\n        logger.error(f\"对话失败: {str(e)}\")\n        return jsonify({\n            \"success\": False,\n            \"error\": str(e),\n            \"traceback\": traceback.format_exc()\n        }), 500\n\n\n# ============== 报告进度与分章节接口 ==============\n\n@report_bp.route('/<report_id>/progress', methods=['GET'])\ndef get_report_progress(report_id: str):\n    \"\"\"\n    获取报告生成进度（实时）\n    \n    返回：\n        {\n            \"success\": true,\n            \"data\": {\n                \"status\": \"generating\",\n                \"progress\": 45,\n                \"message\": \"正在生成章节: 关键发现\",\n                \"current_section\": \"关键发现\",\n                \"completed_sections\": [\"执行摘要\", \"模拟背景\"],\n                \"updated_at\": \"2025-12-09T...\"\n            }\n        }\n    \"\"\"\n    try:\n        progress = ReportManager.get_progress(report_id)\n        \n        if not progress:\n            return jsonify({\n                \"success\": False,\n                \"error\": f\"报告不存在或进度信息不可用: {report_id}\"\n            }), 404\n        \n        return jsonify({\n            \"success\": True,\n            \"data\": progress\n        })\n        \n    except Exception as e:\n        logger.error(f\"获取报告进度失败: {str(e)}\")\n        return jsonify({\n            \"success\": False,\n            \"error\": str(e),\n            \"traceback\": traceback.format_exc()\n        }), 500\n\n\n@report_bp.route('/<report_id>/sections', methods=['GET'])\ndef get_report_sections(report_id: str):\n    \"\"\"\n    获取已生成的章节列表（分章节输出）\n    \n    前端可以轮询此接口获取已生成的章节内容，无需等待整个报告完成\n    \n    返回：\n        {\n            \"success\": true,\n            \"data\": {\n                \"report_id\": \"report_xxxx\",\n                \"sections\": [\n                    {\n                        \"filename\": \"section_01.md\",\n                        \"section_index\": 1,\n                        \"content\": \"## 执行摘要\\\\n\\\\n...\"\n                    },\n                    ...\n                ],\n                \"total_sections\": 3,\n                \"is_complete\": false\n            }\n        }\n    \"\"\"\n    try:\n        sections = ReportManager.get_generated_sections(report_id)\n        \n        # 获取报告状态\n        report = ReportManager.get_report(report_id)\n        is_complete = report is not None and report.status == ReportStatus.COMPLETED\n        \n        return jsonify({\n            \"success\": True,\n            \"data\": {\n                \"report_id\": report_id,\n                \"sections\": sections,\n                \"total_sections\": len(sections),\n                \"is_complete\": is_complete\n            }\n        })\n        \n    except Exception as e:\n        logger.error(f\"获取章节列表失败: {str(e)}\")\n        return jsonify({\n            \"success\": False,\n            \"error\": str(e),\n            \"traceback\": traceback.format_exc()\n        }), 500\n\n\n@report_bp.route('/<report_id>/section/<int:section_index>', methods=['GET'])\ndef get_single_section(report_id: str, section_index: int):\n    \"\"\"\n    获取单个章节内容\n    \n    返回：\n        {\n            \"success\": true,\n            \"data\": {\n                \"filename\": \"section_01.md\",\n                \"content\": \"## 执行摘要\\\\n\\\\n...\"\n            }\n        }\n    \"\"\"\n    try:\n        section_path = ReportManager._get_section_path(report_id, section_index)\n        \n        if not os.path.exists(section_path):\n            return jsonify({\n                \"success\": False,\n                \"error\": f\"章节不存在: section_{section_index:02d}.md\"\n            }), 404\n        \n        with open(section_path, 'r', encoding='utf-8') as f:\n            content = f.read()\n        \n        return jsonify({\n            \"success\": True,\n            \"data\": {\n                \"filename\": f\"section_{section_index:02d}.md\",\n                \"section_index\": section_index,\n                \"content\": content\n            }\n        })\n        \n    except Exception as e:\n        logger.error(f\"获取章节内容失败: {str(e)}\")\n        return jsonify({\n            \"success\": False,\n            \"error\": str(e),\n            \"traceback\": traceback.format_exc()\n        }), 500\n\n\n# ============== 报告状态检查接口 ==============\n\n@report_bp.route('/check/<simulation_id>', methods=['GET'])\ndef check_report_status(simulation_id: str):\n    \"\"\"\n    检查模拟是否有报告，以及报告状态\n    \n    用于前端判断是否解锁Interview功能\n    \n    返回：\n        {\n            \"success\": true,\n            \"data\": {\n                \"simulation_id\": \"sim_xxxx\",\n                \"has_report\": true,\n                \"report_status\": \"completed\",\n                \"report_id\": \"report_xxxx\",\n                \"interview_unlocked\": true\n            }\n        }\n    \"\"\"\n    try:\n        report = ReportManager.get_report_by_simulation(simulation_id)\n        \n        has_report = report is not None\n        report_status = report.status.value if report else None\n        report_id = report.report_id if report else None\n        \n        # 只有报告完成后才解锁interview\n        interview_unlocked = has_report and report.status == ReportStatus.COMPLETED\n        \n        return jsonify({\n            \"success\": True,\n            \"data\": {\n                \"simulation_id\": simulation_id,\n                \"has_report\": has_report,\n                \"report_status\": report_status,\n                \"report_id\": report_id,\n                \"interview_unlocked\": interview_unlocked\n            }\n        })\n        \n    except Exception as e:\n        logger.error(f\"检查报告状态失败: {str(e)}\")\n        return jsonify({\n            \"success\": False,\n            \"error\": str(e),\n            \"traceback\": traceback.format_exc()\n        }), 500\n\n\n# ============== Agent 日志接口 ==============\n\n@report_bp.route('/<report_id>/agent-log', methods=['GET'])\ndef get_agent_log(report_id: str):\n    \"\"\"\n    获取 Report Agent 的详细执行日志\n    \n    实时获取报告生成过程中的每一步动作，包括：\n    - 报告开始、规划开始/完成\n    - 每个章节的开始、工具调用、LLM响应、完成\n    - 报告完成或失败\n    \n    Query参数：\n        from_line: 从第几行开始读取（可选，默认0，用于增量获取）\n    \n    返回：\n        {\n            \"success\": true,\n            \"data\": {\n                \"logs\": [\n                    {\n                        \"timestamp\": \"2025-12-13T...\",\n                        \"elapsed_seconds\": 12.5,\n                        \"report_id\": \"report_xxxx\",\n                        \"action\": \"tool_call\",\n                        \"stage\": \"generating\",\n                        \"section_title\": \"执行摘要\",\n                        \"section_index\": 1,\n                        \"details\": {\n                            \"tool_name\": \"insight_forge\",\n                            \"parameters\": {...},\n                            ...\n                        }\n                    },\n                    ...\n                ],\n                \"total_lines\": 25,\n                \"from_line\": 0,\n                \"has_more\": false\n            }\n        }\n    \"\"\"\n    try:\n        from_line = request.args.get('from_line', 0, type=int)\n        \n        log_data = ReportManager.get_agent_log(report_id, from_line=from_line)\n        \n        return jsonify({\n            \"success\": True,\n            \"data\": log_data\n        })\n        \n    except Exception as e:\n        logger.error(f\"获取Agent日志失败: {str(e)}\")\n        return jsonify({\n            \"success\": False,\n            \"error\": str(e),\n            \"traceback\": traceback.format_exc()\n        }), 500\n\n\n@report_bp.route('/<report_id>/agent-log/stream', methods=['GET'])\ndef stream_agent_log(report_id: str):\n    \"\"\"\n    获取完整的 Agent 日志（一次性获取全部）\n    \n    返回：\n        {\n            \"success\": true,\n            \"data\": {\n                \"logs\": [...],\n                \"count\": 25\n            }\n        }\n    \"\"\"\n    try:\n        logs = ReportManager.get_agent_log_stream(report_id)\n        \n        return jsonify({\n            \"success\": True,\n            \"data\": {\n                \"logs\": logs,\n                \"count\": len(logs)\n            }\n        })\n        \n    except Exception as e:\n        logger.error(f\"获取Agent日志失败: {str(e)}\")\n        return jsonify({\n            \"success\": False,\n            \"error\": str(e),\n            \"traceback\": traceback.format_exc()\n        }), 500\n\n\n# ============== 控制台日志接口 ==============\n\n@report_bp.route('/<report_id>/console-log', methods=['GET'])\ndef get_console_log(report_id: str):\n    \"\"\"\n    获取 Report Agent 的控制台输出日志\n    \n    实时获取报告生成过程中的控制台输出（INFO、WARNING等），\n    这与 agent-log 接口返回的结构化 JSON 日志不同，\n    是纯文本格式的控制台风格日志。\n    \n    Query参数：\n        from_line: 从第几行开始读取（可选，默认0，用于增量获取）\n    \n    返回：\n        {\n            \"success\": true,\n            \"data\": {\n                \"logs\": [\n                    \"[19:46:14] INFO: 搜索完成: 找到 15 条相关事实\",\n                    \"[19:46:14] INFO: 图谱搜索: graph_id=xxx, query=...\",\n                    ...\n                ],\n                \"total_lines\": 100,\n                \"from_line\": 0,\n                \"has_more\": false\n            }\n        }\n    \"\"\"\n    try:\n        from_line = request.args.get('from_line', 0, type=int)\n        \n        log_data = ReportManager.get_console_log(report_id, from_line=from_line)\n        \n        return jsonify({\n            \"success\": True,\n            \"data\": log_data\n        })\n        \n    except Exception as e:\n        logger.error(f\"获取控制台日志失败: {str(e)}\")\n        return jsonify({\n            \"success\": False,\n            \"error\": str(e),\n            \"traceback\": traceback.format_exc()\n        }), 500\n\n\n@report_bp.route('/<report_id>/console-log/stream', methods=['GET'])\ndef stream_console_log(report_id: str):\n    \"\"\"\n    获取完整的控制台日志（一次性获取全部）\n    \n    返回：\n        {\n            \"success\": true,\n            \"data\": {\n                \"logs\": [...],\n                \"count\": 100\n            }\n        }\n    \"\"\"\n    try:\n        logs = ReportManager.get_console_log_stream(report_id)\n        \n        return jsonify({\n            \"success\": True,\n            \"data\": {\n                \"logs\": logs,\n                \"count\": len(logs)\n            }\n        })\n        \n    except Exception as e:\n        logger.error(f\"获取控制台日志失败: {str(e)}\")\n        return jsonify({\n            \"success\": False,\n            \"error\": str(e),\n            \"traceback\": traceback.format_exc()\n        }), 500\n\n\n# ============== 工具调用接口（供调试使用）==============\n\n@report_bp.route('/tools/search', methods=['POST'])\ndef search_graph_tool():\n    \"\"\"\n    图谱搜索工具接口（供调试使用）\n    \n    请求（JSON）：\n        {\n            \"graph_id\": \"mirofish_xxxx\",\n            \"query\": \"搜索查询\",\n            \"limit\": 10\n        }\n    \"\"\"\n    try:\n        data = request.get_json() or {}\n        \n        graph_id = data.get('graph_id')\n        query = data.get('query')\n        limit = data.get('limit', 10)\n        \n        if not graph_id or not query:\n            return jsonify({\n                \"success\": False,\n                \"error\": \"请提供 graph_id 和 query\"\n            }), 400\n        \n        from ..services.zep_tools import ZepToolsService\n        \n        tools = ZepToolsService()\n        result = tools.search_graph(\n            graph_id=graph_id,\n            query=query,\n            limit=limit\n        )\n        \n        return jsonify({\n            \"success\": True,\n            \"data\": result.to_dict()\n        })\n        \n    except Exception as e:\n        logger.error(f\"图谱搜索失败: {str(e)}\")\n        return jsonify({\n            \"success\": False,\n            \"error\": str(e),\n            \"traceback\": traceback.format_exc()\n        }), 500\n\n\n@report_bp.route('/tools/statistics', methods=['POST'])\ndef get_graph_statistics_tool():\n    \"\"\"\n    图谱统计工具接口（供调试使用）\n    \n    请求（JSON）：\n        {\n            \"graph_id\": \"mirofish_xxxx\"\n        }\n    \"\"\"\n    try:\n        data = request.get_json() or {}\n        \n        graph_id = data.get('graph_id')\n        \n        if not graph_id:\n            return jsonify({\n                \"success\": False,\n                \"error\": \"请提供 graph_id\"\n            }), 400\n        \n        from ..services.zep_tools import ZepToolsService\n        \n        tools = ZepToolsService()\n        result = tools.get_graph_statistics(graph_id)\n        \n        return jsonify({\n            \"success\": True,\n            \"data\": result\n        })\n        \n    except Exception as e:\n        logger.error(f\"获取图谱统计失败: {str(e)}\")\n        return jsonify({\n            \"success\": False,\n            \"error\": str(e),\n            \"traceback\": traceback.format_exc()\n        }), 500\n"
  },
  {
    "path": "backend/app/api/simulation.py",
    "content": "\"\"\"\n模拟相关API路由\nStep2: Zep实体读取与过滤、OASIS模拟准备与运行（全程自动化）\n\"\"\"\n\nimport os\nimport traceback\nfrom flask import request, jsonify, send_file\n\nfrom . import simulation_bp\nfrom ..config import Config\nfrom ..services.zep_entity_reader import ZepEntityReader\nfrom ..services.oasis_profile_generator import OasisProfileGenerator\nfrom ..services.simulation_manager import SimulationManager, SimulationStatus\nfrom ..services.simulation_runner import SimulationRunner, RunnerStatus\nfrom ..utils.logger import get_logger\nfrom ..models.project import ProjectManager\n\nlogger = get_logger('mirofish.api.simulation')\n\n\n# Interview prompt 优化前缀\n# 添加此前缀可以避免Agent调用工具，直接用文本回复\nINTERVIEW_PROMPT_PREFIX = \"结合你的人设、所有的过往记忆与行动，不调用任何工具直接用文本回复我：\"\n\n\ndef optimize_interview_prompt(prompt: str) -> str:\n    \"\"\"\n    优化Interview提问，添加前缀避免Agent调用工具\n    \n    Args:\n        prompt: 原始提问\n        \n    Returns:\n        优化后的提问\n    \"\"\"\n    if not prompt:\n        return prompt\n    # 避免重复添加前缀\n    if prompt.startswith(INTERVIEW_PROMPT_PREFIX):\n        return prompt\n    return f\"{INTERVIEW_PROMPT_PREFIX}{prompt}\"\n\n\n# ============== 实体读取接口 ==============\n\n@simulation_bp.route('/entities/<graph_id>', methods=['GET'])\ndef get_graph_entities(graph_id: str):\n    \"\"\"\n    获取图谱中的所有实体（已过滤）\n    \n    只返回符合预定义实体类型的节点（Labels不只是Entity的节点）\n    \n    Query参数：\n        entity_types: 逗号分隔的实体类型列表（可选，用于进一步过滤）\n        enrich: 是否获取相关边信息（默认true）\n    \"\"\"\n    try:\n        if not Config.ZEP_API_KEY:\n            return jsonify({\n                \"success\": False,\n                \"error\": \"ZEP_API_KEY未配置\"\n            }), 500\n        \n        entity_types_str = request.args.get('entity_types', '')\n        entity_types = [t.strip() for t in entity_types_str.split(',') if t.strip()] if entity_types_str else None\n        enrich = request.args.get('enrich', 'true').lower() == 'true'\n        \n        logger.info(f\"获取图谱实体: graph_id={graph_id}, entity_types={entity_types}, enrich={enrich}\")\n        \n        reader = ZepEntityReader()\n        result = reader.filter_defined_entities(\n            graph_id=graph_id,\n            defined_entity_types=entity_types,\n            enrich_with_edges=enrich\n        )\n        \n        return jsonify({\n            \"success\": True,\n            \"data\": result.to_dict()\n        })\n        \n    except Exception as e:\n        logger.error(f\"获取图谱实体失败: {str(e)}\")\n        return jsonify({\n            \"success\": False,\n            \"error\": str(e),\n            \"traceback\": traceback.format_exc()\n        }), 500\n\n\n@simulation_bp.route('/entities/<graph_id>/<entity_uuid>', methods=['GET'])\ndef get_entity_detail(graph_id: str, entity_uuid: str):\n    \"\"\"获取单个实体的详细信息\"\"\"\n    try:\n        if not Config.ZEP_API_KEY:\n            return jsonify({\n                \"success\": False,\n                \"error\": \"ZEP_API_KEY未配置\"\n            }), 500\n        \n        reader = ZepEntityReader()\n        entity = reader.get_entity_with_context(graph_id, entity_uuid)\n        \n        if not entity:\n            return jsonify({\n                \"success\": False,\n                \"error\": f\"实体不存在: {entity_uuid}\"\n            }), 404\n        \n        return jsonify({\n            \"success\": True,\n            \"data\": entity.to_dict()\n        })\n        \n    except Exception as e:\n        logger.error(f\"获取实体详情失败: {str(e)}\")\n        return jsonify({\n            \"success\": False,\n            \"error\": str(e),\n            \"traceback\": traceback.format_exc()\n        }), 500\n\n\n@simulation_bp.route('/entities/<graph_id>/by-type/<entity_type>', methods=['GET'])\ndef get_entities_by_type(graph_id: str, entity_type: str):\n    \"\"\"获取指定类型的所有实体\"\"\"\n    try:\n        if not Config.ZEP_API_KEY:\n            return jsonify({\n                \"success\": False,\n                \"error\": \"ZEP_API_KEY未配置\"\n            }), 500\n        \n        enrich = request.args.get('enrich', 'true').lower() == 'true'\n        \n        reader = ZepEntityReader()\n        entities = reader.get_entities_by_type(\n            graph_id=graph_id,\n            entity_type=entity_type,\n            enrich_with_edges=enrich\n        )\n        \n        return jsonify({\n            \"success\": True,\n            \"data\": {\n                \"entity_type\": entity_type,\n                \"count\": len(entities),\n                \"entities\": [e.to_dict() for e in entities]\n            }\n        })\n        \n    except Exception as e:\n        logger.error(f\"获取实体失败: {str(e)}\")\n        return jsonify({\n            \"success\": False,\n            \"error\": str(e),\n            \"traceback\": traceback.format_exc()\n        }), 500\n\n\n# ============== 模拟管理接口 ==============\n\n@simulation_bp.route('/create', methods=['POST'])\ndef create_simulation():\n    \"\"\"\n    创建新的模拟\n    \n    注意：max_rounds等参数由LLM智能生成，无需手动设置\n    \n    请求（JSON）：\n        {\n            \"project_id\": \"proj_xxxx\",      // 必填\n            \"graph_id\": \"mirofish_xxxx\",    // 可选，如不提供则从project获取\n            \"enable_twitter\": true,          // 可选，默认true\n            \"enable_reddit\": true            // 可选，默认true\n        }\n    \n    返回：\n        {\n            \"success\": true,\n            \"data\": {\n                \"simulation_id\": \"sim_xxxx\",\n                \"project_id\": \"proj_xxxx\",\n                \"graph_id\": \"mirofish_xxxx\",\n                \"status\": \"created\",\n                \"enable_twitter\": true,\n                \"enable_reddit\": true,\n                \"created_at\": \"2025-12-01T10:00:00\"\n            }\n        }\n    \"\"\"\n    try:\n        data = request.get_json() or {}\n        \n        project_id = data.get('project_id')\n        if not project_id:\n            return jsonify({\n                \"success\": False,\n                \"error\": \"请提供 project_id\"\n            }), 400\n        \n        project = ProjectManager.get_project(project_id)\n        if not project:\n            return jsonify({\n                \"success\": False,\n                \"error\": f\"项目不存在: {project_id}\"\n            }), 404\n        \n        graph_id = data.get('graph_id') or project.graph_id\n        if not graph_id:\n            return jsonify({\n                \"success\": False,\n                \"error\": \"项目尚未构建图谱，请先调用 /api/graph/build\"\n            }), 400\n        \n        manager = SimulationManager()\n        state = manager.create_simulation(\n            project_id=project_id,\n            graph_id=graph_id,\n            enable_twitter=data.get('enable_twitter', True),\n            enable_reddit=data.get('enable_reddit', True),\n        )\n        \n        return jsonify({\n            \"success\": True,\n            \"data\": state.to_dict()\n        })\n        \n    except Exception as e:\n        logger.error(f\"创建模拟失败: {str(e)}\")\n        return jsonify({\n            \"success\": False,\n            \"error\": str(e),\n            \"traceback\": traceback.format_exc()\n        }), 500\n\n\ndef _check_simulation_prepared(simulation_id: str) -> tuple:\n    \"\"\"\n    检查模拟是否已经准备完成\n    \n    检查条件：\n    1. state.json 存在且 status 为 \"ready\"\n    2. 必要文件存在：reddit_profiles.json, twitter_profiles.csv, simulation_config.json\n    \n    注意：运行脚本(run_*.py)保留在 backend/scripts/ 目录，不再复制到模拟目录\n    \n    Args:\n        simulation_id: 模拟ID\n        \n    Returns:\n        (is_prepared: bool, info: dict)\n    \"\"\"\n    import os\n    from ..config import Config\n    \n    simulation_dir = os.path.join(Config.OASIS_SIMULATION_DATA_DIR, simulation_id)\n    \n    # 检查目录是否存在\n    if not os.path.exists(simulation_dir):\n        return False, {\"reason\": \"模拟目录不存在\"}\n    \n    # 必要文件列表（不包括脚本，脚本位于 backend/scripts/）\n    required_files = [\n        \"state.json\",\n        \"simulation_config.json\",\n        \"reddit_profiles.json\",\n        \"twitter_profiles.csv\"\n    ]\n    \n    # 检查文件是否存在\n    existing_files = []\n    missing_files = []\n    for f in required_files:\n        file_path = os.path.join(simulation_dir, f)\n        if os.path.exists(file_path):\n            existing_files.append(f)\n        else:\n            missing_files.append(f)\n    \n    if missing_files:\n        return False, {\n            \"reason\": \"缺少必要文件\",\n            \"missing_files\": missing_files,\n            \"existing_files\": existing_files\n        }\n    \n    # 检查state.json中的状态\n    state_file = os.path.join(simulation_dir, \"state.json\")\n    try:\n        import json\n        with open(state_file, 'r', encoding='utf-8') as f:\n            state_data = json.load(f)\n        \n        status = state_data.get(\"status\", \"\")\n        config_generated = state_data.get(\"config_generated\", False)\n        \n        # 详细日志\n        logger.debug(f\"检测模拟准备状态: {simulation_id}, status={status}, config_generated={config_generated}\")\n        \n        # 如果 config_generated=True 且文件存在，认为准备完成\n        # 以下状态都说明准备工作已完成：\n        # - ready: 准备完成，可以运行\n        # - preparing: 如果 config_generated=True 说明已完成\n        # - running: 正在运行，说明准备早就完成了\n        # - completed: 运行完成，说明准备早就完成了\n        # - stopped: 已停止，说明准备早就完成了\n        # - failed: 运行失败（但准备是完成的）\n        prepared_statuses = [\"ready\", \"preparing\", \"running\", \"completed\", \"stopped\", \"failed\"]\n        if status in prepared_statuses and config_generated:\n            # 获取文件统计信息\n            profiles_file = os.path.join(simulation_dir, \"reddit_profiles.json\")\n            config_file = os.path.join(simulation_dir, \"simulation_config.json\")\n            \n            profiles_count = 0\n            if os.path.exists(profiles_file):\n                with open(profiles_file, 'r', encoding='utf-8') as f:\n                    profiles_data = json.load(f)\n                    profiles_count = len(profiles_data) if isinstance(profiles_data, list) else 0\n            \n            # 如果状态是preparing但文件已完成，自动更新状态为ready\n            if status == \"preparing\":\n                try:\n                    state_data[\"status\"] = \"ready\"\n                    from datetime import datetime\n                    state_data[\"updated_at\"] = datetime.now().isoformat()\n                    with open(state_file, 'w', encoding='utf-8') as f:\n                        json.dump(state_data, f, ensure_ascii=False, indent=2)\n                    logger.info(f\"自动更新模拟状态: {simulation_id} preparing -> ready\")\n                    status = \"ready\"\n                except Exception as e:\n                    logger.warning(f\"自动更新状态失败: {e}\")\n            \n            logger.info(f\"模拟 {simulation_id} 检测结果: 已准备完成 (status={status}, config_generated={config_generated})\")\n            return True, {\n                \"status\": status,\n                \"entities_count\": state_data.get(\"entities_count\", 0),\n                \"profiles_count\": profiles_count,\n                \"entity_types\": state_data.get(\"entity_types\", []),\n                \"config_generated\": config_generated,\n                \"created_at\": state_data.get(\"created_at\"),\n                \"updated_at\": state_data.get(\"updated_at\"),\n                \"existing_files\": existing_files\n            }\n        else:\n            logger.warning(f\"模拟 {simulation_id} 检测结果: 未准备完成 (status={status}, config_generated={config_generated})\")\n            return False, {\n                \"reason\": f\"状态不在已准备列表中或config_generated为false: status={status}, config_generated={config_generated}\",\n                \"status\": status,\n                \"config_generated\": config_generated\n            }\n            \n    except Exception as e:\n        return False, {\"reason\": f\"读取状态文件失败: {str(e)}\"}\n\n\n@simulation_bp.route('/prepare', methods=['POST'])\ndef prepare_simulation():\n    \"\"\"\n    准备模拟环境（异步任务，LLM智能生成所有参数）\n    \n    这是一个耗时操作，接口会立即返回task_id，\n    使用 GET /api/simulation/prepare/status 查询进度\n    \n    特性：\n    - 自动检测已完成的准备工作，避免重复生成\n    - 如果已准备完成，直接返回已有结果\n    - 支持强制重新生成（force_regenerate=true）\n    \n    步骤：\n    1. 检查是否已有完成的准备工作\n    2. 从Zep图谱读取并过滤实体\n    3. 为每个实体生成OASIS Agent Profile（带重试机制）\n    4. LLM智能生成模拟配置（带重试机制）\n    5. 保存配置文件和预设脚本\n    \n    请求（JSON）：\n        {\n            \"simulation_id\": \"sim_xxxx\",                   // 必填，模拟ID\n            \"entity_types\": [\"Student\", \"PublicFigure\"],  // 可选，指定实体类型\n            \"use_llm_for_profiles\": true,                 // 可选，是否用LLM生成人设\n            \"parallel_profile_count\": 5,                  // 可选，并行生成人设数量，默认5\n            \"force_regenerate\": false                     // 可选，强制重新生成，默认false\n        }\n    \n    返回：\n        {\n            \"success\": true,\n            \"data\": {\n                \"simulation_id\": \"sim_xxxx\",\n                \"task_id\": \"task_xxxx\",           // 新任务时返回\n                \"status\": \"preparing|ready\",\n                \"message\": \"准备任务已启动|已有完成的准备工作\",\n                \"already_prepared\": true|false    // 是否已准备完成\n            }\n        }\n    \"\"\"\n    import threading\n    import os\n    from ..models.task import TaskManager, TaskStatus\n    from ..config import Config\n    \n    try:\n        data = request.get_json() or {}\n        \n        simulation_id = data.get('simulation_id')\n        if not simulation_id:\n            return jsonify({\n                \"success\": False,\n                \"error\": \"请提供 simulation_id\"\n            }), 400\n        \n        manager = SimulationManager()\n        state = manager.get_simulation(simulation_id)\n        \n        if not state:\n            return jsonify({\n                \"success\": False,\n                \"error\": f\"模拟不存在: {simulation_id}\"\n            }), 404\n        \n        # 检查是否强制重新生成\n        force_regenerate = data.get('force_regenerate', False)\n        logger.info(f\"开始处理 /prepare 请求: simulation_id={simulation_id}, force_regenerate={force_regenerate}\")\n        \n        # 检查是否已经准备完成（避免重复生成）\n        if not force_regenerate:\n            logger.debug(f\"检查模拟 {simulation_id} 是否已准备完成...\")\n            is_prepared, prepare_info = _check_simulation_prepared(simulation_id)\n            logger.debug(f\"检查结果: is_prepared={is_prepared}, prepare_info={prepare_info}\")\n            if is_prepared:\n                logger.info(f\"模拟 {simulation_id} 已准备完成，跳过重复生成\")\n                return jsonify({\n                    \"success\": True,\n                    \"data\": {\n                        \"simulation_id\": simulation_id,\n                        \"status\": \"ready\",\n                        \"message\": \"已有完成的准备工作，无需重复生成\",\n                        \"already_prepared\": True,\n                        \"prepare_info\": prepare_info\n                    }\n                })\n            else:\n                logger.info(f\"模拟 {simulation_id} 未准备完成，将启动准备任务\")\n        \n        # 从项目获取必要信息\n        project = ProjectManager.get_project(state.project_id)\n        if not project:\n            return jsonify({\n                \"success\": False,\n                \"error\": f\"项目不存在: {state.project_id}\"\n            }), 404\n        \n        # 获取模拟需求\n        simulation_requirement = project.simulation_requirement or \"\"\n        if not simulation_requirement:\n            return jsonify({\n                \"success\": False,\n                \"error\": \"项目缺少模拟需求描述 (simulation_requirement)\"\n            }), 400\n        \n        # 获取文档文本\n        document_text = ProjectManager.get_extracted_text(state.project_id) or \"\"\n        \n        entity_types_list = data.get('entity_types')\n        use_llm_for_profiles = data.get('use_llm_for_profiles', True)\n        parallel_profile_count = data.get('parallel_profile_count', 5)\n        \n        # ========== 同步获取实体数量（在后台任务启动前） ==========\n        # 这样前端在调用prepare后立即就能获取到预期Agent总数\n        try:\n            logger.info(f\"同步获取实体数量: graph_id={state.graph_id}\")\n            reader = ZepEntityReader()\n            # 快速读取实体（不需要边信息，只统计数量）\n            filtered_preview = reader.filter_defined_entities(\n                graph_id=state.graph_id,\n                defined_entity_types=entity_types_list,\n                enrich_with_edges=False  # 不获取边信息，加快速度\n            )\n            # 保存实体数量到状态（供前端立即获取）\n            state.entities_count = filtered_preview.filtered_count\n            state.entity_types = list(filtered_preview.entity_types)\n            logger.info(f\"预期实体数量: {filtered_preview.filtered_count}, 类型: {filtered_preview.entity_types}\")\n        except Exception as e:\n            logger.warning(f\"同步获取实体数量失败（将在后台任务中重试）: {e}\")\n            # 失败不影响后续流程，后台任务会重新获取\n        \n        # 创建异步任务\n        task_manager = TaskManager()\n        task_id = task_manager.create_task(\n            task_type=\"simulation_prepare\",\n            metadata={\n                \"simulation_id\": simulation_id,\n                \"project_id\": state.project_id\n            }\n        )\n        \n        # 更新模拟状态（包含预先获取的实体数量）\n        state.status = SimulationStatus.PREPARING\n        manager._save_simulation_state(state)\n        \n        # 定义后台任务\n        def run_prepare():\n            try:\n                task_manager.update_task(\n                    task_id,\n                    status=TaskStatus.PROCESSING,\n                    progress=0,\n                    message=\"开始准备模拟环境...\"\n                )\n                \n                # 准备模拟（带进度回调）\n                # 存储阶段进度详情\n                stage_details = {}\n                \n                def progress_callback(stage, progress, message, **kwargs):\n                    # 计算总进度\n                    stage_weights = {\n                        \"reading\": (0, 20),           # 0-20%\n                        \"generating_profiles\": (20, 70),  # 20-70%\n                        \"generating_config\": (70, 90),    # 70-90%\n                        \"copying_scripts\": (90, 100)       # 90-100%\n                    }\n                    \n                    start, end = stage_weights.get(stage, (0, 100))\n                    current_progress = int(start + (end - start) * progress / 100)\n                    \n                    # 构建详细进度信息\n                    stage_names = {\n                        \"reading\": \"读取图谱实体\",\n                        \"generating_profiles\": \"生成Agent人设\",\n                        \"generating_config\": \"生成模拟配置\",\n                        \"copying_scripts\": \"准备模拟脚本\"\n                    }\n                    \n                    stage_index = list(stage_weights.keys()).index(stage) + 1 if stage in stage_weights else 1\n                    total_stages = len(stage_weights)\n                    \n                    # 更新阶段详情\n                    stage_details[stage] = {\n                        \"stage_name\": stage_names.get(stage, stage),\n                        \"stage_progress\": progress,\n                        \"current\": kwargs.get(\"current\", 0),\n                        \"total\": kwargs.get(\"total\", 0),\n                        \"item_name\": kwargs.get(\"item_name\", \"\")\n                    }\n                    \n                    # 构建详细进度信息\n                    detail = stage_details[stage]\n                    progress_detail_data = {\n                        \"current_stage\": stage,\n                        \"current_stage_name\": stage_names.get(stage, stage),\n                        \"stage_index\": stage_index,\n                        \"total_stages\": total_stages,\n                        \"stage_progress\": progress,\n                        \"current_item\": detail[\"current\"],\n                        \"total_items\": detail[\"total\"],\n                        \"item_description\": message\n                    }\n                    \n                    # 构建简洁消息\n                    if detail[\"total\"] > 0:\n                        detailed_message = (\n                            f\"[{stage_index}/{total_stages}] {stage_names.get(stage, stage)}: \"\n                            f\"{detail['current']}/{detail['total']} - {message}\"\n                        )\n                    else:\n                        detailed_message = f\"[{stage_index}/{total_stages}] {stage_names.get(stage, stage)}: {message}\"\n                    \n                    task_manager.update_task(\n                        task_id,\n                        progress=current_progress,\n                        message=detailed_message,\n                        progress_detail=progress_detail_data\n                    )\n                \n                result_state = manager.prepare_simulation(\n                    simulation_id=simulation_id,\n                    simulation_requirement=simulation_requirement,\n                    document_text=document_text,\n                    defined_entity_types=entity_types_list,\n                    use_llm_for_profiles=use_llm_for_profiles,\n                    progress_callback=progress_callback,\n                    parallel_profile_count=parallel_profile_count\n                )\n                \n                # 任务完成\n                task_manager.complete_task(\n                    task_id,\n                    result=result_state.to_simple_dict()\n                )\n                \n            except Exception as e:\n                logger.error(f\"准备模拟失败: {str(e)}\")\n                task_manager.fail_task(task_id, str(e))\n                \n                # 更新模拟状态为失败\n                state = manager.get_simulation(simulation_id)\n                if state:\n                    state.status = SimulationStatus.FAILED\n                    state.error = str(e)\n                    manager._save_simulation_state(state)\n        \n        # 启动后台线程\n        thread = threading.Thread(target=run_prepare, daemon=True)\n        thread.start()\n        \n        return jsonify({\n            \"success\": True,\n            \"data\": {\n                \"simulation_id\": simulation_id,\n                \"task_id\": task_id,\n                \"status\": \"preparing\",\n                \"message\": \"准备任务已启动，请通过 /api/simulation/prepare/status 查询进度\",\n                \"already_prepared\": False,\n                \"expected_entities_count\": state.entities_count,  # 预期的Agent总数\n                \"entity_types\": state.entity_types  # 实体类型列表\n            }\n        })\n        \n    except ValueError as e:\n        return jsonify({\n            \"success\": False,\n            \"error\": str(e)\n        }), 404\n        \n    except Exception as e:\n        logger.error(f\"启动准备任务失败: {str(e)}\")\n        return jsonify({\n            \"success\": False,\n            \"error\": str(e),\n            \"traceback\": traceback.format_exc()\n        }), 500\n\n\n@simulation_bp.route('/prepare/status', methods=['POST'])\ndef get_prepare_status():\n    \"\"\"\n    查询准备任务进度\n    \n    支持两种查询方式：\n    1. 通过task_id查询正在进行的任务进度\n    2. 通过simulation_id检查是否已有完成的准备工作\n    \n    请求（JSON）：\n        {\n            \"task_id\": \"task_xxxx\",          // 可选，prepare返回的task_id\n            \"simulation_id\": \"sim_xxxx\"      // 可选，模拟ID（用于检查已完成的准备）\n        }\n    \n    返回：\n        {\n            \"success\": true,\n            \"data\": {\n                \"task_id\": \"task_xxxx\",\n                \"status\": \"processing|completed|ready\",\n                \"progress\": 45,\n                \"message\": \"...\",\n                \"already_prepared\": true|false,  // 是否已有完成的准备\n                \"prepare_info\": {...}            // 已准备完成时的详细信息\n            }\n        }\n    \"\"\"\n    from ..models.task import TaskManager\n    \n    try:\n        data = request.get_json() or {}\n        \n        task_id = data.get('task_id')\n        simulation_id = data.get('simulation_id')\n        \n        # 如果提供了simulation_id，先检查是否已准备完成\n        if simulation_id:\n            is_prepared, prepare_info = _check_simulation_prepared(simulation_id)\n            if is_prepared:\n                return jsonify({\n                    \"success\": True,\n                    \"data\": {\n                        \"simulation_id\": simulation_id,\n                        \"status\": \"ready\",\n                        \"progress\": 100,\n                        \"message\": \"已有完成的准备工作\",\n                        \"already_prepared\": True,\n                        \"prepare_info\": prepare_info\n                    }\n                })\n        \n        # 如果没有task_id，返回错误\n        if not task_id:\n            if simulation_id:\n                # 有simulation_id但未准备完成\n                return jsonify({\n                    \"success\": True,\n                    \"data\": {\n                        \"simulation_id\": simulation_id,\n                        \"status\": \"not_started\",\n                        \"progress\": 0,\n                        \"message\": \"尚未开始准备，请调用 /api/simulation/prepare 开始\",\n                        \"already_prepared\": False\n                    }\n                })\n            return jsonify({\n                \"success\": False,\n                \"error\": \"请提供 task_id 或 simulation_id\"\n            }), 400\n        \n        task_manager = TaskManager()\n        task = task_manager.get_task(task_id)\n        \n        if not task:\n            # 任务不存在，但如果有simulation_id，检查是否已准备完成\n            if simulation_id:\n                is_prepared, prepare_info = _check_simulation_prepared(simulation_id)\n                if is_prepared:\n                    return jsonify({\n                        \"success\": True,\n                        \"data\": {\n                            \"simulation_id\": simulation_id,\n                            \"task_id\": task_id,\n                            \"status\": \"ready\",\n                            \"progress\": 100,\n                            \"message\": \"任务已完成（准备工作已存在）\",\n                            \"already_prepared\": True,\n                            \"prepare_info\": prepare_info\n                        }\n                    })\n            \n            return jsonify({\n                \"success\": False,\n                \"error\": f\"任务不存在: {task_id}\"\n            }), 404\n        \n        task_dict = task.to_dict()\n        task_dict[\"already_prepared\"] = False\n        \n        return jsonify({\n            \"success\": True,\n            \"data\": task_dict\n        })\n        \n    except Exception as e:\n        logger.error(f\"查询任务状态失败: {str(e)}\")\n        return jsonify({\n            \"success\": False,\n            \"error\": str(e)\n        }), 500\n\n\n@simulation_bp.route('/<simulation_id>', methods=['GET'])\ndef get_simulation(simulation_id: str):\n    \"\"\"获取模拟状态\"\"\"\n    try:\n        manager = SimulationManager()\n        state = manager.get_simulation(simulation_id)\n        \n        if not state:\n            return jsonify({\n                \"success\": False,\n                \"error\": f\"模拟不存在: {simulation_id}\"\n            }), 404\n        \n        result = state.to_dict()\n        \n        # 如果模拟已准备好，附加运行说明\n        if state.status == SimulationStatus.READY:\n            result[\"run_instructions\"] = manager.get_run_instructions(simulation_id)\n        \n        return jsonify({\n            \"success\": True,\n            \"data\": result\n        })\n        \n    except Exception as e:\n        logger.error(f\"获取模拟状态失败: {str(e)}\")\n        return jsonify({\n            \"success\": False,\n            \"error\": str(e),\n            \"traceback\": traceback.format_exc()\n        }), 500\n\n\n@simulation_bp.route('/list', methods=['GET'])\ndef list_simulations():\n    \"\"\"\n    列出所有模拟\n    \n    Query参数：\n        project_id: 按项目ID过滤（可选）\n    \"\"\"\n    try:\n        project_id = request.args.get('project_id')\n        \n        manager = SimulationManager()\n        simulations = manager.list_simulations(project_id=project_id)\n        \n        return jsonify({\n            \"success\": True,\n            \"data\": [s.to_dict() for s in simulations],\n            \"count\": len(simulations)\n        })\n        \n    except Exception as e:\n        logger.error(f\"列出模拟失败: {str(e)}\")\n        return jsonify({\n            \"success\": False,\n            \"error\": str(e),\n            \"traceback\": traceback.format_exc()\n        }), 500\n\n\ndef _get_report_id_for_simulation(simulation_id: str) -> str:\n    \"\"\"\n    获取 simulation 对应的最新 report_id\n    \n    遍历 reports 目录，找出 simulation_id 匹配的 report，\n    如果有多个则返回最新的（按 created_at 排序）\n    \n    Args:\n        simulation_id: 模拟ID\n        \n    Returns:\n        report_id 或 None\n    \"\"\"\n    import json\n    from datetime import datetime\n    \n    # reports 目录路径：backend/uploads/reports\n    # __file__ 是 app/api/simulation.py，需要向上两级到 backend/\n    reports_dir = os.path.join(os.path.dirname(__file__), '../../uploads/reports')\n    if not os.path.exists(reports_dir):\n        return None\n    \n    matching_reports = []\n    \n    try:\n        for report_folder in os.listdir(reports_dir):\n            report_path = os.path.join(reports_dir, report_folder)\n            if not os.path.isdir(report_path):\n                continue\n            \n            meta_file = os.path.join(report_path, \"meta.json\")\n            if not os.path.exists(meta_file):\n                continue\n            \n            try:\n                with open(meta_file, 'r', encoding='utf-8') as f:\n                    meta = json.load(f)\n                \n                if meta.get(\"simulation_id\") == simulation_id:\n                    matching_reports.append({\n                        \"report_id\": meta.get(\"report_id\"),\n                        \"created_at\": meta.get(\"created_at\", \"\"),\n                        \"status\": meta.get(\"status\", \"\")\n                    })\n            except Exception:\n                continue\n        \n        if not matching_reports:\n            return None\n        \n        # 按创建时间倒序排序，返回最新的\n        matching_reports.sort(key=lambda x: x.get(\"created_at\", \"\"), reverse=True)\n        return matching_reports[0].get(\"report_id\")\n        \n    except Exception as e:\n        logger.warning(f\"查找 simulation {simulation_id} 的 report 失败: {e}\")\n        return None\n\n\n@simulation_bp.route('/history', methods=['GET'])\ndef get_simulation_history():\n    \"\"\"\n    获取历史模拟列表（带项目详情）\n    \n    用于首页历史项目展示，返回包含项目名称、描述等丰富信息的模拟列表\n    \n    Query参数：\n        limit: 返回数量限制（默认20）\n    \n    返回：\n        {\n            \"success\": true,\n            \"data\": [\n                {\n                    \"simulation_id\": \"sim_xxxx\",\n                    \"project_id\": \"proj_xxxx\",\n                    \"project_name\": \"武大舆情分析\",\n                    \"simulation_requirement\": \"如果武汉大学发布...\",\n                    \"status\": \"completed\",\n                    \"entities_count\": 68,\n                    \"profiles_count\": 68,\n                    \"entity_types\": [\"Student\", \"Professor\", ...],\n                    \"created_at\": \"2024-12-10\",\n                    \"updated_at\": \"2024-12-10\",\n                    \"total_rounds\": 120,\n                    \"current_round\": 120,\n                    \"report_id\": \"report_xxxx\",\n                    \"version\": \"v1.0.2\"\n                },\n                ...\n            ],\n            \"count\": 7\n        }\n    \"\"\"\n    try:\n        limit = request.args.get('limit', 20, type=int)\n        \n        manager = SimulationManager()\n        simulations = manager.list_simulations()[:limit]\n        \n        # 增强模拟数据，只从 Simulation 文件读取\n        enriched_simulations = []\n        for sim in simulations:\n            sim_dict = sim.to_dict()\n            \n            # 获取模拟配置信息（从 simulation_config.json 读取 simulation_requirement）\n            config = manager.get_simulation_config(sim.simulation_id)\n            if config:\n                sim_dict[\"simulation_requirement\"] = config.get(\"simulation_requirement\", \"\")\n                time_config = config.get(\"time_config\", {})\n                sim_dict[\"total_simulation_hours\"] = time_config.get(\"total_simulation_hours\", 0)\n                # 推荐轮数（后备值）\n                recommended_rounds = int(\n                    time_config.get(\"total_simulation_hours\", 0) * 60 / \n                    max(time_config.get(\"minutes_per_round\", 60), 1)\n                )\n            else:\n                sim_dict[\"simulation_requirement\"] = \"\"\n                sim_dict[\"total_simulation_hours\"] = 0\n                recommended_rounds = 0\n            \n            # 获取运行状态（从 run_state.json 读取用户设置的实际轮数）\n            run_state = SimulationRunner.get_run_state(sim.simulation_id)\n            if run_state:\n                sim_dict[\"current_round\"] = run_state.current_round\n                sim_dict[\"runner_status\"] = run_state.runner_status.value\n                # 使用用户设置的 total_rounds，若无则使用推荐轮数\n                sim_dict[\"total_rounds\"] = run_state.total_rounds if run_state.total_rounds > 0 else recommended_rounds\n            else:\n                sim_dict[\"current_round\"] = 0\n                sim_dict[\"runner_status\"] = \"idle\"\n                sim_dict[\"total_rounds\"] = recommended_rounds\n            \n            # 获取关联项目的文件列表（最多3个）\n            project = ProjectManager.get_project(sim.project_id)\n            if project and hasattr(project, 'files') and project.files:\n                sim_dict[\"files\"] = [\n                    {\"filename\": f.get(\"filename\", \"未知文件\")} \n                    for f in project.files[:3]\n                ]\n            else:\n                sim_dict[\"files\"] = []\n            \n            # 获取关联的 report_id（查找该 simulation 最新的 report）\n            sim_dict[\"report_id\"] = _get_report_id_for_simulation(sim.simulation_id)\n            \n            # 添加版本号\n            sim_dict[\"version\"] = \"v1.0.2\"\n            \n            # 格式化日期\n            try:\n                created_date = sim_dict.get(\"created_at\", \"\")[:10]\n                sim_dict[\"created_date\"] = created_date\n            except:\n                sim_dict[\"created_date\"] = \"\"\n            \n            enriched_simulations.append(sim_dict)\n        \n        return jsonify({\n            \"success\": True,\n            \"data\": enriched_simulations,\n            \"count\": len(enriched_simulations)\n        })\n        \n    except Exception as e:\n        logger.error(f\"获取历史模拟失败: {str(e)}\")\n        return jsonify({\n            \"success\": False,\n            \"error\": str(e),\n            \"traceback\": traceback.format_exc()\n        }), 500\n\n\n@simulation_bp.route('/<simulation_id>/profiles', methods=['GET'])\ndef get_simulation_profiles(simulation_id: str):\n    \"\"\"\n    获取模拟的Agent Profile\n    \n    Query参数：\n        platform: 平台类型（reddit/twitter，默认reddit）\n    \"\"\"\n    try:\n        platform = request.args.get('platform', 'reddit')\n        \n        manager = SimulationManager()\n        profiles = manager.get_profiles(simulation_id, platform=platform)\n        \n        return jsonify({\n            \"success\": True,\n            \"data\": {\n                \"platform\": platform,\n                \"count\": len(profiles),\n                \"profiles\": profiles\n            }\n        })\n        \n    except ValueError as e:\n        return jsonify({\n            \"success\": False,\n            \"error\": str(e)\n        }), 404\n        \n    except Exception as e:\n        logger.error(f\"获取Profile失败: {str(e)}\")\n        return jsonify({\n            \"success\": False,\n            \"error\": str(e),\n            \"traceback\": traceback.format_exc()\n        }), 500\n\n\n@simulation_bp.route('/<simulation_id>/profiles/realtime', methods=['GET'])\ndef get_simulation_profiles_realtime(simulation_id: str):\n    \"\"\"\n    实时获取模拟的Agent Profile（用于在生成过程中实时查看进度）\n    \n    与 /profiles 接口的区别：\n    - 直接读取文件，不经过 SimulationManager\n    - 适用于生成过程中的实时查看\n    - 返回额外的元数据（如文件修改时间、是否正在生成等）\n    \n    Query参数：\n        platform: 平台类型（reddit/twitter，默认reddit）\n    \n    返回：\n        {\n            \"success\": true,\n            \"data\": {\n                \"simulation_id\": \"sim_xxxx\",\n                \"platform\": \"reddit\",\n                \"count\": 15,\n                \"total_expected\": 93,  // 预期总数（如果有）\n                \"is_generating\": true,  // 是否正在生成\n                \"file_exists\": true,\n                \"file_modified_at\": \"2025-12-04T18:20:00\",\n                \"profiles\": [...]\n            }\n        }\n    \"\"\"\n    import json\n    import csv\n    from datetime import datetime\n    \n    try:\n        platform = request.args.get('platform', 'reddit')\n        \n        # 获取模拟目录\n        sim_dir = os.path.join(Config.OASIS_SIMULATION_DATA_DIR, simulation_id)\n        \n        if not os.path.exists(sim_dir):\n            return jsonify({\n                \"success\": False,\n                \"error\": f\"模拟不存在: {simulation_id}\"\n            }), 404\n        \n        # 确定文件路径\n        if platform == \"reddit\":\n            profiles_file = os.path.join(sim_dir, \"reddit_profiles.json\")\n        else:\n            profiles_file = os.path.join(sim_dir, \"twitter_profiles.csv\")\n        \n        # 检查文件是否存在\n        file_exists = os.path.exists(profiles_file)\n        profiles = []\n        file_modified_at = None\n        \n        if file_exists:\n            # 获取文件修改时间\n            file_stat = os.stat(profiles_file)\n            file_modified_at = datetime.fromtimestamp(file_stat.st_mtime).isoformat()\n            \n            try:\n                if platform == \"reddit\":\n                    with open(profiles_file, 'r', encoding='utf-8') as f:\n                        profiles = json.load(f)\n                else:\n                    with open(profiles_file, 'r', encoding='utf-8') as f:\n                        reader = csv.DictReader(f)\n                        profiles = list(reader)\n            except (json.JSONDecodeError, Exception) as e:\n                logger.warning(f\"读取 profiles 文件失败（可能正在写入中）: {e}\")\n                profiles = []\n        \n        # 检查是否正在生成（通过 state.json 判断）\n        is_generating = False\n        total_expected = None\n        \n        state_file = os.path.join(sim_dir, \"state.json\")\n        if os.path.exists(state_file):\n            try:\n                with open(state_file, 'r', encoding='utf-8') as f:\n                    state_data = json.load(f)\n                    status = state_data.get(\"status\", \"\")\n                    is_generating = status == \"preparing\"\n                    total_expected = state_data.get(\"entities_count\")\n            except Exception:\n                pass\n        \n        return jsonify({\n            \"success\": True,\n            \"data\": {\n                \"simulation_id\": simulation_id,\n                \"platform\": platform,\n                \"count\": len(profiles),\n                \"total_expected\": total_expected,\n                \"is_generating\": is_generating,\n                \"file_exists\": file_exists,\n                \"file_modified_at\": file_modified_at,\n                \"profiles\": profiles\n            }\n        })\n        \n    except Exception as e:\n        logger.error(f\"实时获取Profile失败: {str(e)}\")\n        return jsonify({\n            \"success\": False,\n            \"error\": str(e),\n            \"traceback\": traceback.format_exc()\n        }), 500\n\n\n@simulation_bp.route('/<simulation_id>/config/realtime', methods=['GET'])\ndef get_simulation_config_realtime(simulation_id: str):\n    \"\"\"\n    实时获取模拟配置（用于在生成过程中实时查看进度）\n    \n    与 /config 接口的区别：\n    - 直接读取文件，不经过 SimulationManager\n    - 适用于生成过程中的实时查看\n    - 返回额外的元数据（如文件修改时间、是否正在生成等）\n    - 即使配置还没生成完也能返回部分信息\n    \n    返回：\n        {\n            \"success\": true,\n            \"data\": {\n                \"simulation_id\": \"sim_xxxx\",\n                \"file_exists\": true,\n                \"file_modified_at\": \"2025-12-04T18:20:00\",\n                \"is_generating\": true,  // 是否正在生成\n                \"generation_stage\": \"generating_config\",  // 当前生成阶段\n                \"config\": {...}  // 配置内容（如果存在）\n            }\n        }\n    \"\"\"\n    import json\n    from datetime import datetime\n    \n    try:\n        # 获取模拟目录\n        sim_dir = os.path.join(Config.OASIS_SIMULATION_DATA_DIR, simulation_id)\n        \n        if not os.path.exists(sim_dir):\n            return jsonify({\n                \"success\": False,\n                \"error\": f\"模拟不存在: {simulation_id}\"\n            }), 404\n        \n        # 配置文件路径\n        config_file = os.path.join(sim_dir, \"simulation_config.json\")\n        \n        # 检查文件是否存在\n        file_exists = os.path.exists(config_file)\n        config = None\n        file_modified_at = None\n        \n        if file_exists:\n            # 获取文件修改时间\n            file_stat = os.stat(config_file)\n            file_modified_at = datetime.fromtimestamp(file_stat.st_mtime).isoformat()\n            \n            try:\n                with open(config_file, 'r', encoding='utf-8') as f:\n                    config = json.load(f)\n            except (json.JSONDecodeError, Exception) as e:\n                logger.warning(f\"读取 config 文件失败（可能正在写入中）: {e}\")\n                config = None\n        \n        # 检查是否正在生成（通过 state.json 判断）\n        is_generating = False\n        generation_stage = None\n        config_generated = False\n        \n        state_file = os.path.join(sim_dir, \"state.json\")\n        if os.path.exists(state_file):\n            try:\n                with open(state_file, 'r', encoding='utf-8') as f:\n                    state_data = json.load(f)\n                    status = state_data.get(\"status\", \"\")\n                    is_generating = status == \"preparing\"\n                    config_generated = state_data.get(\"config_generated\", False)\n                    \n                    # 判断当前阶段\n                    if is_generating:\n                        if state_data.get(\"profiles_generated\", False):\n                            generation_stage = \"generating_config\"\n                        else:\n                            generation_stage = \"generating_profiles\"\n                    elif status == \"ready\":\n                        generation_stage = \"completed\"\n            except Exception:\n                pass\n        \n        # 构建返回数据\n        response_data = {\n            \"simulation_id\": simulation_id,\n            \"file_exists\": file_exists,\n            \"file_modified_at\": file_modified_at,\n            \"is_generating\": is_generating,\n            \"generation_stage\": generation_stage,\n            \"config_generated\": config_generated,\n            \"config\": config\n        }\n        \n        # 如果配置存在，提取一些关键统计信息\n        if config:\n            response_data[\"summary\"] = {\n                \"total_agents\": len(config.get(\"agent_configs\", [])),\n                \"simulation_hours\": config.get(\"time_config\", {}).get(\"total_simulation_hours\"),\n                \"initial_posts_count\": len(config.get(\"event_config\", {}).get(\"initial_posts\", [])),\n                \"hot_topics_count\": len(config.get(\"event_config\", {}).get(\"hot_topics\", [])),\n                \"has_twitter_config\": \"twitter_config\" in config,\n                \"has_reddit_config\": \"reddit_config\" in config,\n                \"generated_at\": config.get(\"generated_at\"),\n                \"llm_model\": config.get(\"llm_model\")\n            }\n        \n        return jsonify({\n            \"success\": True,\n            \"data\": response_data\n        })\n        \n    except Exception as e:\n        logger.error(f\"实时获取Config失败: {str(e)}\")\n        return jsonify({\n            \"success\": False,\n            \"error\": str(e),\n            \"traceback\": traceback.format_exc()\n        }), 500\n\n\n@simulation_bp.route('/<simulation_id>/config', methods=['GET'])\ndef get_simulation_config(simulation_id: str):\n    \"\"\"\n    获取模拟配置（LLM智能生成的完整配置）\n    \n    返回包含：\n        - time_config: 时间配置（模拟时长、轮次、高峰/低谷时段）\n        - agent_configs: 每个Agent的活动配置（活跃度、发言频率、立场等）\n        - event_config: 事件配置（初始帖子、热点话题）\n        - platform_configs: 平台配置\n        - generation_reasoning: LLM的配置推理说明\n    \"\"\"\n    try:\n        manager = SimulationManager()\n        config = manager.get_simulation_config(simulation_id)\n        \n        if not config:\n            return jsonify({\n                \"success\": False,\n                \"error\": f\"模拟配置不存在，请先调用 /prepare 接口\"\n            }), 404\n        \n        return jsonify({\n            \"success\": True,\n            \"data\": config\n        })\n        \n    except Exception as e:\n        logger.error(f\"获取配置失败: {str(e)}\")\n        return jsonify({\n            \"success\": False,\n            \"error\": str(e),\n            \"traceback\": traceback.format_exc()\n        }), 500\n\n\n@simulation_bp.route('/<simulation_id>/config/download', methods=['GET'])\ndef download_simulation_config(simulation_id: str):\n    \"\"\"下载模拟配置文件\"\"\"\n    try:\n        manager = SimulationManager()\n        sim_dir = manager._get_simulation_dir(simulation_id)\n        config_path = os.path.join(sim_dir, \"simulation_config.json\")\n        \n        if not os.path.exists(config_path):\n            return jsonify({\n                \"success\": False,\n                \"error\": \"配置文件不存在，请先调用 /prepare 接口\"\n            }), 404\n        \n        return send_file(\n            config_path,\n            as_attachment=True,\n            download_name=\"simulation_config.json\"\n        )\n        \n    except Exception as e:\n        logger.error(f\"下载配置失败: {str(e)}\")\n        return jsonify({\n            \"success\": False,\n            \"error\": str(e),\n            \"traceback\": traceback.format_exc()\n        }), 500\n\n\n@simulation_bp.route('/script/<script_name>/download', methods=['GET'])\ndef download_simulation_script(script_name: str):\n    \"\"\"\n    下载模拟运行脚本文件（通用脚本，位于 backend/scripts/）\n    \n    script_name可选值：\n        - run_twitter_simulation.py\n        - run_reddit_simulation.py\n        - run_parallel_simulation.py\n        - action_logger.py\n    \"\"\"\n    try:\n        # 脚本位于 backend/scripts/ 目录\n        scripts_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../scripts'))\n        \n        # 验证脚本名称\n        allowed_scripts = [\n            \"run_twitter_simulation.py\",\n            \"run_reddit_simulation.py\", \n            \"run_parallel_simulation.py\",\n            \"action_logger.py\"\n        ]\n        \n        if script_name not in allowed_scripts:\n            return jsonify({\n                \"success\": False,\n                \"error\": f\"未知脚本: {script_name}，可选: {allowed_scripts}\"\n            }), 400\n        \n        script_path = os.path.join(scripts_dir, script_name)\n        \n        if not os.path.exists(script_path):\n            return jsonify({\n                \"success\": False,\n                \"error\": f\"脚本文件不存在: {script_name}\"\n            }), 404\n        \n        return send_file(\n            script_path,\n            as_attachment=True,\n            download_name=script_name\n        )\n        \n    except Exception as e:\n        logger.error(f\"下载脚本失败: {str(e)}\")\n        return jsonify({\n            \"success\": False,\n            \"error\": str(e),\n            \"traceback\": traceback.format_exc()\n        }), 500\n\n\n# ============== Profile生成接口（独立使用） ==============\n\n@simulation_bp.route('/generate-profiles', methods=['POST'])\ndef generate_profiles():\n    \"\"\"\n    直接从图谱生成OASIS Agent Profile（不创建模拟）\n    \n    请求（JSON）：\n        {\n            \"graph_id\": \"mirofish_xxxx\",     // 必填\n            \"entity_types\": [\"Student\"],      // 可选\n            \"use_llm\": true,                  // 可选\n            \"platform\": \"reddit\"              // 可选\n        }\n    \"\"\"\n    try:\n        data = request.get_json() or {}\n        \n        graph_id = data.get('graph_id')\n        if not graph_id:\n            return jsonify({\n                \"success\": False,\n                \"error\": \"请提供 graph_id\"\n            }), 400\n        \n        entity_types = data.get('entity_types')\n        use_llm = data.get('use_llm', True)\n        platform = data.get('platform', 'reddit')\n        \n        reader = ZepEntityReader()\n        filtered = reader.filter_defined_entities(\n            graph_id=graph_id,\n            defined_entity_types=entity_types,\n            enrich_with_edges=True\n        )\n        \n        if filtered.filtered_count == 0:\n            return jsonify({\n                \"success\": False,\n                \"error\": \"没有找到符合条件的实体\"\n            }), 400\n        \n        generator = OasisProfileGenerator()\n        profiles = generator.generate_profiles_from_entities(\n            entities=filtered.entities,\n            use_llm=use_llm\n        )\n        \n        if platform == \"reddit\":\n            profiles_data = [p.to_reddit_format() for p in profiles]\n        elif platform == \"twitter\":\n            profiles_data = [p.to_twitter_format() for p in profiles]\n        else:\n            profiles_data = [p.to_dict() for p in profiles]\n        \n        return jsonify({\n            \"success\": True,\n            \"data\": {\n                \"platform\": platform,\n                \"entity_types\": list(filtered.entity_types),\n                \"count\": len(profiles_data),\n                \"profiles\": profiles_data\n            }\n        })\n        \n    except Exception as e:\n        logger.error(f\"生成Profile失败: {str(e)}\")\n        return jsonify({\n            \"success\": False,\n            \"error\": str(e),\n            \"traceback\": traceback.format_exc()\n        }), 500\n\n\n# ============== 模拟运行控制接口 ==============\n\n@simulation_bp.route('/start', methods=['POST'])\ndef start_simulation():\n    \"\"\"\n    开始运行模拟\n\n    请求（JSON）：\n        {\n            \"simulation_id\": \"sim_xxxx\",          // 必填，模拟ID\n            \"platform\": \"parallel\",                // 可选: twitter / reddit / parallel (默认)\n            \"max_rounds\": 100,                     // 可选: 最大模拟轮数，用于截断过长的模拟\n            \"enable_graph_memory_update\": false,   // 可选: 是否将Agent活动动态更新到Zep图谱记忆\n            \"force\": false                         // 可选: 强制重新开始（会停止运行中的模拟并清理日志）\n        }\n\n    关于 force 参数：\n        - 启用后，如果模拟正在运行或已完成，会先停止并清理运行日志\n        - 清理的内容包括：run_state.json, actions.jsonl, simulation.log 等\n        - 不会清理配置文件（simulation_config.json）和 profile 文件\n        - 适用于需要重新运行模拟的场景\n\n    关于 enable_graph_memory_update：\n        - 启用后，模拟中所有Agent的活动（发帖、评论、点赞等）都会实时更新到Zep图谱\n        - 这可以让图谱\"记住\"模拟过程，用于后续分析或AI对话\n        - 需要模拟关联的项目有有效的 graph_id\n        - 采用批量更新机制，减少API调用次数\n\n    返回：\n        {\n            \"success\": true,\n            \"data\": {\n                \"simulation_id\": \"sim_xxxx\",\n                \"runner_status\": \"running\",\n                \"process_pid\": 12345,\n                \"twitter_running\": true,\n                \"reddit_running\": true,\n                \"started_at\": \"2025-12-01T10:00:00\",\n                \"graph_memory_update_enabled\": true,  // 是否启用了图谱记忆更新\n                \"force_restarted\": true               // 是否是强制重新开始\n            }\n        }\n    \"\"\"\n    try:\n        data = request.get_json() or {}\n\n        simulation_id = data.get('simulation_id')\n        if not simulation_id:\n            return jsonify({\n                \"success\": False,\n                \"error\": \"请提供 simulation_id\"\n            }), 400\n\n        platform = data.get('platform', 'parallel')\n        max_rounds = data.get('max_rounds')  # 可选：最大模拟轮数\n        enable_graph_memory_update = data.get('enable_graph_memory_update', False)  # 可选：是否启用图谱记忆更新\n        force = data.get('force', False)  # 可选：强制重新开始\n\n        # 验证 max_rounds 参数\n        if max_rounds is not None:\n            try:\n                max_rounds = int(max_rounds)\n                if max_rounds <= 0:\n                    return jsonify({\n                        \"success\": False,\n                        \"error\": \"max_rounds 必须是正整数\"\n                    }), 400\n            except (ValueError, TypeError):\n                return jsonify({\n                    \"success\": False,\n                    \"error\": \"max_rounds 必须是有效的整数\"\n                }), 400\n\n        if platform not in ['twitter', 'reddit', 'parallel']:\n            return jsonify({\n                \"success\": False,\n                \"error\": f\"无效的平台类型: {platform}，可选: twitter/reddit/parallel\"\n            }), 400\n\n        # 检查模拟是否已准备好\n        manager = SimulationManager()\n        state = manager.get_simulation(simulation_id)\n\n        if not state:\n            return jsonify({\n                \"success\": False,\n                \"error\": f\"模拟不存在: {simulation_id}\"\n            }), 404\n\n        force_restarted = False\n        \n        # 智能处理状态：如果准备工作已完成，允许重新启动\n        if state.status != SimulationStatus.READY:\n            # 检查准备工作是否已完成\n            is_prepared, prepare_info = _check_simulation_prepared(simulation_id)\n\n            if is_prepared:\n                # 准备工作已完成，检查是否有正在运行的进程\n                if state.status == SimulationStatus.RUNNING:\n                    # 检查模拟进程是否真的在运行\n                    run_state = SimulationRunner.get_run_state(simulation_id)\n                    if run_state and run_state.runner_status.value == \"running\":\n                        # 进程确实在运行\n                        if force:\n                            # 强制模式：停止运行中的模拟\n                            logger.info(f\"强制模式：停止运行中的模拟 {simulation_id}\")\n                            try:\n                                SimulationRunner.stop_simulation(simulation_id)\n                            except Exception as e:\n                                logger.warning(f\"停止模拟时出现警告: {str(e)}\")\n                        else:\n                            return jsonify({\n                                \"success\": False,\n                                \"error\": f\"模拟正在运行中，请先调用 /stop 接口停止，或使用 force=true 强制重新开始\"\n                            }), 400\n\n                # 如果是强制模式，清理运行日志\n                if force:\n                    logger.info(f\"强制模式：清理模拟日志 {simulation_id}\")\n                    cleanup_result = SimulationRunner.cleanup_simulation_logs(simulation_id)\n                    if not cleanup_result.get(\"success\"):\n                        logger.warning(f\"清理日志时出现警告: {cleanup_result.get('errors')}\")\n                    force_restarted = True\n\n                # 进程不存在或已结束，重置状态为 ready\n                logger.info(f\"模拟 {simulation_id} 准备工作已完成，重置状态为 ready（原状态: {state.status.value}）\")\n                state.status = SimulationStatus.READY\n                manager._save_simulation_state(state)\n            else:\n                # 准备工作未完成\n                return jsonify({\n                    \"success\": False,\n                    \"error\": f\"模拟未准备好，当前状态: {state.status.value}，请先调用 /prepare 接口\"\n                }), 400\n        \n        # 获取图谱ID（用于图谱记忆更新）\n        graph_id = None\n        if enable_graph_memory_update:\n            # 从模拟状态或项目中获取 graph_id\n            graph_id = state.graph_id\n            if not graph_id:\n                # 尝试从项目中获取\n                project = ProjectManager.get_project(state.project_id)\n                if project:\n                    graph_id = project.graph_id\n            \n            if not graph_id:\n                return jsonify({\n                    \"success\": False,\n                    \"error\": \"启用图谱记忆更新需要有效的 graph_id，请确保项目已构建图谱\"\n                }), 400\n            \n            logger.info(f\"启用图谱记忆更新: simulation_id={simulation_id}, graph_id={graph_id}\")\n        \n        # 启动模拟\n        run_state = SimulationRunner.start_simulation(\n            simulation_id=simulation_id,\n            platform=platform,\n            max_rounds=max_rounds,\n            enable_graph_memory_update=enable_graph_memory_update,\n            graph_id=graph_id\n        )\n        \n        # 更新模拟状态\n        state.status = SimulationStatus.RUNNING\n        manager._save_simulation_state(state)\n        \n        response_data = run_state.to_dict()\n        if max_rounds:\n            response_data['max_rounds_applied'] = max_rounds\n        response_data['graph_memory_update_enabled'] = enable_graph_memory_update\n        response_data['force_restarted'] = force_restarted\n        if enable_graph_memory_update:\n            response_data['graph_id'] = graph_id\n        \n        return jsonify({\n            \"success\": True,\n            \"data\": response_data\n        })\n        \n    except ValueError as e:\n        return jsonify({\n            \"success\": False,\n            \"error\": str(e)\n        }), 400\n        \n    except Exception as e:\n        logger.error(f\"启动模拟失败: {str(e)}\")\n        return jsonify({\n            \"success\": False,\n            \"error\": str(e),\n            \"traceback\": traceback.format_exc()\n        }), 500\n\n\n@simulation_bp.route('/stop', methods=['POST'])\ndef stop_simulation():\n    \"\"\"\n    停止模拟\n    \n    请求（JSON）：\n        {\n            \"simulation_id\": \"sim_xxxx\"  // 必填，模拟ID\n        }\n    \n    返回：\n        {\n            \"success\": true,\n            \"data\": {\n                \"simulation_id\": \"sim_xxxx\",\n                \"runner_status\": \"stopped\",\n                \"completed_at\": \"2025-12-01T12:00:00\"\n            }\n        }\n    \"\"\"\n    try:\n        data = request.get_json() or {}\n        \n        simulation_id = data.get('simulation_id')\n        if not simulation_id:\n            return jsonify({\n                \"success\": False,\n                \"error\": \"请提供 simulation_id\"\n            }), 400\n        \n        run_state = SimulationRunner.stop_simulation(simulation_id)\n        \n        # 更新模拟状态\n        manager = SimulationManager()\n        state = manager.get_simulation(simulation_id)\n        if state:\n            state.status = SimulationStatus.PAUSED\n            manager._save_simulation_state(state)\n        \n        return jsonify({\n            \"success\": True,\n            \"data\": run_state.to_dict()\n        })\n        \n    except ValueError as e:\n        return jsonify({\n            \"success\": False,\n            \"error\": str(e)\n        }), 400\n        \n    except Exception as e:\n        logger.error(f\"停止模拟失败: {str(e)}\")\n        return jsonify({\n            \"success\": False,\n            \"error\": str(e),\n            \"traceback\": traceback.format_exc()\n        }), 500\n\n\n# ============== 实时状态监控接口 ==============\n\n@simulation_bp.route('/<simulation_id>/run-status', methods=['GET'])\ndef get_run_status(simulation_id: str):\n    \"\"\"\n    获取模拟运行实时状态（用于前端轮询）\n    \n    返回：\n        {\n            \"success\": true,\n            \"data\": {\n                \"simulation_id\": \"sim_xxxx\",\n                \"runner_status\": \"running\",\n                \"current_round\": 5,\n                \"total_rounds\": 144,\n                \"progress_percent\": 3.5,\n                \"simulated_hours\": 2,\n                \"total_simulation_hours\": 72,\n                \"twitter_running\": true,\n                \"reddit_running\": true,\n                \"twitter_actions_count\": 150,\n                \"reddit_actions_count\": 200,\n                \"total_actions_count\": 350,\n                \"started_at\": \"2025-12-01T10:00:00\",\n                \"updated_at\": \"2025-12-01T10:30:00\"\n            }\n        }\n    \"\"\"\n    try:\n        run_state = SimulationRunner.get_run_state(simulation_id)\n        \n        if not run_state:\n            return jsonify({\n                \"success\": True,\n                \"data\": {\n                    \"simulation_id\": simulation_id,\n                    \"runner_status\": \"idle\",\n                    \"current_round\": 0,\n                    \"total_rounds\": 0,\n                    \"progress_percent\": 0,\n                    \"twitter_actions_count\": 0,\n                    \"reddit_actions_count\": 0,\n                    \"total_actions_count\": 0,\n                }\n            })\n        \n        return jsonify({\n            \"success\": True,\n            \"data\": run_state.to_dict()\n        })\n        \n    except Exception as e:\n        logger.error(f\"获取运行状态失败: {str(e)}\")\n        return jsonify({\n            \"success\": False,\n            \"error\": str(e),\n            \"traceback\": traceback.format_exc()\n        }), 500\n\n\n@simulation_bp.route('/<simulation_id>/run-status/detail', methods=['GET'])\ndef get_run_status_detail(simulation_id: str):\n    \"\"\"\n    获取模拟运行详细状态（包含所有动作）\n    \n    用于前端展示实时动态\n    \n    Query参数：\n        platform: 过滤平台（twitter/reddit，可选）\n    \n    返回：\n        {\n            \"success\": true,\n            \"data\": {\n                \"simulation_id\": \"sim_xxxx\",\n                \"runner_status\": \"running\",\n                \"current_round\": 5,\n                ...\n                \"all_actions\": [\n                    {\n                        \"round_num\": 5,\n                        \"timestamp\": \"2025-12-01T10:30:00\",\n                        \"platform\": \"twitter\",\n                        \"agent_id\": 3,\n                        \"agent_name\": \"Agent Name\",\n                        \"action_type\": \"CREATE_POST\",\n                        \"action_args\": {\"content\": \"...\"},\n                        \"result\": null,\n                        \"success\": true\n                    },\n                    ...\n                ],\n                \"twitter_actions\": [...],  # Twitter 平台的所有动作\n                \"reddit_actions\": [...]    # Reddit 平台的所有动作\n            }\n        }\n    \"\"\"\n    try:\n        run_state = SimulationRunner.get_run_state(simulation_id)\n        platform_filter = request.args.get('platform')\n        \n        if not run_state:\n            return jsonify({\n                \"success\": True,\n                \"data\": {\n                    \"simulation_id\": simulation_id,\n                    \"runner_status\": \"idle\",\n                    \"all_actions\": [],\n                    \"twitter_actions\": [],\n                    \"reddit_actions\": []\n                }\n            })\n        \n        # 获取完整的动作列表\n        all_actions = SimulationRunner.get_all_actions(\n            simulation_id=simulation_id,\n            platform=platform_filter\n        )\n        \n        # 分平台获取动作\n        twitter_actions = SimulationRunner.get_all_actions(\n            simulation_id=simulation_id,\n            platform=\"twitter\"\n        ) if not platform_filter or platform_filter == \"twitter\" else []\n        \n        reddit_actions = SimulationRunner.get_all_actions(\n            simulation_id=simulation_id,\n            platform=\"reddit\"\n        ) if not platform_filter or platform_filter == \"reddit\" else []\n        \n        # 获取当前轮次的动作（recent_actions 只展示最新一轮）\n        current_round = run_state.current_round\n        recent_actions = SimulationRunner.get_all_actions(\n            simulation_id=simulation_id,\n            platform=platform_filter,\n            round_num=current_round\n        ) if current_round > 0 else []\n        \n        # 获取基础状态信息\n        result = run_state.to_dict()\n        result[\"all_actions\"] = [a.to_dict() for a in all_actions]\n        result[\"twitter_actions\"] = [a.to_dict() for a in twitter_actions]\n        result[\"reddit_actions\"] = [a.to_dict() for a in reddit_actions]\n        result[\"rounds_count\"] = len(run_state.rounds)\n        # recent_actions 只展示当前最新一轮两个平台的内容\n        result[\"recent_actions\"] = [a.to_dict() for a in recent_actions]\n        \n        return jsonify({\n            \"success\": True,\n            \"data\": result\n        })\n        \n    except Exception as e:\n        logger.error(f\"获取详细状态失败: {str(e)}\")\n        return jsonify({\n            \"success\": False,\n            \"error\": str(e),\n            \"traceback\": traceback.format_exc()\n        }), 500\n\n\n@simulation_bp.route('/<simulation_id>/actions', methods=['GET'])\ndef get_simulation_actions(simulation_id: str):\n    \"\"\"\n    获取模拟中的Agent动作历史\n    \n    Query参数：\n        limit: 返回数量（默认100）\n        offset: 偏移量（默认0）\n        platform: 过滤平台（twitter/reddit）\n        agent_id: 过滤Agent ID\n        round_num: 过滤轮次\n    \n    返回：\n        {\n            \"success\": true,\n            \"data\": {\n                \"count\": 100,\n                \"actions\": [...]\n            }\n        }\n    \"\"\"\n    try:\n        limit = request.args.get('limit', 100, type=int)\n        offset = request.args.get('offset', 0, type=int)\n        platform = request.args.get('platform')\n        agent_id = request.args.get('agent_id', type=int)\n        round_num = request.args.get('round_num', type=int)\n        \n        actions = SimulationRunner.get_actions(\n            simulation_id=simulation_id,\n            limit=limit,\n            offset=offset,\n            platform=platform,\n            agent_id=agent_id,\n            round_num=round_num\n        )\n        \n        return jsonify({\n            \"success\": True,\n            \"data\": {\n                \"count\": len(actions),\n                \"actions\": [a.to_dict() for a in actions]\n            }\n        })\n        \n    except Exception as e:\n        logger.error(f\"获取动作历史失败: {str(e)}\")\n        return jsonify({\n            \"success\": False,\n            \"error\": str(e),\n            \"traceback\": traceback.format_exc()\n        }), 500\n\n\n@simulation_bp.route('/<simulation_id>/timeline', methods=['GET'])\ndef get_simulation_timeline(simulation_id: str):\n    \"\"\"\n    获取模拟时间线（按轮次汇总）\n    \n    用于前端展示进度条和时间线视图\n    \n    Query参数：\n        start_round: 起始轮次（默认0）\n        end_round: 结束轮次（默认全部）\n    \n    返回每轮的汇总信息\n    \"\"\"\n    try:\n        start_round = request.args.get('start_round', 0, type=int)\n        end_round = request.args.get('end_round', type=int)\n        \n        timeline = SimulationRunner.get_timeline(\n            simulation_id=simulation_id,\n            start_round=start_round,\n            end_round=end_round\n        )\n        \n        return jsonify({\n            \"success\": True,\n            \"data\": {\n                \"rounds_count\": len(timeline),\n                \"timeline\": timeline\n            }\n        })\n        \n    except Exception as e:\n        logger.error(f\"获取时间线失败: {str(e)}\")\n        return jsonify({\n            \"success\": False,\n            \"error\": str(e),\n            \"traceback\": traceback.format_exc()\n        }), 500\n\n\n@simulation_bp.route('/<simulation_id>/agent-stats', methods=['GET'])\ndef get_agent_stats(simulation_id: str):\n    \"\"\"\n    获取每个Agent的统计信息\n    \n    用于前端展示Agent活跃度排行、动作分布等\n    \"\"\"\n    try:\n        stats = SimulationRunner.get_agent_stats(simulation_id)\n        \n        return jsonify({\n            \"success\": True,\n            \"data\": {\n                \"agents_count\": len(stats),\n                \"stats\": stats\n            }\n        })\n        \n    except Exception as e:\n        logger.error(f\"获取Agent统计失败: {str(e)}\")\n        return jsonify({\n            \"success\": False,\n            \"error\": str(e),\n            \"traceback\": traceback.format_exc()\n        }), 500\n\n\n# ============== 数据库查询接口 ==============\n\n@simulation_bp.route('/<simulation_id>/posts', methods=['GET'])\ndef get_simulation_posts(simulation_id: str):\n    \"\"\"\n    获取模拟中的帖子\n    \n    Query参数：\n        platform: 平台类型（twitter/reddit）\n        limit: 返回数量（默认50）\n        offset: 偏移量\n    \n    返回帖子列表（从SQLite数据库读取）\n    \"\"\"\n    try:\n        platform = request.args.get('platform', 'reddit')\n        limit = request.args.get('limit', 50, type=int)\n        offset = request.args.get('offset', 0, type=int)\n        \n        sim_dir = os.path.join(\n            os.path.dirname(__file__),\n            f'../../uploads/simulations/{simulation_id}'\n        )\n        \n        db_file = f\"{platform}_simulation.db\"\n        db_path = os.path.join(sim_dir, db_file)\n        \n        if not os.path.exists(db_path):\n            return jsonify({\n                \"success\": True,\n                \"data\": {\n                    \"platform\": platform,\n                    \"count\": 0,\n                    \"posts\": [],\n                    \"message\": \"数据库不存在，模拟可能尚未运行\"\n                }\n            })\n        \n        import sqlite3\n        conn = sqlite3.connect(db_path)\n        conn.row_factory = sqlite3.Row\n        cursor = conn.cursor()\n        \n        try:\n            cursor.execute(\"\"\"\n                SELECT * FROM post \n                ORDER BY created_at DESC \n                LIMIT ? OFFSET ?\n            \"\"\", (limit, offset))\n            \n            posts = [dict(row) for row in cursor.fetchall()]\n            \n            cursor.execute(\"SELECT COUNT(*) FROM post\")\n            total = cursor.fetchone()[0]\n            \n        except sqlite3.OperationalError:\n            posts = []\n            total = 0\n        \n        conn.close()\n        \n        return jsonify({\n            \"success\": True,\n            \"data\": {\n                \"platform\": platform,\n                \"total\": total,\n                \"count\": len(posts),\n                \"posts\": posts\n            }\n        })\n        \n    except Exception as e:\n        logger.error(f\"获取帖子失败: {str(e)}\")\n        return jsonify({\n            \"success\": False,\n            \"error\": str(e),\n            \"traceback\": traceback.format_exc()\n        }), 500\n\n\n@simulation_bp.route('/<simulation_id>/comments', methods=['GET'])\ndef get_simulation_comments(simulation_id: str):\n    \"\"\"\n    获取模拟中的评论（仅Reddit）\n    \n    Query参数：\n        post_id: 过滤帖子ID（可选）\n        limit: 返回数量\n        offset: 偏移量\n    \"\"\"\n    try:\n        post_id = request.args.get('post_id')\n        limit = request.args.get('limit', 50, type=int)\n        offset = request.args.get('offset', 0, type=int)\n        \n        sim_dir = os.path.join(\n            os.path.dirname(__file__),\n            f'../../uploads/simulations/{simulation_id}'\n        )\n        \n        db_path = os.path.join(sim_dir, \"reddit_simulation.db\")\n        \n        if not os.path.exists(db_path):\n            return jsonify({\n                \"success\": True,\n                \"data\": {\n                    \"count\": 0,\n                    \"comments\": []\n                }\n            })\n        \n        import sqlite3\n        conn = sqlite3.connect(db_path)\n        conn.row_factory = sqlite3.Row\n        cursor = conn.cursor()\n        \n        try:\n            if post_id:\n                cursor.execute(\"\"\"\n                    SELECT * FROM comment \n                    WHERE post_id = ?\n                    ORDER BY created_at DESC \n                    LIMIT ? OFFSET ?\n                \"\"\", (post_id, limit, offset))\n            else:\n                cursor.execute(\"\"\"\n                    SELECT * FROM comment \n                    ORDER BY created_at DESC \n                    LIMIT ? OFFSET ?\n                \"\"\", (limit, offset))\n            \n            comments = [dict(row) for row in cursor.fetchall()]\n            \n        except sqlite3.OperationalError:\n            comments = []\n        \n        conn.close()\n        \n        return jsonify({\n            \"success\": True,\n            \"data\": {\n                \"count\": len(comments),\n                \"comments\": comments\n            }\n        })\n        \n    except Exception as e:\n        logger.error(f\"获取评论失败: {str(e)}\")\n        return jsonify({\n            \"success\": False,\n            \"error\": str(e),\n            \"traceback\": traceback.format_exc()\n        }), 500\n\n\n# ============== Interview 采访接口 ==============\n\n@simulation_bp.route('/interview', methods=['POST'])\ndef interview_agent():\n    \"\"\"\n    采访单个Agent\n\n    注意：此功能需要模拟环境处于运行状态（完成模拟循环后进入等待命令模式）\n\n    请求（JSON）：\n        {\n            \"simulation_id\": \"sim_xxxx\",       // 必填，模拟ID\n            \"agent_id\": 0,                     // 必填，Agent ID\n            \"prompt\": \"你对这件事有什么看法？\",  // 必填，采访问题\n            \"platform\": \"twitter\",             // 可选，指定平台（twitter/reddit）\n                                               // 不指定时：双平台模拟同时采访两个平台\n            \"timeout\": 60                      // 可选，超时时间（秒），默认60\n        }\n\n    返回（不指定platform，双平台模式）：\n        {\n            \"success\": true,\n            \"data\": {\n                \"agent_id\": 0,\n                \"prompt\": \"你对这件事有什么看法？\",\n                \"result\": {\n                    \"agent_id\": 0,\n                    \"prompt\": \"...\",\n                    \"platforms\": {\n                        \"twitter\": {\"agent_id\": 0, \"response\": \"...\", \"platform\": \"twitter\"},\n                        \"reddit\": {\"agent_id\": 0, \"response\": \"...\", \"platform\": \"reddit\"}\n                    }\n                },\n                \"timestamp\": \"2025-12-08T10:00:01\"\n            }\n        }\n\n    返回（指定platform）：\n        {\n            \"success\": true,\n            \"data\": {\n                \"agent_id\": 0,\n                \"prompt\": \"你对这件事有什么看法？\",\n                \"result\": {\n                    \"agent_id\": 0,\n                    \"response\": \"我认为...\",\n                    \"platform\": \"twitter\",\n                    \"timestamp\": \"2025-12-08T10:00:00\"\n                },\n                \"timestamp\": \"2025-12-08T10:00:01\"\n            }\n        }\n    \"\"\"\n    try:\n        data = request.get_json() or {}\n        \n        simulation_id = data.get('simulation_id')\n        agent_id = data.get('agent_id')\n        prompt = data.get('prompt')\n        platform = data.get('platform')  # 可选：twitter/reddit/None\n        timeout = data.get('timeout', 60)\n        \n        if not simulation_id:\n            return jsonify({\n                \"success\": False,\n                \"error\": \"请提供 simulation_id\"\n            }), 400\n        \n        if agent_id is None:\n            return jsonify({\n                \"success\": False,\n                \"error\": \"请提供 agent_id\"\n            }), 400\n        \n        if not prompt:\n            return jsonify({\n                \"success\": False,\n                \"error\": \"请提供 prompt（采访问题）\"\n            }), 400\n        \n        # 验证platform参数\n        if platform and platform not in (\"twitter\", \"reddit\"):\n            return jsonify({\n                \"success\": False,\n                \"error\": \"platform 参数只能是 'twitter' 或 'reddit'\"\n            }), 400\n        \n        # 检查环境状态\n        if not SimulationRunner.check_env_alive(simulation_id):\n            return jsonify({\n                \"success\": False,\n                \"error\": \"模拟环境未运行或已关闭。请确保模拟已完成并进入等待命令模式。\"\n            }), 400\n        \n        # 优化prompt，添加前缀避免Agent调用工具\n        optimized_prompt = optimize_interview_prompt(prompt)\n        \n        result = SimulationRunner.interview_agent(\n            simulation_id=simulation_id,\n            agent_id=agent_id,\n            prompt=optimized_prompt,\n            platform=platform,\n            timeout=timeout\n        )\n\n        return jsonify({\n            \"success\": result.get(\"success\", False),\n            \"data\": result\n        })\n        \n    except ValueError as e:\n        return jsonify({\n            \"success\": False,\n            \"error\": str(e)\n        }), 400\n        \n    except TimeoutError as e:\n        return jsonify({\n            \"success\": False,\n            \"error\": f\"等待Interview响应超时: {str(e)}\"\n        }), 504\n        \n    except Exception as e:\n        logger.error(f\"Interview失败: {str(e)}\")\n        return jsonify({\n            \"success\": False,\n            \"error\": str(e),\n            \"traceback\": traceback.format_exc()\n        }), 500\n\n\n@simulation_bp.route('/interview/batch', methods=['POST'])\ndef interview_agents_batch():\n    \"\"\"\n    批量采访多个Agent\n\n    注意：此功能需要模拟环境处于运行状态\n\n    请求（JSON）：\n        {\n            \"simulation_id\": \"sim_xxxx\",       // 必填，模拟ID\n            \"interviews\": [                    // 必填，采访列表\n                {\n                    \"agent_id\": 0,\n                    \"prompt\": \"你对A有什么看法？\",\n                    \"platform\": \"twitter\"      // 可选，指定该Agent的采访平台\n                },\n                {\n                    \"agent_id\": 1,\n                    \"prompt\": \"你对B有什么看法？\"  // 不指定platform则使用默认值\n                }\n            ],\n            \"platform\": \"reddit\",              // 可选，默认平台（被每项的platform覆盖）\n                                               // 不指定时：双平台模拟每个Agent同时采访两个平台\n            \"timeout\": 120                     // 可选，超时时间（秒），默认120\n        }\n\n    返回：\n        {\n            \"success\": true,\n            \"data\": {\n                \"interviews_count\": 2,\n                \"result\": {\n                    \"interviews_count\": 4,\n                    \"results\": {\n                        \"twitter_0\": {\"agent_id\": 0, \"response\": \"...\", \"platform\": \"twitter\"},\n                        \"reddit_0\": {\"agent_id\": 0, \"response\": \"...\", \"platform\": \"reddit\"},\n                        \"twitter_1\": {\"agent_id\": 1, \"response\": \"...\", \"platform\": \"twitter\"},\n                        \"reddit_1\": {\"agent_id\": 1, \"response\": \"...\", \"platform\": \"reddit\"}\n                    }\n                },\n                \"timestamp\": \"2025-12-08T10:00:01\"\n            }\n        }\n    \"\"\"\n    try:\n        data = request.get_json() or {}\n\n        simulation_id = data.get('simulation_id')\n        interviews = data.get('interviews')\n        platform = data.get('platform')  # 可选：twitter/reddit/None\n        timeout = data.get('timeout', 120)\n\n        if not simulation_id:\n            return jsonify({\n                \"success\": False,\n                \"error\": \"请提供 simulation_id\"\n            }), 400\n\n        if not interviews or not isinstance(interviews, list):\n            return jsonify({\n                \"success\": False,\n                \"error\": \"请提供 interviews（采访列表）\"\n            }), 400\n\n        # 验证platform参数\n        if platform and platform not in (\"twitter\", \"reddit\"):\n            return jsonify({\n                \"success\": False,\n                \"error\": \"platform 参数只能是 'twitter' 或 'reddit'\"\n            }), 400\n\n        # 验证每个采访项\n        for i, interview in enumerate(interviews):\n            if 'agent_id' not in interview:\n                return jsonify({\n                    \"success\": False,\n                    \"error\": f\"采访列表第{i+1}项缺少 agent_id\"\n                }), 400\n            if 'prompt' not in interview:\n                return jsonify({\n                    \"success\": False,\n                    \"error\": f\"采访列表第{i+1}项缺少 prompt\"\n                }), 400\n            # 验证每项的platform（如果有）\n            item_platform = interview.get('platform')\n            if item_platform and item_platform not in (\"twitter\", \"reddit\"):\n                return jsonify({\n                    \"success\": False,\n                    \"error\": f\"采访列表第{i+1}项的platform只能是 'twitter' 或 'reddit'\"\n                }), 400\n\n        # 检查环境状态\n        if not SimulationRunner.check_env_alive(simulation_id):\n            return jsonify({\n                \"success\": False,\n                \"error\": \"模拟环境未运行或已关闭。请确保模拟已完成并进入等待命令模式。\"\n            }), 400\n\n        # 优化每个采访项的prompt，添加前缀避免Agent调用工具\n        optimized_interviews = []\n        for interview in interviews:\n            optimized_interview = interview.copy()\n            optimized_interview['prompt'] = optimize_interview_prompt(interview.get('prompt', ''))\n            optimized_interviews.append(optimized_interview)\n\n        result = SimulationRunner.interview_agents_batch(\n            simulation_id=simulation_id,\n            interviews=optimized_interviews,\n            platform=platform,\n            timeout=timeout\n        )\n\n        return jsonify({\n            \"success\": result.get(\"success\", False),\n            \"data\": result\n        })\n\n    except ValueError as e:\n        return jsonify({\n            \"success\": False,\n            \"error\": str(e)\n        }), 400\n\n    except TimeoutError as e:\n        return jsonify({\n            \"success\": False,\n            \"error\": f\"等待批量Interview响应超时: {str(e)}\"\n        }), 504\n\n    except Exception as e:\n        logger.error(f\"批量Interview失败: {str(e)}\")\n        return jsonify({\n            \"success\": False,\n            \"error\": str(e),\n            \"traceback\": traceback.format_exc()\n        }), 500\n\n\n@simulation_bp.route('/interview/all', methods=['POST'])\ndef interview_all_agents():\n    \"\"\"\n    全局采访 - 使用相同问题采访所有Agent\n\n    注意：此功能需要模拟环境处于运行状态\n\n    请求（JSON）：\n        {\n            \"simulation_id\": \"sim_xxxx\",            // 必填，模拟ID\n            \"prompt\": \"你对这件事整体有什么看法？\",  // 必填，采访问题（所有Agent使用相同问题）\n            \"platform\": \"reddit\",                   // 可选，指定平台（twitter/reddit）\n                                                    // 不指定时：双平台模拟每个Agent同时采访两个平台\n            \"timeout\": 180                          // 可选，超时时间（秒），默认180\n        }\n\n    返回：\n        {\n            \"success\": true,\n            \"data\": {\n                \"interviews_count\": 50,\n                \"result\": {\n                    \"interviews_count\": 100,\n                    \"results\": {\n                        \"twitter_0\": {\"agent_id\": 0, \"response\": \"...\", \"platform\": \"twitter\"},\n                        \"reddit_0\": {\"agent_id\": 0, \"response\": \"...\", \"platform\": \"reddit\"},\n                        ...\n                    }\n                },\n                \"timestamp\": \"2025-12-08T10:00:01\"\n            }\n        }\n    \"\"\"\n    try:\n        data = request.get_json() or {}\n\n        simulation_id = data.get('simulation_id')\n        prompt = data.get('prompt')\n        platform = data.get('platform')  # 可选：twitter/reddit/None\n        timeout = data.get('timeout', 180)\n\n        if not simulation_id:\n            return jsonify({\n                \"success\": False,\n                \"error\": \"请提供 simulation_id\"\n            }), 400\n\n        if not prompt:\n            return jsonify({\n                \"success\": False,\n                \"error\": \"请提供 prompt（采访问题）\"\n            }), 400\n\n        # 验证platform参数\n        if platform and platform not in (\"twitter\", \"reddit\"):\n            return jsonify({\n                \"success\": False,\n                \"error\": \"platform 参数只能是 'twitter' 或 'reddit'\"\n            }), 400\n\n        # 检查环境状态\n        if not SimulationRunner.check_env_alive(simulation_id):\n            return jsonify({\n                \"success\": False,\n                \"error\": \"模拟环境未运行或已关闭。请确保模拟已完成并进入等待命令模式。\"\n            }), 400\n\n        # 优化prompt，添加前缀避免Agent调用工具\n        optimized_prompt = optimize_interview_prompt(prompt)\n\n        result = SimulationRunner.interview_all_agents(\n            simulation_id=simulation_id,\n            prompt=optimized_prompt,\n            platform=platform,\n            timeout=timeout\n        )\n\n        return jsonify({\n            \"success\": result.get(\"success\", False),\n            \"data\": result\n        })\n\n    except ValueError as e:\n        return jsonify({\n            \"success\": False,\n            \"error\": str(e)\n        }), 400\n\n    except TimeoutError as e:\n        return jsonify({\n            \"success\": False,\n            \"error\": f\"等待全局Interview响应超时: {str(e)}\"\n        }), 504\n\n    except Exception as e:\n        logger.error(f\"全局Interview失败: {str(e)}\")\n        return jsonify({\n            \"success\": False,\n            \"error\": str(e),\n            \"traceback\": traceback.format_exc()\n        }), 500\n\n\n@simulation_bp.route('/interview/history', methods=['POST'])\ndef get_interview_history():\n    \"\"\"\n    获取Interview历史记录\n\n    从模拟数据库中读取所有Interview记录\n\n    请求（JSON）：\n        {\n            \"simulation_id\": \"sim_xxxx\",  // 必填，模拟ID\n            \"platform\": \"reddit\",          // 可选，平台类型（reddit/twitter）\n                                           // 不指定则返回两个平台的所有历史\n            \"agent_id\": 0,                 // 可选，只获取该Agent的采访历史\n            \"limit\": 100                   // 可选，返回数量，默认100\n        }\n\n    返回：\n        {\n            \"success\": true,\n            \"data\": {\n                \"count\": 10,\n                \"history\": [\n                    {\n                        \"agent_id\": 0,\n                        \"response\": \"我认为...\",\n                        \"prompt\": \"你对这件事有什么看法？\",\n                        \"timestamp\": \"2025-12-08T10:00:00\",\n                        \"platform\": \"reddit\"\n                    },\n                    ...\n                ]\n            }\n        }\n    \"\"\"\n    try:\n        data = request.get_json() or {}\n        \n        simulation_id = data.get('simulation_id')\n        platform = data.get('platform')  # 不指定则返回两个平台的历史\n        agent_id = data.get('agent_id')\n        limit = data.get('limit', 100)\n        \n        if not simulation_id:\n            return jsonify({\n                \"success\": False,\n                \"error\": \"请提供 simulation_id\"\n            }), 400\n\n        history = SimulationRunner.get_interview_history(\n            simulation_id=simulation_id,\n            platform=platform,\n            agent_id=agent_id,\n            limit=limit\n        )\n\n        return jsonify({\n            \"success\": True,\n            \"data\": {\n                \"count\": len(history),\n                \"history\": history\n            }\n        })\n\n    except Exception as e:\n        logger.error(f\"获取Interview历史失败: {str(e)}\")\n        return jsonify({\n            \"success\": False,\n            \"error\": str(e),\n            \"traceback\": traceback.format_exc()\n        }), 500\n\n\n@simulation_bp.route('/env-status', methods=['POST'])\ndef get_env_status():\n    \"\"\"\n    获取模拟环境状态\n\n    检查模拟环境是否存活（可以接收Interview命令）\n\n    请求（JSON）：\n        {\n            \"simulation_id\": \"sim_xxxx\"  // 必填，模拟ID\n        }\n\n    返回：\n        {\n            \"success\": true,\n            \"data\": {\n                \"simulation_id\": \"sim_xxxx\",\n                \"env_alive\": true,\n                \"twitter_available\": true,\n                \"reddit_available\": true,\n                \"message\": \"环境正在运行，可以接收Interview命令\"\n            }\n        }\n    \"\"\"\n    try:\n        data = request.get_json() or {}\n        \n        simulation_id = data.get('simulation_id')\n        \n        if not simulation_id:\n            return jsonify({\n                \"success\": False,\n                \"error\": \"请提供 simulation_id\"\n            }), 400\n\n        env_alive = SimulationRunner.check_env_alive(simulation_id)\n        \n        # 获取更详细的状态信息\n        env_status = SimulationRunner.get_env_status_detail(simulation_id)\n\n        if env_alive:\n            message = \"环境正在运行，可以接收Interview命令\"\n        else:\n            message = \"环境未运行或已关闭\"\n\n        return jsonify({\n            \"success\": True,\n            \"data\": {\n                \"simulation_id\": simulation_id,\n                \"env_alive\": env_alive,\n                \"twitter_available\": env_status.get(\"twitter_available\", False),\n                \"reddit_available\": env_status.get(\"reddit_available\", False),\n                \"message\": message\n            }\n        })\n\n    except Exception as e:\n        logger.error(f\"获取环境状态失败: {str(e)}\")\n        return jsonify({\n            \"success\": False,\n            \"error\": str(e),\n            \"traceback\": traceback.format_exc()\n        }), 500\n\n\n@simulation_bp.route('/close-env', methods=['POST'])\ndef close_simulation_env():\n    \"\"\"\n    关闭模拟环境\n    \n    向模拟发送关闭环境命令，使其优雅退出等待命令模式。\n    \n    注意：这不同于 /stop 接口，/stop 会强制终止进程，\n    而此接口会让模拟优雅地关闭环境并退出。\n    \n    请求（JSON）：\n        {\n            \"simulation_id\": \"sim_xxxx\",  // 必填，模拟ID\n            \"timeout\": 30                  // 可选，超时时间（秒），默认30\n        }\n    \n    返回：\n        {\n            \"success\": true,\n            \"data\": {\n                \"message\": \"环境关闭命令已发送\",\n                \"result\": {...},\n                \"timestamp\": \"2025-12-08T10:00:01\"\n            }\n        }\n    \"\"\"\n    try:\n        data = request.get_json() or {}\n        \n        simulation_id = data.get('simulation_id')\n        timeout = data.get('timeout', 30)\n        \n        if not simulation_id:\n            return jsonify({\n                \"success\": False,\n                \"error\": \"请提供 simulation_id\"\n            }), 400\n        \n        result = SimulationRunner.close_simulation_env(\n            simulation_id=simulation_id,\n            timeout=timeout\n        )\n        \n        # 更新模拟状态\n        manager = SimulationManager()\n        state = manager.get_simulation(simulation_id)\n        if state:\n            state.status = SimulationStatus.COMPLETED\n            manager._save_simulation_state(state)\n        \n        return jsonify({\n            \"success\": result.get(\"success\", False),\n            \"data\": result\n        })\n        \n    except ValueError as e:\n        return jsonify({\n            \"success\": False,\n            \"error\": str(e)\n        }), 400\n        \n    except Exception as e:\n        logger.error(f\"关闭环境失败: {str(e)}\")\n        return jsonify({\n            \"success\": False,\n            \"error\": str(e),\n            \"traceback\": traceback.format_exc()\n        }), 500\n"
  },
  {
    "path": "backend/app/config.py",
    "content": "\"\"\"\n配置管理\n统一从项目根目录的 .env 文件加载配置\n\"\"\"\n\nimport os\nfrom dotenv import load_dotenv\n\n# 加载项目根目录的 .env 文件\n# 路径: MiroFish/.env (相对于 backend/app/config.py)\nproject_root_env = os.path.join(os.path.dirname(__file__), '../../.env')\n\nif os.path.exists(project_root_env):\n    load_dotenv(project_root_env, override=True)\nelse:\n    # 如果根目录没有 .env，尝试加载环境变量（用于生产环境）\n    load_dotenv(override=True)\n\n\nclass Config:\n    \"\"\"Flask配置类\"\"\"\n    \n    # Flask配置\n    SECRET_KEY = os.environ.get('SECRET_KEY', 'mirofish-secret-key')\n    DEBUG = os.environ.get('FLASK_DEBUG', 'True').lower() == 'true'\n    \n    # JSON配置 - 禁用ASCII转义，让中文直接显示（而不是 \\uXXXX 格式）\n    JSON_AS_ASCII = False\n    \n    # LLM配置（统一使用OpenAI格式）\n    LLM_API_KEY = os.environ.get('LLM_API_KEY')\n    LLM_BASE_URL = os.environ.get('LLM_BASE_URL', 'https://api.openai.com/v1')\n    LLM_MODEL_NAME = os.environ.get('LLM_MODEL_NAME', 'gpt-4o-mini')\n    \n    # Zep配置\n    ZEP_API_KEY = os.environ.get('ZEP_API_KEY')\n    \n    # 文件上传配置\n    MAX_CONTENT_LENGTH = 50 * 1024 * 1024  # 50MB\n    UPLOAD_FOLDER = os.path.join(os.path.dirname(__file__), '../uploads')\n    ALLOWED_EXTENSIONS = {'pdf', 'md', 'txt', 'markdown'}\n    \n    # 文本处理配置\n    DEFAULT_CHUNK_SIZE = 500  # 默认切块大小\n    DEFAULT_CHUNK_OVERLAP = 50  # 默认重叠大小\n    \n    # OASIS模拟配置\n    OASIS_DEFAULT_MAX_ROUNDS = int(os.environ.get('OASIS_DEFAULT_MAX_ROUNDS', '10'))\n    OASIS_SIMULATION_DATA_DIR = os.path.join(os.path.dirname(__file__), '../uploads/simulations')\n    \n    # OASIS平台可用动作配置\n    OASIS_TWITTER_ACTIONS = [\n        'CREATE_POST', 'LIKE_POST', 'REPOST', 'FOLLOW', 'DO_NOTHING', 'QUOTE_POST'\n    ]\n    OASIS_REDDIT_ACTIONS = [\n        'LIKE_POST', 'DISLIKE_POST', 'CREATE_POST', 'CREATE_COMMENT',\n        'LIKE_COMMENT', 'DISLIKE_COMMENT', 'SEARCH_POSTS', 'SEARCH_USER',\n        'TREND', 'REFRESH', 'DO_NOTHING', 'FOLLOW', 'MUTE'\n    ]\n    \n    # Report Agent配置\n    REPORT_AGENT_MAX_TOOL_CALLS = int(os.environ.get('REPORT_AGENT_MAX_TOOL_CALLS', '5'))\n    REPORT_AGENT_MAX_REFLECTION_ROUNDS = int(os.environ.get('REPORT_AGENT_MAX_REFLECTION_ROUNDS', '2'))\n    REPORT_AGENT_TEMPERATURE = float(os.environ.get('REPORT_AGENT_TEMPERATURE', '0.5'))\n    \n    @classmethod\n    def validate(cls):\n        \"\"\"验证必要配置\"\"\"\n        errors = []\n        if not cls.LLM_API_KEY:\n            errors.append(\"LLM_API_KEY 未配置\")\n        if not cls.ZEP_API_KEY:\n            errors.append(\"ZEP_API_KEY 未配置\")\n        return errors\n\n"
  },
  {
    "path": "backend/app/models/__init__.py",
    "content": "\"\"\"\n数据模型模块\n\"\"\"\n\nfrom .task import TaskManager, TaskStatus\nfrom .project import Project, ProjectStatus, ProjectManager\n\n__all__ = ['TaskManager', 'TaskStatus', 'Project', 'ProjectStatus', 'ProjectManager']\n\n"
  },
  {
    "path": "backend/app/models/project.py",
    "content": "\"\"\"\n项目上下文管理\n用于在服务端持久化项目状态，避免前端在接口间传递大量数据\n\"\"\"\n\nimport os\nimport json\nimport uuid\nimport shutil\nfrom datetime import datetime\nfrom typing import Dict, Any, List, Optional\nfrom enum import Enum\nfrom dataclasses import dataclass, field, asdict\nfrom ..config import Config\n\n\nclass ProjectStatus(str, Enum):\n    \"\"\"项目状态\"\"\"\n    CREATED = \"created\"              # 刚创建，文件已上传\n    ONTOLOGY_GENERATED = \"ontology_generated\"  # 本体已生成\n    GRAPH_BUILDING = \"graph_building\"    # 图谱构建中\n    GRAPH_COMPLETED = \"graph_completed\"  # 图谱构建完成\n    FAILED = \"failed\"                # 失败\n\n\n@dataclass\nclass Project:\n    \"\"\"项目数据模型\"\"\"\n    project_id: str\n    name: str\n    status: ProjectStatus\n    created_at: str\n    updated_at: str\n    \n    # 文件信息\n    files: List[Dict[str, str]] = field(default_factory=list)  # [{filename, path, size}]\n    total_text_length: int = 0\n    \n    # 本体信息（接口1生成后填充）\n    ontology: Optional[Dict[str, Any]] = None\n    analysis_summary: Optional[str] = None\n    \n    # 图谱信息（接口2完成后填充）\n    graph_id: Optional[str] = None\n    graph_build_task_id: Optional[str] = None\n    \n    # 配置\n    simulation_requirement: Optional[str] = None\n    chunk_size: int = 500\n    chunk_overlap: int = 50\n    \n    # 错误信息\n    error: Optional[str] = None\n    \n    def to_dict(self) -> Dict[str, Any]:\n        \"\"\"转换为字典\"\"\"\n        return {\n            \"project_id\": self.project_id,\n            \"name\": self.name,\n            \"status\": self.status.value if isinstance(self.status, ProjectStatus) else self.status,\n            \"created_at\": self.created_at,\n            \"updated_at\": self.updated_at,\n            \"files\": self.files,\n            \"total_text_length\": self.total_text_length,\n            \"ontology\": self.ontology,\n            \"analysis_summary\": self.analysis_summary,\n            \"graph_id\": self.graph_id,\n            \"graph_build_task_id\": self.graph_build_task_id,\n            \"simulation_requirement\": self.simulation_requirement,\n            \"chunk_size\": self.chunk_size,\n            \"chunk_overlap\": self.chunk_overlap,\n            \"error\": self.error\n        }\n    \n    @classmethod\n    def from_dict(cls, data: Dict[str, Any]) -> 'Project':\n        \"\"\"从字典创建\"\"\"\n        status = data.get('status', 'created')\n        if isinstance(status, str):\n            status = ProjectStatus(status)\n        \n        return cls(\n            project_id=data['project_id'],\n            name=data.get('name', 'Unnamed Project'),\n            status=status,\n            created_at=data.get('created_at', ''),\n            updated_at=data.get('updated_at', ''),\n            files=data.get('files', []),\n            total_text_length=data.get('total_text_length', 0),\n            ontology=data.get('ontology'),\n            analysis_summary=data.get('analysis_summary'),\n            graph_id=data.get('graph_id'),\n            graph_build_task_id=data.get('graph_build_task_id'),\n            simulation_requirement=data.get('simulation_requirement'),\n            chunk_size=data.get('chunk_size', 500),\n            chunk_overlap=data.get('chunk_overlap', 50),\n            error=data.get('error')\n        )\n\n\nclass ProjectManager:\n    \"\"\"项目管理器 - 负责项目的持久化存储和检索\"\"\"\n    \n    # 项目存储根目录\n    PROJECTS_DIR = os.path.join(Config.UPLOAD_FOLDER, 'projects')\n    \n    @classmethod\n    def _ensure_projects_dir(cls):\n        \"\"\"确保项目目录存在\"\"\"\n        os.makedirs(cls.PROJECTS_DIR, exist_ok=True)\n    \n    @classmethod\n    def _get_project_dir(cls, project_id: str) -> str:\n        \"\"\"获取项目目录路径\"\"\"\n        return os.path.join(cls.PROJECTS_DIR, project_id)\n    \n    @classmethod\n    def _get_project_meta_path(cls, project_id: str) -> str:\n        \"\"\"获取项目元数据文件路径\"\"\"\n        return os.path.join(cls._get_project_dir(project_id), 'project.json')\n    \n    @classmethod\n    def _get_project_files_dir(cls, project_id: str) -> str:\n        \"\"\"获取项目文件存储目录\"\"\"\n        return os.path.join(cls._get_project_dir(project_id), 'files')\n    \n    @classmethod\n    def _get_project_text_path(cls, project_id: str) -> str:\n        \"\"\"获取项目提取文本存储路径\"\"\"\n        return os.path.join(cls._get_project_dir(project_id), 'extracted_text.txt')\n    \n    @classmethod\n    def create_project(cls, name: str = \"Unnamed Project\") -> Project:\n        \"\"\"\n        创建新项目\n        \n        Args:\n            name: 项目名称\n            \n        Returns:\n            新创建的Project对象\n        \"\"\"\n        cls._ensure_projects_dir()\n        \n        project_id = f\"proj_{uuid.uuid4().hex[:12]}\"\n        now = datetime.now().isoformat()\n        \n        project = Project(\n            project_id=project_id,\n            name=name,\n            status=ProjectStatus.CREATED,\n            created_at=now,\n            updated_at=now\n        )\n        \n        # 创建项目目录结构\n        project_dir = cls._get_project_dir(project_id)\n        files_dir = cls._get_project_files_dir(project_id)\n        os.makedirs(project_dir, exist_ok=True)\n        os.makedirs(files_dir, exist_ok=True)\n        \n        # 保存项目元数据\n        cls.save_project(project)\n        \n        return project\n    \n    @classmethod\n    def save_project(cls, project: Project) -> None:\n        \"\"\"保存项目元数据\"\"\"\n        project.updated_at = datetime.now().isoformat()\n        meta_path = cls._get_project_meta_path(project.project_id)\n        \n        with open(meta_path, 'w', encoding='utf-8') as f:\n            json.dump(project.to_dict(), f, ensure_ascii=False, indent=2)\n    \n    @classmethod\n    def get_project(cls, project_id: str) -> Optional[Project]:\n        \"\"\"\n        获取项目\n        \n        Args:\n            project_id: 项目ID\n            \n        Returns:\n            Project对象，如果不存在返回None\n        \"\"\"\n        meta_path = cls._get_project_meta_path(project_id)\n        \n        if not os.path.exists(meta_path):\n            return None\n        \n        with open(meta_path, 'r', encoding='utf-8') as f:\n            data = json.load(f)\n        \n        return Project.from_dict(data)\n    \n    @classmethod\n    def list_projects(cls, limit: int = 50) -> List[Project]:\n        \"\"\"\n        列出所有项目\n        \n        Args:\n            limit: 返回数量限制\n            \n        Returns:\n            项目列表，按创建时间倒序\n        \"\"\"\n        cls._ensure_projects_dir()\n        \n        projects = []\n        for project_id in os.listdir(cls.PROJECTS_DIR):\n            project = cls.get_project(project_id)\n            if project:\n                projects.append(project)\n        \n        # 按创建时间倒序排序\n        projects.sort(key=lambda p: p.created_at, reverse=True)\n        \n        return projects[:limit]\n    \n    @classmethod\n    def delete_project(cls, project_id: str) -> bool:\n        \"\"\"\n        删除项目及其所有文件\n        \n        Args:\n            project_id: 项目ID\n            \n        Returns:\n            是否删除成功\n        \"\"\"\n        project_dir = cls._get_project_dir(project_id)\n        \n        if not os.path.exists(project_dir):\n            return False\n        \n        shutil.rmtree(project_dir)\n        return True\n    \n    @classmethod\n    def save_file_to_project(cls, project_id: str, file_storage, original_filename: str) -> Dict[str, str]:\n        \"\"\"\n        保存上传的文件到项目目录\n        \n        Args:\n            project_id: 项目ID\n            file_storage: Flask的FileStorage对象\n            original_filename: 原始文件名\n            \n        Returns:\n            文件信息字典 {filename, path, size}\n        \"\"\"\n        files_dir = cls._get_project_files_dir(project_id)\n        os.makedirs(files_dir, exist_ok=True)\n        \n        # 生成安全的文件名\n        ext = os.path.splitext(original_filename)[1].lower()\n        safe_filename = f\"{uuid.uuid4().hex[:8]}{ext}\"\n        file_path = os.path.join(files_dir, safe_filename)\n        \n        # 保存文件\n        file_storage.save(file_path)\n        \n        # 获取文件大小\n        file_size = os.path.getsize(file_path)\n        \n        return {\n            \"original_filename\": original_filename,\n            \"saved_filename\": safe_filename,\n            \"path\": file_path,\n            \"size\": file_size\n        }\n    \n    @classmethod\n    def save_extracted_text(cls, project_id: str, text: str) -> None:\n        \"\"\"保存提取的文本\"\"\"\n        text_path = cls._get_project_text_path(project_id)\n        with open(text_path, 'w', encoding='utf-8') as f:\n            f.write(text)\n    \n    @classmethod\n    def get_extracted_text(cls, project_id: str) -> Optional[str]:\n        \"\"\"获取提取的文本\"\"\"\n        text_path = cls._get_project_text_path(project_id)\n        \n        if not os.path.exists(text_path):\n            return None\n        \n        with open(text_path, 'r', encoding='utf-8') as f:\n            return f.read()\n    \n    @classmethod\n    def get_project_files(cls, project_id: str) -> List[str]:\n        \"\"\"获取项目的所有文件路径\"\"\"\n        files_dir = cls._get_project_files_dir(project_id)\n        \n        if not os.path.exists(files_dir):\n            return []\n        \n        return [\n            os.path.join(files_dir, f) \n            for f in os.listdir(files_dir) \n            if os.path.isfile(os.path.join(files_dir, f))\n        ]\n\n"
  },
  {
    "path": "backend/app/models/task.py",
    "content": "\"\"\"\n任务状态管理\n用于跟踪长时间运行的任务（如图谱构建）\n\"\"\"\n\nimport uuid\nimport threading\nfrom datetime import datetime\nfrom enum import Enum\nfrom typing import Dict, Any, Optional\nfrom dataclasses import dataclass, field\n\n\nclass TaskStatus(str, Enum):\n    \"\"\"任务状态枚举\"\"\"\n    PENDING = \"pending\"          # 等待中\n    PROCESSING = \"processing\"    # 处理中\n    COMPLETED = \"completed\"      # 已完成\n    FAILED = \"failed\"            # 失败\n\n\n@dataclass\nclass Task:\n    \"\"\"任务数据类\"\"\"\n    task_id: str\n    task_type: str\n    status: TaskStatus\n    created_at: datetime\n    updated_at: datetime\n    progress: int = 0              # 总进度百分比 0-100\n    message: str = \"\"              # 状态消息\n    result: Optional[Dict] = None  # 任务结果\n    error: Optional[str] = None    # 错误信息\n    metadata: Dict = field(default_factory=dict)  # 额外元数据\n    progress_detail: Dict = field(default_factory=dict)  # 详细进度信息\n    \n    def to_dict(self) -> Dict[str, Any]:\n        \"\"\"转换为字典\"\"\"\n        return {\n            \"task_id\": self.task_id,\n            \"task_type\": self.task_type,\n            \"status\": self.status.value,\n            \"created_at\": self.created_at.isoformat(),\n            \"updated_at\": self.updated_at.isoformat(),\n            \"progress\": self.progress,\n            \"message\": self.message,\n            \"progress_detail\": self.progress_detail,\n            \"result\": self.result,\n            \"error\": self.error,\n            \"metadata\": self.metadata,\n        }\n\n\nclass TaskManager:\n    \"\"\"\n    任务管理器\n    线程安全的任务状态管理\n    \"\"\"\n    \n    _instance = None\n    _lock = threading.Lock()\n    \n    def __new__(cls):\n        \"\"\"单例模式\"\"\"\n        if cls._instance is None:\n            with cls._lock:\n                if cls._instance is None:\n                    cls._instance = super().__new__(cls)\n                    cls._instance._tasks: Dict[str, Task] = {}\n                    cls._instance._task_lock = threading.Lock()\n        return cls._instance\n    \n    def create_task(self, task_type: str, metadata: Optional[Dict] = None) -> str:\n        \"\"\"\n        创建新任务\n        \n        Args:\n            task_type: 任务类型\n            metadata: 额外元数据\n            \n        Returns:\n            任务ID\n        \"\"\"\n        task_id = str(uuid.uuid4())\n        now = datetime.now()\n        \n        task = Task(\n            task_id=task_id,\n            task_type=task_type,\n            status=TaskStatus.PENDING,\n            created_at=now,\n            updated_at=now,\n            metadata=metadata or {}\n        )\n        \n        with self._task_lock:\n            self._tasks[task_id] = task\n        \n        return task_id\n    \n    def get_task(self, task_id: str) -> Optional[Task]:\n        \"\"\"获取任务\"\"\"\n        with self._task_lock:\n            return self._tasks.get(task_id)\n    \n    def update_task(\n        self,\n        task_id: str,\n        status: Optional[TaskStatus] = None,\n        progress: Optional[int] = None,\n        message: Optional[str] = None,\n        result: Optional[Dict] = None,\n        error: Optional[str] = None,\n        progress_detail: Optional[Dict] = None\n    ):\n        \"\"\"\n        更新任务状态\n        \n        Args:\n            task_id: 任务ID\n            status: 新状态\n            progress: 进度\n            message: 消息\n            result: 结果\n            error: 错误信息\n            progress_detail: 详细进度信息\n        \"\"\"\n        with self._task_lock:\n            task = self._tasks.get(task_id)\n            if task:\n                task.updated_at = datetime.now()\n                if status is not None:\n                    task.status = status\n                if progress is not None:\n                    task.progress = progress\n                if message is not None:\n                    task.message = message\n                if result is not None:\n                    task.result = result\n                if error is not None:\n                    task.error = error\n                if progress_detail is not None:\n                    task.progress_detail = progress_detail\n    \n    def complete_task(self, task_id: str, result: Dict):\n        \"\"\"标记任务完成\"\"\"\n        self.update_task(\n            task_id,\n            status=TaskStatus.COMPLETED,\n            progress=100,\n            message=\"任务完成\",\n            result=result\n        )\n    \n    def fail_task(self, task_id: str, error: str):\n        \"\"\"标记任务失败\"\"\"\n        self.update_task(\n            task_id,\n            status=TaskStatus.FAILED,\n            message=\"任务失败\",\n            error=error\n        )\n    \n    def list_tasks(self, task_type: Optional[str] = None) -> list:\n        \"\"\"列出任务\"\"\"\n        with self._task_lock:\n            tasks = list(self._tasks.values())\n            if task_type:\n                tasks = [t for t in tasks if t.task_type == task_type]\n            return [t.to_dict() for t in sorted(tasks, key=lambda x: x.created_at, reverse=True)]\n    \n    def cleanup_old_tasks(self, max_age_hours: int = 24):\n        \"\"\"清理旧任务\"\"\"\n        from datetime import timedelta\n        cutoff = datetime.now() - timedelta(hours=max_age_hours)\n        \n        with self._task_lock:\n            old_ids = [\n                tid for tid, task in self._tasks.items()\n                if task.created_at < cutoff and task.status in [TaskStatus.COMPLETED, TaskStatus.FAILED]\n            ]\n            for tid in old_ids:\n                del self._tasks[tid]\n\n"
  },
  {
    "path": "backend/app/services/__init__.py",
    "content": "\"\"\"\n业务服务模块\n\"\"\"\n\nfrom .ontology_generator import OntologyGenerator\nfrom .graph_builder import GraphBuilderService\nfrom .text_processor import TextProcessor\nfrom .zep_entity_reader import ZepEntityReader, EntityNode, FilteredEntities\nfrom .oasis_profile_generator import OasisProfileGenerator, OasisAgentProfile\nfrom .simulation_manager import SimulationManager, SimulationState, SimulationStatus\nfrom .simulation_config_generator import (\n    SimulationConfigGenerator, \n    SimulationParameters,\n    AgentActivityConfig,\n    TimeSimulationConfig,\n    EventConfig,\n    PlatformConfig\n)\nfrom .simulation_runner import (\n    SimulationRunner,\n    SimulationRunState,\n    RunnerStatus,\n    AgentAction,\n    RoundSummary\n)\nfrom .zep_graph_memory_updater import (\n    ZepGraphMemoryUpdater,\n    ZepGraphMemoryManager,\n    AgentActivity\n)\nfrom .simulation_ipc import (\n    SimulationIPCClient,\n    SimulationIPCServer,\n    IPCCommand,\n    IPCResponse,\n    CommandType,\n    CommandStatus\n)\n\n__all__ = [\n    'OntologyGenerator', \n    'GraphBuilderService', \n    'TextProcessor',\n    'ZepEntityReader',\n    'EntityNode',\n    'FilteredEntities',\n    'OasisProfileGenerator',\n    'OasisAgentProfile',\n    'SimulationManager',\n    'SimulationState',\n    'SimulationStatus',\n    'SimulationConfigGenerator',\n    'SimulationParameters',\n    'AgentActivityConfig',\n    'TimeSimulationConfig',\n    'EventConfig',\n    'PlatformConfig',\n    'SimulationRunner',\n    'SimulationRunState',\n    'RunnerStatus',\n    'AgentAction',\n    'RoundSummary',\n    'ZepGraphMemoryUpdater',\n    'ZepGraphMemoryManager',\n    'AgentActivity',\n    'SimulationIPCClient',\n    'SimulationIPCServer',\n    'IPCCommand',\n    'IPCResponse',\n    'CommandType',\n    'CommandStatus',\n]\n\n"
  },
  {
    "path": "backend/app/services/graph_builder.py",
    "content": "\"\"\"\n图谱构建服务\n接口2：使用Zep API构建Standalone Graph\n\"\"\"\n\nimport os\nimport uuid\nimport time\nimport threading\nfrom typing import Dict, Any, List, Optional, Callable\nfrom dataclasses import dataclass\n\nfrom zep_cloud.client import Zep\nfrom zep_cloud import EpisodeData, EntityEdgeSourceTarget\n\nfrom ..config import Config\nfrom ..models.task import TaskManager, TaskStatus\nfrom ..utils.zep_paging import fetch_all_nodes, fetch_all_edges\nfrom .text_processor import TextProcessor\n\n\n@dataclass\nclass GraphInfo:\n    \"\"\"图谱信息\"\"\"\n    graph_id: str\n    node_count: int\n    edge_count: int\n    entity_types: List[str]\n    \n    def to_dict(self) -> Dict[str, Any]:\n        return {\n            \"graph_id\": self.graph_id,\n            \"node_count\": self.node_count,\n            \"edge_count\": self.edge_count,\n            \"entity_types\": self.entity_types,\n        }\n\n\nclass GraphBuilderService:\n    \"\"\"\n    图谱构建服务\n    负责调用Zep API构建知识图谱\n    \"\"\"\n    \n    def __init__(self, api_key: Optional[str] = None):\n        self.api_key = api_key or Config.ZEP_API_KEY\n        if not self.api_key:\n            raise ValueError(\"ZEP_API_KEY 未配置\")\n        \n        self.client = Zep(api_key=self.api_key)\n        self.task_manager = TaskManager()\n    \n    def build_graph_async(\n        self,\n        text: str,\n        ontology: Dict[str, Any],\n        graph_name: str = \"MiroFish Graph\",\n        chunk_size: int = 500,\n        chunk_overlap: int = 50,\n        batch_size: int = 3\n    ) -> str:\n        \"\"\"\n        异步构建图谱\n        \n        Args:\n            text: 输入文本\n            ontology: 本体定义（来自接口1的输出）\n            graph_name: 图谱名称\n            chunk_size: 文本块大小\n            chunk_overlap: 块重叠大小\n            batch_size: 每批发送的块数量\n            \n        Returns:\n            任务ID\n        \"\"\"\n        # 创建任务\n        task_id = self.task_manager.create_task(\n            task_type=\"graph_build\",\n            metadata={\n                \"graph_name\": graph_name,\n                \"chunk_size\": chunk_size,\n                \"text_length\": len(text),\n            }\n        )\n        \n        # 在后台线程中执行构建\n        thread = threading.Thread(\n            target=self._build_graph_worker,\n            args=(task_id, text, ontology, graph_name, chunk_size, chunk_overlap, batch_size)\n        )\n        thread.daemon = True\n        thread.start()\n        \n        return task_id\n    \n    def _build_graph_worker(\n        self,\n        task_id: str,\n        text: str,\n        ontology: Dict[str, Any],\n        graph_name: str,\n        chunk_size: int,\n        chunk_overlap: int,\n        batch_size: int\n    ):\n        \"\"\"图谱构建工作线程\"\"\"\n        try:\n            self.task_manager.update_task(\n                task_id,\n                status=TaskStatus.PROCESSING,\n                progress=5,\n                message=\"开始构建图谱...\"\n            )\n            \n            # 1. 创建图谱\n            graph_id = self.create_graph(graph_name)\n            self.task_manager.update_task(\n                task_id,\n                progress=10,\n                message=f\"图谱已创建: {graph_id}\"\n            )\n            \n            # 2. 设置本体\n            self.set_ontology(graph_id, ontology)\n            self.task_manager.update_task(\n                task_id,\n                progress=15,\n                message=\"本体已设置\"\n            )\n            \n            # 3. 文本分块\n            chunks = TextProcessor.split_text(text, chunk_size, chunk_overlap)\n            total_chunks = len(chunks)\n            self.task_manager.update_task(\n                task_id,\n                progress=20,\n                message=f\"文本已分割为 {total_chunks} 个块\"\n            )\n            \n            # 4. 分批发送数据\n            episode_uuids = self.add_text_batches(\n                graph_id, chunks, batch_size,\n                lambda msg, prog: self.task_manager.update_task(\n                    task_id,\n                    progress=20 + int(prog * 0.4),  # 20-60%\n                    message=msg\n                )\n            )\n            \n            # 5. 等待Zep处理完成\n            self.task_manager.update_task(\n                task_id,\n                progress=60,\n                message=\"等待Zep处理数据...\"\n            )\n            \n            self._wait_for_episodes(\n                episode_uuids,\n                lambda msg, prog: self.task_manager.update_task(\n                    task_id,\n                    progress=60 + int(prog * 0.3),  # 60-90%\n                    message=msg\n                )\n            )\n            \n            # 6. 获取图谱信息\n            self.task_manager.update_task(\n                task_id,\n                progress=90,\n                message=\"获取图谱信息...\"\n            )\n            \n            graph_info = self._get_graph_info(graph_id)\n            \n            # 完成\n            self.task_manager.complete_task(task_id, {\n                \"graph_id\": graph_id,\n                \"graph_info\": graph_info.to_dict(),\n                \"chunks_processed\": total_chunks,\n            })\n            \n        except Exception as e:\n            import traceback\n            error_msg = f\"{str(e)}\\n{traceback.format_exc()}\"\n            self.task_manager.fail_task(task_id, error_msg)\n    \n    def create_graph(self, name: str) -> str:\n        \"\"\"创建Zep图谱（公开方法）\"\"\"\n        graph_id = f\"mirofish_{uuid.uuid4().hex[:16]}\"\n        \n        self.client.graph.create(\n            graph_id=graph_id,\n            name=name,\n            description=\"MiroFish Social Simulation Graph\"\n        )\n        \n        return graph_id\n    \n    def set_ontology(self, graph_id: str, ontology: Dict[str, Any]):\n        \"\"\"设置图谱本体（公开方法）\"\"\"\n        import warnings\n        from typing import Optional\n        from pydantic import Field\n        from zep_cloud.external_clients.ontology import EntityModel, EntityText, EdgeModel\n        \n        # 抑制 Pydantic v2 关于 Field(default=None) 的警告\n        # 这是 Zep SDK 要求的用法，警告来自动态类创建，可以安全忽略\n        warnings.filterwarnings('ignore', category=UserWarning, module='pydantic')\n        \n        # Zep 保留名称，不能作为属性名\n        RESERVED_NAMES = {'uuid', 'name', 'group_id', 'name_embedding', 'summary', 'created_at'}\n        \n        def safe_attr_name(attr_name: str) -> str:\n            \"\"\"将保留名称转换为安全名称\"\"\"\n            if attr_name.lower() in RESERVED_NAMES:\n                return f\"entity_{attr_name}\"\n            return attr_name\n        \n        # 动态创建实体类型\n        entity_types = {}\n        for entity_def in ontology.get(\"entity_types\", []):\n            name = entity_def[\"name\"]\n            description = entity_def.get(\"description\", f\"A {name} entity.\")\n            \n            # 创建属性字典和类型注解（Pydantic v2 需要）\n            attrs = {\"__doc__\": description}\n            annotations = {}\n            \n            for attr_def in entity_def.get(\"attributes\", []):\n                attr_name = safe_attr_name(attr_def[\"name\"])  # 使用安全名称\n                attr_desc = attr_def.get(\"description\", attr_name)\n                # Zep API 需要 Field 的 description，这是必需的\n                attrs[attr_name] = Field(description=attr_desc, default=None)\n                annotations[attr_name] = Optional[EntityText]  # 类型注解\n            \n            attrs[\"__annotations__\"] = annotations\n            \n            # 动态创建类\n            entity_class = type(name, (EntityModel,), attrs)\n            entity_class.__doc__ = description\n            entity_types[name] = entity_class\n        \n        # 动态创建边类型\n        edge_definitions = {}\n        for edge_def in ontology.get(\"edge_types\", []):\n            name = edge_def[\"name\"]\n            description = edge_def.get(\"description\", f\"A {name} relationship.\")\n            \n            # 创建属性字典和类型注解\n            attrs = {\"__doc__\": description}\n            annotations = {}\n            \n            for attr_def in edge_def.get(\"attributes\", []):\n                attr_name = safe_attr_name(attr_def[\"name\"])  # 使用安全名称\n                attr_desc = attr_def.get(\"description\", attr_name)\n                # Zep API 需要 Field 的 description，这是必需的\n                attrs[attr_name] = Field(description=attr_desc, default=None)\n                annotations[attr_name] = Optional[str]  # 边属性用str类型\n            \n            attrs[\"__annotations__\"] = annotations\n            \n            # 动态创建类\n            class_name = ''.join(word.capitalize() for word in name.split('_'))\n            edge_class = type(class_name, (EdgeModel,), attrs)\n            edge_class.__doc__ = description\n            \n            # 构建source_targets\n            source_targets = []\n            for st in edge_def.get(\"source_targets\", []):\n                source_targets.append(\n                    EntityEdgeSourceTarget(\n                        source=st.get(\"source\", \"Entity\"),\n                        target=st.get(\"target\", \"Entity\")\n                    )\n                )\n            \n            if source_targets:\n                edge_definitions[name] = (edge_class, source_targets)\n        \n        # 调用Zep API设置本体\n        if entity_types or edge_definitions:\n            self.client.graph.set_ontology(\n                graph_ids=[graph_id],\n                entities=entity_types if entity_types else None,\n                edges=edge_definitions if edge_definitions else None,\n            )\n    \n    def add_text_batches(\n        self,\n        graph_id: str,\n        chunks: List[str],\n        batch_size: int = 3,\n        progress_callback: Optional[Callable] = None\n    ) -> List[str]:\n        \"\"\"分批添加文本到图谱，返回所有 episode 的 uuid 列表\"\"\"\n        episode_uuids = []\n        total_chunks = len(chunks)\n        \n        for i in range(0, total_chunks, batch_size):\n            batch_chunks = chunks[i:i + batch_size]\n            batch_num = i // batch_size + 1\n            total_batches = (total_chunks + batch_size - 1) // batch_size\n            \n            if progress_callback:\n                progress = (i + len(batch_chunks)) / total_chunks\n                progress_callback(\n                    f\"发送第 {batch_num}/{total_batches} 批数据 ({len(batch_chunks)} 块)...\",\n                    progress\n                )\n            \n            # 构建episode数据\n            episodes = [\n                EpisodeData(data=chunk, type=\"text\")\n                for chunk in batch_chunks\n            ]\n            \n            # 发送到Zep\n            try:\n                batch_result = self.client.graph.add_batch(\n                    graph_id=graph_id,\n                    episodes=episodes\n                )\n                \n                # 收集返回的 episode uuid\n                if batch_result and isinstance(batch_result, list):\n                    for ep in batch_result:\n                        ep_uuid = getattr(ep, 'uuid_', None) or getattr(ep, 'uuid', None)\n                        if ep_uuid:\n                            episode_uuids.append(ep_uuid)\n                \n                # 避免请求过快\n                time.sleep(1)\n                \n            except Exception as e:\n                if progress_callback:\n                    progress_callback(f\"批次 {batch_num} 发送失败: {str(e)}\", 0)\n                raise\n        \n        return episode_uuids\n    \n    def _wait_for_episodes(\n        self,\n        episode_uuids: List[str],\n        progress_callback: Optional[Callable] = None,\n        timeout: int = 600\n    ):\n        \"\"\"等待所有 episode 处理完成（通过查询每个 episode 的 processed 状态）\"\"\"\n        if not episode_uuids:\n            if progress_callback:\n                progress_callback(\"无需等待（没有 episode）\", 1.0)\n            return\n        \n        start_time = time.time()\n        pending_episodes = set(episode_uuids)\n        completed_count = 0\n        total_episodes = len(episode_uuids)\n        \n        if progress_callback:\n            progress_callback(f\"开始等待 {total_episodes} 个文本块处理...\", 0)\n        \n        while pending_episodes:\n            if time.time() - start_time > timeout:\n                if progress_callback:\n                    progress_callback(\n                        f\"部分文本块超时，已完成 {completed_count}/{total_episodes}\",\n                        completed_count / total_episodes\n                    )\n                break\n            \n            # 检查每个 episode 的处理状态\n            for ep_uuid in list(pending_episodes):\n                try:\n                    episode = self.client.graph.episode.get(uuid_=ep_uuid)\n                    is_processed = getattr(episode, 'processed', False)\n                    \n                    if is_processed:\n                        pending_episodes.remove(ep_uuid)\n                        completed_count += 1\n                        \n                except Exception as e:\n                    # 忽略单个查询错误，继续\n                    pass\n            \n            elapsed = int(time.time() - start_time)\n            if progress_callback:\n                progress_callback(\n                    f\"Zep处理中... {completed_count}/{total_episodes} 完成, {len(pending_episodes)} 待处理 ({elapsed}秒)\",\n                    completed_count / total_episodes if total_episodes > 0 else 0\n                )\n            \n            if pending_episodes:\n                time.sleep(3)  # 每3秒检查一次\n        \n        if progress_callback:\n            progress_callback(f\"处理完成: {completed_count}/{total_episodes}\", 1.0)\n    \n    def _get_graph_info(self, graph_id: str) -> GraphInfo:\n        \"\"\"获取图谱信息\"\"\"\n        # 获取节点（分页）\n        nodes = fetch_all_nodes(self.client, graph_id)\n\n        # 获取边（分页）\n        edges = fetch_all_edges(self.client, graph_id)\n\n        # 统计实体类型\n        entity_types = set()\n        for node in nodes:\n            if node.labels:\n                for label in node.labels:\n                    if label not in [\"Entity\", \"Node\"]:\n                        entity_types.add(label)\n\n        return GraphInfo(\n            graph_id=graph_id,\n            node_count=len(nodes),\n            edge_count=len(edges),\n            entity_types=list(entity_types)\n        )\n    \n    def get_graph_data(self, graph_id: str) -> Dict[str, Any]:\n        \"\"\"\n        获取完整图谱数据（包含详细信息）\n        \n        Args:\n            graph_id: 图谱ID\n            \n        Returns:\n            包含nodes和edges的字典，包括时间信息、属性等详细数据\n        \"\"\"\n        nodes = fetch_all_nodes(self.client, graph_id)\n        edges = fetch_all_edges(self.client, graph_id)\n\n        # 创建节点映射用于获取节点名称\n        node_map = {}\n        for node in nodes:\n            node_map[node.uuid_] = node.name or \"\"\n        \n        nodes_data = []\n        for node in nodes:\n            # 获取创建时间\n            created_at = getattr(node, 'created_at', None)\n            if created_at:\n                created_at = str(created_at)\n            \n            nodes_data.append({\n                \"uuid\": node.uuid_,\n                \"name\": node.name,\n                \"labels\": node.labels or [],\n                \"summary\": node.summary or \"\",\n                \"attributes\": node.attributes or {},\n                \"created_at\": created_at,\n            })\n        \n        edges_data = []\n        for edge in edges:\n            # 获取时间信息\n            created_at = getattr(edge, 'created_at', None)\n            valid_at = getattr(edge, 'valid_at', None)\n            invalid_at = getattr(edge, 'invalid_at', None)\n            expired_at = getattr(edge, 'expired_at', None)\n            \n            # 获取 episodes\n            episodes = getattr(edge, 'episodes', None) or getattr(edge, 'episode_ids', None)\n            if episodes and not isinstance(episodes, list):\n                episodes = [str(episodes)]\n            elif episodes:\n                episodes = [str(e) for e in episodes]\n            \n            # 获取 fact_type\n            fact_type = getattr(edge, 'fact_type', None) or edge.name or \"\"\n            \n            edges_data.append({\n                \"uuid\": edge.uuid_,\n                \"name\": edge.name or \"\",\n                \"fact\": edge.fact or \"\",\n                \"fact_type\": fact_type,\n                \"source_node_uuid\": edge.source_node_uuid,\n                \"target_node_uuid\": edge.target_node_uuid,\n                \"source_node_name\": node_map.get(edge.source_node_uuid, \"\"),\n                \"target_node_name\": node_map.get(edge.target_node_uuid, \"\"),\n                \"attributes\": edge.attributes or {},\n                \"created_at\": str(created_at) if created_at else None,\n                \"valid_at\": str(valid_at) if valid_at else None,\n                \"invalid_at\": str(invalid_at) if invalid_at else None,\n                \"expired_at\": str(expired_at) if expired_at else None,\n                \"episodes\": episodes or [],\n            })\n        \n        return {\n            \"graph_id\": graph_id,\n            \"nodes\": nodes_data,\n            \"edges\": edges_data,\n            \"node_count\": len(nodes_data),\n            \"edge_count\": len(edges_data),\n        }\n    \n    def delete_graph(self, graph_id: str):\n        \"\"\"删除图谱\"\"\"\n        self.client.graph.delete(graph_id=graph_id)\n\n"
  },
  {
    "path": "backend/app/services/oasis_profile_generator.py",
    "content": "\"\"\"\nOASIS Agent Profile生成器\n将Zep图谱中的实体转换为OASIS模拟平台所需的Agent Profile格式\n\n优化改进：\n1. 调用Zep检索功能二次丰富节点信息\n2. 优化提示词生成非常详细的人设\n3. 区分个人实体和抽象群体实体\n\"\"\"\n\nimport json\nimport random\nimport time\nfrom typing import Dict, Any, List, Optional\nfrom dataclasses import dataclass, field\nfrom datetime import datetime\n\nfrom openai import OpenAI\nfrom zep_cloud.client import Zep\n\nfrom ..config import Config\nfrom ..utils.logger import get_logger\nfrom .zep_entity_reader import EntityNode, ZepEntityReader\n\nlogger = get_logger('mirofish.oasis_profile')\n\n\n@dataclass\nclass OasisAgentProfile:\n    \"\"\"OASIS Agent Profile数据结构\"\"\"\n    # 通用字段\n    user_id: int\n    user_name: str\n    name: str\n    bio: str\n    persona: str\n    \n    # 可选字段 - Reddit风格\n    karma: int = 1000\n    \n    # 可选字段 - Twitter风格\n    friend_count: int = 100\n    follower_count: int = 150\n    statuses_count: int = 500\n    \n    # 额外人设信息\n    age: Optional[int] = None\n    gender: Optional[str] = None\n    mbti: Optional[str] = None\n    country: Optional[str] = None\n    profession: Optional[str] = None\n    interested_topics: List[str] = field(default_factory=list)\n    \n    # 来源实体信息\n    source_entity_uuid: Optional[str] = None\n    source_entity_type: Optional[str] = None\n    \n    created_at: str = field(default_factory=lambda: datetime.now().strftime(\"%Y-%m-%d\"))\n    \n    def to_reddit_format(self) -> Dict[str, Any]:\n        \"\"\"转换为Reddit平台格式\"\"\"\n        profile = {\n            \"user_id\": self.user_id,\n            \"username\": self.user_name,  # OASIS 库要求字段名为 username（无下划线）\n            \"name\": self.name,\n            \"bio\": self.bio,\n            \"persona\": self.persona,\n            \"karma\": self.karma,\n            \"created_at\": self.created_at,\n        }\n        \n        # 添加额外人设信息（如果有）\n        if self.age:\n            profile[\"age\"] = self.age\n        if self.gender:\n            profile[\"gender\"] = self.gender\n        if self.mbti:\n            profile[\"mbti\"] = self.mbti\n        if self.country:\n            profile[\"country\"] = self.country\n        if self.profession:\n            profile[\"profession\"] = self.profession\n        if self.interested_topics:\n            profile[\"interested_topics\"] = self.interested_topics\n        \n        return profile\n    \n    def to_twitter_format(self) -> Dict[str, Any]:\n        \"\"\"转换为Twitter平台格式\"\"\"\n        profile = {\n            \"user_id\": self.user_id,\n            \"username\": self.user_name,  # OASIS 库要求字段名为 username（无下划线）\n            \"name\": self.name,\n            \"bio\": self.bio,\n            \"persona\": self.persona,\n            \"friend_count\": self.friend_count,\n            \"follower_count\": self.follower_count,\n            \"statuses_count\": self.statuses_count,\n            \"created_at\": self.created_at,\n        }\n        \n        # 添加额外人设信息\n        if self.age:\n            profile[\"age\"] = self.age\n        if self.gender:\n            profile[\"gender\"] = self.gender\n        if self.mbti:\n            profile[\"mbti\"] = self.mbti\n        if self.country:\n            profile[\"country\"] = self.country\n        if self.profession:\n            profile[\"profession\"] = self.profession\n        if self.interested_topics:\n            profile[\"interested_topics\"] = self.interested_topics\n        \n        return profile\n    \n    def to_dict(self) -> Dict[str, Any]:\n        \"\"\"转换为完整字典格式\"\"\"\n        return {\n            \"user_id\": self.user_id,\n            \"user_name\": self.user_name,\n            \"name\": self.name,\n            \"bio\": self.bio,\n            \"persona\": self.persona,\n            \"karma\": self.karma,\n            \"friend_count\": self.friend_count,\n            \"follower_count\": self.follower_count,\n            \"statuses_count\": self.statuses_count,\n            \"age\": self.age,\n            \"gender\": self.gender,\n            \"mbti\": self.mbti,\n            \"country\": self.country,\n            \"profession\": self.profession,\n            \"interested_topics\": self.interested_topics,\n            \"source_entity_uuid\": self.source_entity_uuid,\n            \"source_entity_type\": self.source_entity_type,\n            \"created_at\": self.created_at,\n        }\n\n\nclass OasisProfileGenerator:\n    \"\"\"\n    OASIS Profile生成器\n    \n    将Zep图谱中的实体转换为OASIS模拟所需的Agent Profile\n    \n    优化特性：\n    1. 调用Zep图谱检索功能获取更丰富的上下文\n    2. 生成非常详细的人设（包括基本信息、职业经历、性格特征、社交媒体行为等）\n    3. 区分个人实体和抽象群体实体\n    \"\"\"\n    \n    # MBTI类型列表\n    MBTI_TYPES = [\n        \"INTJ\", \"INTP\", \"ENTJ\", \"ENTP\",\n        \"INFJ\", \"INFP\", \"ENFJ\", \"ENFP\",\n        \"ISTJ\", \"ISFJ\", \"ESTJ\", \"ESFJ\",\n        \"ISTP\", \"ISFP\", \"ESTP\", \"ESFP\"\n    ]\n    \n    # 常见国家列表\n    COUNTRIES = [\n        \"China\", \"US\", \"UK\", \"Japan\", \"Germany\", \"France\", \n        \"Canada\", \"Australia\", \"Brazil\", \"India\", \"South Korea\"\n    ]\n    \n    # 个人类型实体（需要生成具体人设）\n    INDIVIDUAL_ENTITY_TYPES = [\n        \"student\", \"alumni\", \"professor\", \"person\", \"publicfigure\", \n        \"expert\", \"faculty\", \"official\", \"journalist\", \"activist\"\n    ]\n    \n    # 群体/机构类型实体（需要生成群体代表人设）\n    GROUP_ENTITY_TYPES = [\n        \"university\", \"governmentagency\", \"organization\", \"ngo\", \n        \"mediaoutlet\", \"company\", \"institution\", \"group\", \"community\"\n    ]\n    \n    def __init__(\n        self, \n        api_key: Optional[str] = None,\n        base_url: Optional[str] = None,\n        model_name: Optional[str] = None,\n        zep_api_key: Optional[str] = None,\n        graph_id: Optional[str] = None\n    ):\n        self.api_key = api_key or Config.LLM_API_KEY\n        self.base_url = base_url or Config.LLM_BASE_URL\n        self.model_name = model_name or Config.LLM_MODEL_NAME\n        \n        if not self.api_key:\n            raise ValueError(\"LLM_API_KEY 未配置\")\n        \n        self.client = OpenAI(\n            api_key=self.api_key,\n            base_url=self.base_url\n        )\n        \n        # Zep客户端用于检索丰富上下文\n        self.zep_api_key = zep_api_key or Config.ZEP_API_KEY\n        self.zep_client = None\n        self.graph_id = graph_id\n        \n        if self.zep_api_key:\n            try:\n                self.zep_client = Zep(api_key=self.zep_api_key)\n            except Exception as e:\n                logger.warning(f\"Zep客户端初始化失败: {e}\")\n    \n    def generate_profile_from_entity(\n        self, \n        entity: EntityNode, \n        user_id: int,\n        use_llm: bool = True\n    ) -> OasisAgentProfile:\n        \"\"\"\n        从Zep实体生成OASIS Agent Profile\n        \n        Args:\n            entity: Zep实体节点\n            user_id: 用户ID（用于OASIS）\n            use_llm: 是否使用LLM生成详细人设\n            \n        Returns:\n            OasisAgentProfile\n        \"\"\"\n        entity_type = entity.get_entity_type() or \"Entity\"\n        \n        # 基础信息\n        name = entity.name\n        user_name = self._generate_username(name)\n        \n        # 构建上下文信息\n        context = self._build_entity_context(entity)\n        \n        if use_llm:\n            # 使用LLM生成详细人设\n            profile_data = self._generate_profile_with_llm(\n                entity_name=name,\n                entity_type=entity_type,\n                entity_summary=entity.summary,\n                entity_attributes=entity.attributes,\n                context=context\n            )\n        else:\n            # 使用规则生成基础人设\n            profile_data = self._generate_profile_rule_based(\n                entity_name=name,\n                entity_type=entity_type,\n                entity_summary=entity.summary,\n                entity_attributes=entity.attributes\n            )\n        \n        return OasisAgentProfile(\n            user_id=user_id,\n            user_name=user_name,\n            name=name,\n            bio=profile_data.get(\"bio\", f\"{entity_type}: {name}\"),\n            persona=profile_data.get(\"persona\", entity.summary or f\"A {entity_type} named {name}.\"),\n            karma=profile_data.get(\"karma\", random.randint(500, 5000)),\n            friend_count=profile_data.get(\"friend_count\", random.randint(50, 500)),\n            follower_count=profile_data.get(\"follower_count\", random.randint(100, 1000)),\n            statuses_count=profile_data.get(\"statuses_count\", random.randint(100, 2000)),\n            age=profile_data.get(\"age\"),\n            gender=profile_data.get(\"gender\"),\n            mbti=profile_data.get(\"mbti\"),\n            country=profile_data.get(\"country\"),\n            profession=profile_data.get(\"profession\"),\n            interested_topics=profile_data.get(\"interested_topics\", []),\n            source_entity_uuid=entity.uuid,\n            source_entity_type=entity_type,\n        )\n    \n    def _generate_username(self, name: str) -> str:\n        \"\"\"生成用户名\"\"\"\n        # 移除特殊字符，转换为小写\n        username = name.lower().replace(\" \", \"_\")\n        username = ''.join(c for c in username if c.isalnum() or c == '_')\n        \n        # 添加随机后缀避免重复\n        suffix = random.randint(100, 999)\n        return f\"{username}_{suffix}\"\n    \n    def _search_zep_for_entity(self, entity: EntityNode) -> Dict[str, Any]:\n        \"\"\"\n        使用Zep图谱混合搜索功能获取实体相关的丰富信息\n        \n        Zep没有内置混合搜索接口，需要分别搜索edges和nodes然后合并结果。\n        使用并行请求同时搜索，提高效率。\n        \n        Args:\n            entity: 实体节点对象\n            \n        Returns:\n            包含facts, node_summaries, context的字典\n        \"\"\"\n        import concurrent.futures\n        \n        if not self.zep_client:\n            return {\"facts\": [], \"node_summaries\": [], \"context\": \"\"}\n        \n        entity_name = entity.name\n        \n        results = {\n            \"facts\": [],\n            \"node_summaries\": [],\n            \"context\": \"\"\n        }\n        \n        # 必须有graph_id才能进行搜索\n        if not self.graph_id:\n            logger.debug(f\"跳过Zep检索：未设置graph_id\")\n            return results\n        \n        comprehensive_query = f\"关于{entity_name}的所有信息、活动、事件、关系和背景\"\n        \n        def search_edges():\n            \"\"\"搜索边（事实/关系）- 带重试机制\"\"\"\n            max_retries = 3\n            last_exception = None\n            delay = 2.0\n            \n            for attempt in range(max_retries):\n                try:\n                    return self.zep_client.graph.search(\n                        query=comprehensive_query,\n                        graph_id=self.graph_id,\n                        limit=30,\n                        scope=\"edges\",\n                        reranker=\"rrf\"\n                    )\n                except Exception as e:\n                    last_exception = e\n                    if attempt < max_retries - 1:\n                        logger.debug(f\"Zep边搜索第 {attempt + 1} 次失败: {str(e)[:80]}, 重试中...\")\n                        time.sleep(delay)\n                        delay *= 2\n                    else:\n                        logger.debug(f\"Zep边搜索在 {max_retries} 次尝试后仍失败: {e}\")\n            return None\n        \n        def search_nodes():\n            \"\"\"搜索节点（实体摘要）- 带重试机制\"\"\"\n            max_retries = 3\n            last_exception = None\n            delay = 2.0\n            \n            for attempt in range(max_retries):\n                try:\n                    return self.zep_client.graph.search(\n                        query=comprehensive_query,\n                        graph_id=self.graph_id,\n                        limit=20,\n                        scope=\"nodes\",\n                        reranker=\"rrf\"\n                    )\n                except Exception as e:\n                    last_exception = e\n                    if attempt < max_retries - 1:\n                        logger.debug(f\"Zep节点搜索第 {attempt + 1} 次失败: {str(e)[:80]}, 重试中...\")\n                        time.sleep(delay)\n                        delay *= 2\n                    else:\n                        logger.debug(f\"Zep节点搜索在 {max_retries} 次尝试后仍失败: {e}\")\n            return None\n        \n        try:\n            # 并行执行edges和nodes搜索\n            with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:\n                edge_future = executor.submit(search_edges)\n                node_future = executor.submit(search_nodes)\n                \n                # 获取结果\n                edge_result = edge_future.result(timeout=30)\n                node_result = node_future.result(timeout=30)\n            \n            # 处理边搜索结果\n            all_facts = set()\n            if edge_result and hasattr(edge_result, 'edges') and edge_result.edges:\n                for edge in edge_result.edges:\n                    if hasattr(edge, 'fact') and edge.fact:\n                        all_facts.add(edge.fact)\n            results[\"facts\"] = list(all_facts)\n            \n            # 处理节点搜索结果\n            all_summaries = set()\n            if node_result and hasattr(node_result, 'nodes') and node_result.nodes:\n                for node in node_result.nodes:\n                    if hasattr(node, 'summary') and node.summary:\n                        all_summaries.add(node.summary)\n                    if hasattr(node, 'name') and node.name and node.name != entity_name:\n                        all_summaries.add(f\"相关实体: {node.name}\")\n            results[\"node_summaries\"] = list(all_summaries)\n            \n            # 构建综合上下文\n            context_parts = []\n            if results[\"facts\"]:\n                context_parts.append(\"事实信息:\\n\" + \"\\n\".join(f\"- {f}\" for f in results[\"facts\"][:20]))\n            if results[\"node_summaries\"]:\n                context_parts.append(\"相关实体:\\n\" + \"\\n\".join(f\"- {s}\" for s in results[\"node_summaries\"][:10]))\n            results[\"context\"] = \"\\n\\n\".join(context_parts)\n            \n            logger.info(f\"Zep混合检索完成: {entity_name}, 获取 {len(results['facts'])} 条事实, {len(results['node_summaries'])} 个相关节点\")\n            \n        except concurrent.futures.TimeoutError:\n            logger.warning(f\"Zep检索超时 ({entity_name})\")\n        except Exception as e:\n            logger.warning(f\"Zep检索失败 ({entity_name}): {e}\")\n        \n        return results\n    \n    def _build_entity_context(self, entity: EntityNode) -> str:\n        \"\"\"\n        构建实体的完整上下文信息\n        \n        包括：\n        1. 实体本身的边信息（事实）\n        2. 关联节点的详细信息\n        3. Zep混合检索到的丰富信息\n        \"\"\"\n        context_parts = []\n        \n        # 1. 添加实体属性信息\n        if entity.attributes:\n            attrs = []\n            for key, value in entity.attributes.items():\n                if value and str(value).strip():\n                    attrs.append(f\"- {key}: {value}\")\n            if attrs:\n                context_parts.append(\"### 实体属性\\n\" + \"\\n\".join(attrs))\n        \n        # 2. 添加相关边信息（事实/关系）\n        existing_facts = set()\n        if entity.related_edges:\n            relationships = []\n            for edge in entity.related_edges:  # 不限制数量\n                fact = edge.get(\"fact\", \"\")\n                edge_name = edge.get(\"edge_name\", \"\")\n                direction = edge.get(\"direction\", \"\")\n                \n                if fact:\n                    relationships.append(f\"- {fact}\")\n                    existing_facts.add(fact)\n                elif edge_name:\n                    if direction == \"outgoing\":\n                        relationships.append(f\"- {entity.name} --[{edge_name}]--> (相关实体)\")\n                    else:\n                        relationships.append(f\"- (相关实体) --[{edge_name}]--> {entity.name}\")\n            \n            if relationships:\n                context_parts.append(\"### 相关事实和关系\\n\" + \"\\n\".join(relationships))\n        \n        # 3. 添加关联节点的详细信息\n        if entity.related_nodes:\n            related_info = []\n            for node in entity.related_nodes:  # 不限制数量\n                node_name = node.get(\"name\", \"\")\n                node_labels = node.get(\"labels\", [])\n                node_summary = node.get(\"summary\", \"\")\n                \n                # 过滤掉默认标签\n                custom_labels = [l for l in node_labels if l not in [\"Entity\", \"Node\"]]\n                label_str = f\" ({', '.join(custom_labels)})\" if custom_labels else \"\"\n                \n                if node_summary:\n                    related_info.append(f\"- **{node_name}**{label_str}: {node_summary}\")\n                else:\n                    related_info.append(f\"- **{node_name}**{label_str}\")\n            \n            if related_info:\n                context_parts.append(\"### 关联实体信息\\n\" + \"\\n\".join(related_info))\n        \n        # 4. 使用Zep混合检索获取更丰富的信息\n        zep_results = self._search_zep_for_entity(entity)\n        \n        if zep_results.get(\"facts\"):\n            # 去重：排除已存在的事实\n            new_facts = [f for f in zep_results[\"facts\"] if f not in existing_facts]\n            if new_facts:\n                context_parts.append(\"### Zep检索到的事实信息\\n\" + \"\\n\".join(f\"- {f}\" for f in new_facts[:15]))\n        \n        if zep_results.get(\"node_summaries\"):\n            context_parts.append(\"### Zep检索到的相关节点\\n\" + \"\\n\".join(f\"- {s}\" for s in zep_results[\"node_summaries\"][:10]))\n        \n        return \"\\n\\n\".join(context_parts)\n    \n    def _is_individual_entity(self, entity_type: str) -> bool:\n        \"\"\"判断是否是个人类型实体\"\"\"\n        return entity_type.lower() in self.INDIVIDUAL_ENTITY_TYPES\n    \n    def _is_group_entity(self, entity_type: str) -> bool:\n        \"\"\"判断是否是群体/机构类型实体\"\"\"\n        return entity_type.lower() in self.GROUP_ENTITY_TYPES\n    \n    def _generate_profile_with_llm(\n        self,\n        entity_name: str,\n        entity_type: str,\n        entity_summary: str,\n        entity_attributes: Dict[str, Any],\n        context: str\n    ) -> Dict[str, Any]:\n        \"\"\"\n        使用LLM生成非常详细的人设\n        \n        根据实体类型区分：\n        - 个人实体：生成具体的人物设定\n        - 群体/机构实体：生成代表性账号设定\n        \"\"\"\n        \n        is_individual = self._is_individual_entity(entity_type)\n        \n        if is_individual:\n            prompt = self._build_individual_persona_prompt(\n                entity_name, entity_type, entity_summary, entity_attributes, context\n            )\n        else:\n            prompt = self._build_group_persona_prompt(\n                entity_name, entity_type, entity_summary, entity_attributes, context\n            )\n\n        # 尝试多次生成，直到成功或达到最大重试次数\n        max_attempts = 3\n        last_error = None\n        \n        for attempt in range(max_attempts):\n            try:\n                response = self.client.chat.completions.create(\n                    model=self.model_name,\n                    messages=[\n                        {\"role\": \"system\", \"content\": self._get_system_prompt(is_individual)},\n                        {\"role\": \"user\", \"content\": prompt}\n                    ],\n                    response_format={\"type\": \"json_object\"},\n                    temperature=0.7 - (attempt * 0.1)  # 每次重试降低温度\n                    # 不设置max_tokens，让LLM自由发挥\n                )\n                \n                content = response.choices[0].message.content\n                \n                # 检查是否被截断（finish_reason不是'stop'）\n                finish_reason = response.choices[0].finish_reason\n                if finish_reason == 'length':\n                    logger.warning(f\"LLM输出被截断 (attempt {attempt+1}), 尝试修复...\")\n                    content = self._fix_truncated_json(content)\n                \n                # 尝试解析JSON\n                try:\n                    result = json.loads(content)\n                    \n                    # 验证必需字段\n                    if \"bio\" not in result or not result[\"bio\"]:\n                        result[\"bio\"] = entity_summary[:200] if entity_summary else f\"{entity_type}: {entity_name}\"\n                    if \"persona\" not in result or not result[\"persona\"]:\n                        result[\"persona\"] = entity_summary or f\"{entity_name}是一个{entity_type}。\"\n                    \n                    return result\n                    \n                except json.JSONDecodeError as je:\n                    logger.warning(f\"JSON解析失败 (attempt {attempt+1}): {str(je)[:80]}\")\n                    \n                    # 尝试修复JSON\n                    result = self._try_fix_json(content, entity_name, entity_type, entity_summary)\n                    if result.get(\"_fixed\"):\n                        del result[\"_fixed\"]\n                        return result\n                    \n                    last_error = je\n                    \n            except Exception as e:\n                logger.warning(f\"LLM调用失败 (attempt {attempt+1}): {str(e)[:80]}\")\n                last_error = e\n                import time\n                time.sleep(1 * (attempt + 1))  # 指数退避\n        \n        logger.warning(f\"LLM生成人设失败（{max_attempts}次尝试）: {last_error}, 使用规则生成\")\n        return self._generate_profile_rule_based(\n            entity_name, entity_type, entity_summary, entity_attributes\n        )\n    \n    def _fix_truncated_json(self, content: str) -> str:\n        \"\"\"修复被截断的JSON（输出被max_tokens限制截断）\"\"\"\n        import re\n        \n        # 如果JSON被截断，尝试闭合它\n        content = content.strip()\n        \n        # 计算未闭合的括号\n        open_braces = content.count('{') - content.count('}')\n        open_brackets = content.count('[') - content.count(']')\n        \n        # 检查是否有未闭合的字符串\n        # 简单检查：如果最后一个引号后没有逗号或闭合括号，可能是字符串被截断\n        if content and content[-1] not in '\",}]':\n            # 尝试闭合字符串\n            content += '\"'\n        \n        # 闭合括号\n        content += ']' * open_brackets\n        content += '}' * open_braces\n        \n        return content\n    \n    def _try_fix_json(self, content: str, entity_name: str, entity_type: str, entity_summary: str = \"\") -> Dict[str, Any]:\n        \"\"\"尝试修复损坏的JSON\"\"\"\n        import re\n        \n        # 1. 首先尝试修复被截断的情况\n        content = self._fix_truncated_json(content)\n        \n        # 2. 尝试提取JSON部分\n        json_match = re.search(r'\\{[\\s\\S]*\\}', content)\n        if json_match:\n            json_str = json_match.group()\n            \n            # 3. 处理字符串中的换行符问题\n            # 找到所有字符串值并替换其中的换行符\n            def fix_string_newlines(match):\n                s = match.group(0)\n                # 替换字符串内的实际换行符为空格\n                s = s.replace('\\n', ' ').replace('\\r', ' ')\n                # 替换多余空格\n                s = re.sub(r'\\s+', ' ', s)\n                return s\n            \n            # 匹配JSON字符串值\n            json_str = re.sub(r'\"[^\"\\\\]*(?:\\\\.[^\"\\\\]*)*\"', fix_string_newlines, json_str)\n            \n            # 4. 尝试解析\n            try:\n                result = json.loads(json_str)\n                result[\"_fixed\"] = True\n                return result\n            except json.JSONDecodeError as e:\n                # 5. 如果还是失败，尝试更激进的修复\n                try:\n                    # 移除所有控制字符\n                    json_str = re.sub(r'[\\x00-\\x1f\\x7f-\\x9f]', ' ', json_str)\n                    # 替换所有连续空白\n                    json_str = re.sub(r'\\s+', ' ', json_str)\n                    result = json.loads(json_str)\n                    result[\"_fixed\"] = True\n                    return result\n                except:\n                    pass\n        \n        # 6. 尝试从内容中提取部分信息\n        bio_match = re.search(r'\"bio\"\\s*:\\s*\"([^\"]*)\"', content)\n        persona_match = re.search(r'\"persona\"\\s*:\\s*\"([^\"]*)', content)  # 可能被截断\n        \n        bio = bio_match.group(1) if bio_match else (entity_summary[:200] if entity_summary else f\"{entity_type}: {entity_name}\")\n        persona = persona_match.group(1) if persona_match else (entity_summary or f\"{entity_name}是一个{entity_type}。\")\n        \n        # 如果提取到了有意义的内容，标记为已修复\n        if bio_match or persona_match:\n            logger.info(f\"从损坏的JSON中提取了部分信息\")\n            return {\n                \"bio\": bio,\n                \"persona\": persona,\n                \"_fixed\": True\n            }\n        \n        # 7. 完全失败，返回基础结构\n        logger.warning(f\"JSON修复失败，返回基础结构\")\n        return {\n            \"bio\": entity_summary[:200] if entity_summary else f\"{entity_type}: {entity_name}\",\n            \"persona\": entity_summary or f\"{entity_name}是一个{entity_type}。\"\n        }\n    \n    def _get_system_prompt(self, is_individual: bool) -> str:\n        \"\"\"获取系统提示词\"\"\"\n        base_prompt = \"你是社交媒体用户画像生成专家。生成详细、真实的人设用于舆论模拟,最大程度还原已有现实情况。必须返回有效的JSON格式，所有字符串值不能包含未转义的换行符。使用中文。\"\n        return base_prompt\n    \n    def _build_individual_persona_prompt(\n        self,\n        entity_name: str,\n        entity_type: str,\n        entity_summary: str,\n        entity_attributes: Dict[str, Any],\n        context: str\n    ) -> str:\n        \"\"\"构建个人实体的详细人设提示词\"\"\"\n        \n        attrs_str = json.dumps(entity_attributes, ensure_ascii=False) if entity_attributes else \"无\"\n        context_str = context[:3000] if context else \"无额外上下文\"\n        \n        return f\"\"\"为实体生成详细的社交媒体用户人设,最大程度还原已有现实情况。\n\n实体名称: {entity_name}\n实体类型: {entity_type}\n实体摘要: {entity_summary}\n实体属性: {attrs_str}\n\n上下文信息:\n{context_str}\n\n请生成JSON，包含以下字段:\n\n1. bio: 社交媒体简介，200字\n2. persona: 详细人设描述（2000字的纯文本），需包含:\n   - 基本信息（年龄、职业、教育背景、所在地）\n   - 人物背景（重要经历、与事件的关联、社会关系）\n   - 性格特征（MBTI类型、核心性格、情绪表达方式）\n   - 社交媒体行为（发帖频率、内容偏好、互动风格、语言特点）\n   - 立场观点（对话题的态度、可能被激怒/感动的内容）\n   - 独特特征（口头禅、特殊经历、个人爱好）\n   - 个人记忆（人设的重要部分，要介绍这个个体与事件的关联，以及这个个体在事件中的已有动作与反应）\n3. age: 年龄数字（必须是整数）\n4. gender: 性别，必须是英文: \"male\" 或 \"female\"\n5. mbti: MBTI类型（如INTJ、ENFP等）\n6. country: 国家（使用中文，如\"中国\"）\n7. profession: 职业\n8. interested_topics: 感兴趣话题数组\n\n重要:\n- 所有字段值必须是字符串或数字，不要使用换行符\n- persona必须是一段连贯的文字描述\n- 使用中文（除了gender字段必须用英文male/female）\n- 内容要与实体信息保持一致\n- age必须是有效的整数，gender必须是\"male\"或\"female\"\n\"\"\"\n\n    def _build_group_persona_prompt(\n        self,\n        entity_name: str,\n        entity_type: str,\n        entity_summary: str,\n        entity_attributes: Dict[str, Any],\n        context: str\n    ) -> str:\n        \"\"\"构建群体/机构实体的详细人设提示词\"\"\"\n        \n        attrs_str = json.dumps(entity_attributes, ensure_ascii=False) if entity_attributes else \"无\"\n        context_str = context[:3000] if context else \"无额外上下文\"\n        \n        return f\"\"\"为机构/群体实体生成详细的社交媒体账号设定,最大程度还原已有现实情况。\n\n实体名称: {entity_name}\n实体类型: {entity_type}\n实体摘要: {entity_summary}\n实体属性: {attrs_str}\n\n上下文信息:\n{context_str}\n\n请生成JSON，包含以下字段:\n\n1. bio: 官方账号简介，200字，专业得体\n2. persona: 详细账号设定描述（2000字的纯文本），需包含:\n   - 机构基本信息（正式名称、机构性质、成立背景、主要职能）\n   - 账号定位（账号类型、目标受众、核心功能）\n   - 发言风格（语言特点、常用表达、禁忌话题）\n   - 发布内容特点（内容类型、发布频率、活跃时间段）\n   - 立场态度（对核心话题的官方立场、面对争议的处理方式）\n   - 特殊说明（代表的群体画像、运营习惯）\n   - 机构记忆（机构人设的重要部分，要介绍这个机构与事件的关联，以及这个机构在事件中的已有动作与反应）\n3. age: 固定填30（机构账号的虚拟年龄）\n4. gender: 固定填\"other\"（机构账号使用other表示非个人）\n5. mbti: MBTI类型，用于描述账号风格，如ISTJ代表严谨保守\n6. country: 国家（使用中文，如\"中国\"）\n7. profession: 机构职能描述\n8. interested_topics: 关注领域数组\n\n重要:\n- 所有字段值必须是字符串或数字，不允许null值\n- persona必须是一段连贯的文字描述，不要使用换行符\n- 使用中文（除了gender字段必须用英文\"other\"）\n- age必须是整数30，gender必须是字符串\"other\"\n- 机构账号发言要符合其身份定位\"\"\"\n    \n    def _generate_profile_rule_based(\n        self,\n        entity_name: str,\n        entity_type: str,\n        entity_summary: str,\n        entity_attributes: Dict[str, Any]\n    ) -> Dict[str, Any]:\n        \"\"\"使用规则生成基础人设\"\"\"\n        \n        # 根据实体类型生成不同的人设\n        entity_type_lower = entity_type.lower()\n        \n        if entity_type_lower in [\"student\", \"alumni\"]:\n            return {\n                \"bio\": f\"{entity_type} with interests in academics and social issues.\",\n                \"persona\": f\"{entity_name} is a {entity_type.lower()} who is actively engaged in academic and social discussions. They enjoy sharing perspectives and connecting with peers.\",\n                \"age\": random.randint(18, 30),\n                \"gender\": random.choice([\"male\", \"female\"]),\n                \"mbti\": random.choice(self.MBTI_TYPES),\n                \"country\": random.choice(self.COUNTRIES),\n                \"profession\": \"Student\",\n                \"interested_topics\": [\"Education\", \"Social Issues\", \"Technology\"],\n            }\n        \n        elif entity_type_lower in [\"publicfigure\", \"expert\", \"faculty\"]:\n            return {\n                \"bio\": f\"Expert and thought leader in their field.\",\n                \"persona\": f\"{entity_name} is a recognized {entity_type.lower()} who shares insights and opinions on important matters. They are known for their expertise and influence in public discourse.\",\n                \"age\": random.randint(35, 60),\n                \"gender\": random.choice([\"male\", \"female\"]),\n                \"mbti\": random.choice([\"ENTJ\", \"INTJ\", \"ENTP\", \"INTP\"]),\n                \"country\": random.choice(self.COUNTRIES),\n                \"profession\": entity_attributes.get(\"occupation\", \"Expert\"),\n                \"interested_topics\": [\"Politics\", \"Economics\", \"Culture & Society\"],\n            }\n        \n        elif entity_type_lower in [\"mediaoutlet\", \"socialmediaplatform\"]:\n            return {\n                \"bio\": f\"Official account for {entity_name}. News and updates.\",\n                \"persona\": f\"{entity_name} is a media entity that reports news and facilitates public discourse. The account shares timely updates and engages with the audience on current events.\",\n                \"age\": 30,  # 机构虚拟年龄\n                \"gender\": \"other\",  # 机构使用other\n                \"mbti\": \"ISTJ\",  # 机构风格：严谨保守\n                \"country\": \"中国\",\n                \"profession\": \"Media\",\n                \"interested_topics\": [\"General News\", \"Current Events\", \"Public Affairs\"],\n            }\n        \n        elif entity_type_lower in [\"university\", \"governmentagency\", \"ngo\", \"organization\"]:\n            return {\n                \"bio\": f\"Official account of {entity_name}.\",\n                \"persona\": f\"{entity_name} is an institutional entity that communicates official positions, announcements, and engages with stakeholders on relevant matters.\",\n                \"age\": 30,  # 机构虚拟年龄\n                \"gender\": \"other\",  # 机构使用other\n                \"mbti\": \"ISTJ\",  # 机构风格：严谨保守\n                \"country\": \"中国\",\n                \"profession\": entity_type,\n                \"interested_topics\": [\"Public Policy\", \"Community\", \"Official Announcements\"],\n            }\n        \n        else:\n            # 默认人设\n            return {\n                \"bio\": entity_summary[:150] if entity_summary else f\"{entity_type}: {entity_name}\",\n                \"persona\": entity_summary or f\"{entity_name} is a {entity_type.lower()} participating in social discussions.\",\n                \"age\": random.randint(25, 50),\n                \"gender\": random.choice([\"male\", \"female\"]),\n                \"mbti\": random.choice(self.MBTI_TYPES),\n                \"country\": random.choice(self.COUNTRIES),\n                \"profession\": entity_type,\n                \"interested_topics\": [\"General\", \"Social Issues\"],\n            }\n    \n    def set_graph_id(self, graph_id: str):\n        \"\"\"设置图谱ID用于Zep检索\"\"\"\n        self.graph_id = graph_id\n    \n    def generate_profiles_from_entities(\n        self,\n        entities: List[EntityNode],\n        use_llm: bool = True,\n        progress_callback: Optional[callable] = None,\n        graph_id: Optional[str] = None,\n        parallel_count: int = 5,\n        realtime_output_path: Optional[str] = None,\n        output_platform: str = \"reddit\"\n    ) -> List[OasisAgentProfile]:\n        \"\"\"\n        批量从实体生成Agent Profile（支持并行生成）\n        \n        Args:\n            entities: 实体列表\n            use_llm: 是否使用LLM生成详细人设\n            progress_callback: 进度回调函数 (current, total, message)\n            graph_id: 图谱ID，用于Zep检索获取更丰富上下文\n            parallel_count: 并行生成数量，默认5\n            realtime_output_path: 实时写入的文件路径（如果提供，每生成一个就写入一次）\n            output_platform: 输出平台格式 (\"reddit\" 或 \"twitter\")\n            \n        Returns:\n            Agent Profile列表\n        \"\"\"\n        import concurrent.futures\n        from threading import Lock\n        \n        # 设置graph_id用于Zep检索\n        if graph_id:\n            self.graph_id = graph_id\n        \n        total = len(entities)\n        profiles = [None] * total  # 预分配列表保持顺序\n        completed_count = [0]  # 使用列表以便在闭包中修改\n        lock = Lock()\n        \n        # 实时写入文件的辅助函数\n        def save_profiles_realtime():\n            \"\"\"实时保存已生成的 profiles 到文件\"\"\"\n            if not realtime_output_path:\n                return\n            \n            with lock:\n                # 过滤出已生成的 profiles\n                existing_profiles = [p for p in profiles if p is not None]\n                if not existing_profiles:\n                    return\n                \n                try:\n                    if output_platform == \"reddit\":\n                        # Reddit JSON 格式\n                        profiles_data = [p.to_reddit_format() for p in existing_profiles]\n                        with open(realtime_output_path, 'w', encoding='utf-8') as f:\n                            json.dump(profiles_data, f, ensure_ascii=False, indent=2)\n                    else:\n                        # Twitter CSV 格式\n                        import csv\n                        profiles_data = [p.to_twitter_format() for p in existing_profiles]\n                        if profiles_data:\n                            fieldnames = list(profiles_data[0].keys())\n                            with open(realtime_output_path, 'w', encoding='utf-8', newline='') as f:\n                                writer = csv.DictWriter(f, fieldnames=fieldnames)\n                                writer.writeheader()\n                                writer.writerows(profiles_data)\n                except Exception as e:\n                    logger.warning(f\"实时保存 profiles 失败: {e}\")\n        \n        def generate_single_profile(idx: int, entity: EntityNode) -> tuple:\n            \"\"\"生成单个profile的工作函数\"\"\"\n            entity_type = entity.get_entity_type() or \"Entity\"\n            \n            try:\n                profile = self.generate_profile_from_entity(\n                    entity=entity,\n                    user_id=idx,\n                    use_llm=use_llm\n                )\n                \n                # 实时输出生成的人设到控制台和日志\n                self._print_generated_profile(entity.name, entity_type, profile)\n                \n                return idx, profile, None\n                \n            except Exception as e:\n                logger.error(f\"生成实体 {entity.name} 的人设失败: {str(e)}\")\n                # 创建一个基础profile\n                fallback_profile = OasisAgentProfile(\n                    user_id=idx,\n                    user_name=self._generate_username(entity.name),\n                    name=entity.name,\n                    bio=f\"{entity_type}: {entity.name}\",\n                    persona=entity.summary or f\"A participant in social discussions.\",\n                    source_entity_uuid=entity.uuid,\n                    source_entity_type=entity_type,\n                )\n                return idx, fallback_profile, str(e)\n        \n        logger.info(f\"开始并行生成 {total} 个Agent人设（并行数: {parallel_count}）...\")\n        print(f\"\\n{'='*60}\")\n        print(f\"开始生成Agent人设 - 共 {total} 个实体，并行数: {parallel_count}\")\n        print(f\"{'='*60}\\n\")\n        \n        # 使用线程池并行执行\n        with concurrent.futures.ThreadPoolExecutor(max_workers=parallel_count) as executor:\n            # 提交所有任务\n            future_to_entity = {\n                executor.submit(generate_single_profile, idx, entity): (idx, entity)\n                for idx, entity in enumerate(entities)\n            }\n            \n            # 收集结果\n            for future in concurrent.futures.as_completed(future_to_entity):\n                idx, entity = future_to_entity[future]\n                entity_type = entity.get_entity_type() or \"Entity\"\n                \n                try:\n                    result_idx, profile, error = future.result()\n                    profiles[result_idx] = profile\n                    \n                    with lock:\n                        completed_count[0] += 1\n                        current = completed_count[0]\n                    \n                    # 实时写入文件\n                    save_profiles_realtime()\n                    \n                    if progress_callback:\n                        progress_callback(\n                            current, \n                            total, \n                            f\"已完成 {current}/{total}: {entity.name}（{entity_type}）\"\n                        )\n                    \n                    if error:\n                        logger.warning(f\"[{current}/{total}] {entity.name} 使用备用人设: {error}\")\n                    else:\n                        logger.info(f\"[{current}/{total}] 成功生成人设: {entity.name} ({entity_type})\")\n                        \n                except Exception as e:\n                    logger.error(f\"处理实体 {entity.name} 时发生异常: {str(e)}\")\n                    with lock:\n                        completed_count[0] += 1\n                    profiles[idx] = OasisAgentProfile(\n                        user_id=idx,\n                        user_name=self._generate_username(entity.name),\n                        name=entity.name,\n                        bio=f\"{entity_type}: {entity.name}\",\n                        persona=entity.summary or \"A participant in social discussions.\",\n                        source_entity_uuid=entity.uuid,\n                        source_entity_type=entity_type,\n                    )\n                    # 实时写入文件（即使是备用人设）\n                    save_profiles_realtime()\n        \n        print(f\"\\n{'='*60}\")\n        print(f\"人设生成完成！共生成 {len([p for p in profiles if p])} 个Agent\")\n        print(f\"{'='*60}\\n\")\n        \n        return profiles\n    \n    def _print_generated_profile(self, entity_name: str, entity_type: str, profile: OasisAgentProfile):\n        \"\"\"实时输出生成的人设到控制台（完整内容，不截断）\"\"\"\n        separator = \"-\" * 70\n        \n        # 构建完整输出内容（不截断）\n        topics_str = ', '.join(profile.interested_topics) if profile.interested_topics else '无'\n        \n        output_lines = [\n            f\"\\n{separator}\",\n            f\"[已生成] {entity_name} ({entity_type})\",\n            f\"{separator}\",\n            f\"用户名: {profile.user_name}\",\n            f\"\",\n            f\"【简介】\",\n            f\"{profile.bio}\",\n            f\"\",\n            f\"【详细人设】\",\n            f\"{profile.persona}\",\n            f\"\",\n            f\"【基本属性】\",\n            f\"年龄: {profile.age} | 性别: {profile.gender} | MBTI: {profile.mbti}\",\n            f\"职业: {profile.profession} | 国家: {profile.country}\",\n            f\"兴趣话题: {topics_str}\",\n            separator\n        ]\n        \n        output = \"\\n\".join(output_lines)\n        \n        # 只输出到控制台（避免重复，logger不再输出完整内容）\n        print(output)\n    \n    def save_profiles(\n        self,\n        profiles: List[OasisAgentProfile],\n        file_path: str,\n        platform: str = \"reddit\"\n    ):\n        \"\"\"\n        保存Profile到文件（根据平台选择正确格式）\n        \n        OASIS平台格式要求：\n        - Twitter: CSV格式\n        - Reddit: JSON格式\n        \n        Args:\n            profiles: Profile列表\n            file_path: 文件路径\n            platform: 平台类型 (\"reddit\" 或 \"twitter\")\n        \"\"\"\n        if platform == \"twitter\":\n            self._save_twitter_csv(profiles, file_path)\n        else:\n            self._save_reddit_json(profiles, file_path)\n    \n    def _save_twitter_csv(self, profiles: List[OasisAgentProfile], file_path: str):\n        \"\"\"\n        保存Twitter Profile为CSV格式（符合OASIS官方要求）\n        \n        OASIS Twitter要求的CSV字段：\n        - user_id: 用户ID（根据CSV顺序从0开始）\n        - name: 用户真实姓名\n        - username: 系统中的用户名\n        - user_char: 详细人设描述（注入到LLM系统提示中，指导Agent行为）\n        - description: 简短的公开简介（显示在用户资料页面）\n        \n        user_char vs description 区别：\n        - user_char: 内部使用，LLM系统提示，决定Agent如何思考和行动\n        - description: 外部显示，其他用户可见的简介\n        \"\"\"\n        import csv\n        \n        # 确保文件扩展名是.csv\n        if not file_path.endswith('.csv'):\n            file_path = file_path.replace('.json', '.csv')\n        \n        with open(file_path, 'w', newline='', encoding='utf-8') as f:\n            writer = csv.writer(f)\n            \n            # 写入OASIS要求的表头\n            headers = ['user_id', 'name', 'username', 'user_char', 'description']\n            writer.writerow(headers)\n            \n            # 写入数据行\n            for idx, profile in enumerate(profiles):\n                # user_char: 完整人设（bio + persona），用于LLM系统提示\n                user_char = profile.bio\n                if profile.persona and profile.persona != profile.bio:\n                    user_char = f\"{profile.bio} {profile.persona}\"\n                # 处理换行符（CSV中用空格替代）\n                user_char = user_char.replace('\\n', ' ').replace('\\r', ' ')\n                \n                # description: 简短简介，用于外部显示\n                description = profile.bio.replace('\\n', ' ').replace('\\r', ' ')\n                \n                row = [\n                    idx,                    # user_id: 从0开始的顺序ID\n                    profile.name,           # name: 真实姓名\n                    profile.user_name,      # username: 用户名\n                    user_char,              # user_char: 完整人设（内部LLM使用）\n                    description             # description: 简短简介（外部显示）\n                ]\n                writer.writerow(row)\n        \n        logger.info(f\"已保存 {len(profiles)} 个Twitter Profile到 {file_path} (OASIS CSV格式)\")\n    \n    def _normalize_gender(self, gender: Optional[str]) -> str:\n        \"\"\"\n        标准化gender字段为OASIS要求的英文格式\n        \n        OASIS要求: male, female, other\n        \"\"\"\n        if not gender:\n            return \"other\"\n        \n        gender_lower = gender.lower().strip()\n        \n        # 中文映射\n        gender_map = {\n            \"男\": \"male\",\n            \"女\": \"female\",\n            \"机构\": \"other\",\n            \"其他\": \"other\",\n            # 英文已有\n            \"male\": \"male\",\n            \"female\": \"female\",\n            \"other\": \"other\",\n        }\n        \n        return gender_map.get(gender_lower, \"other\")\n    \n    def _save_reddit_json(self, profiles: List[OasisAgentProfile], file_path: str):\n        \"\"\"\n        保存Reddit Profile为JSON格式\n        \n        使用与 to_reddit_format() 一致的格式，确保 OASIS 能正确读取。\n        必须包含 user_id 字段，这是 OASIS agent_graph.get_agent() 匹配的关键！\n        \n        必需字段：\n        - user_id: 用户ID（整数，用于匹配 initial_posts 中的 poster_agent_id）\n        - username: 用户名\n        - name: 显示名称\n        - bio: 简介\n        - persona: 详细人设\n        - age: 年龄（整数）\n        - gender: \"male\", \"female\", 或 \"other\"\n        - mbti: MBTI类型\n        - country: 国家\n        \"\"\"\n        data = []\n        for idx, profile in enumerate(profiles):\n            # 使用与 to_reddit_format() 一致的格式\n            item = {\n                \"user_id\": profile.user_id if profile.user_id is not None else idx,  # 关键：必须包含 user_id\n                \"username\": profile.user_name,\n                \"name\": profile.name,\n                \"bio\": profile.bio[:150] if profile.bio else f\"{profile.name}\",\n                \"persona\": profile.persona or f\"{profile.name} is a participant in social discussions.\",\n                \"karma\": profile.karma if profile.karma else 1000,\n                \"created_at\": profile.created_at,\n                # OASIS必需字段 - 确保都有默认值\n                \"age\": profile.age if profile.age else 30,\n                \"gender\": self._normalize_gender(profile.gender),\n                \"mbti\": profile.mbti if profile.mbti else \"ISTJ\",\n                \"country\": profile.country if profile.country else \"中国\",\n            }\n            \n            # 可选字段\n            if profile.profession:\n                item[\"profession\"] = profile.profession\n            if profile.interested_topics:\n                item[\"interested_topics\"] = profile.interested_topics\n            \n            data.append(item)\n        \n        with open(file_path, 'w', encoding='utf-8') as f:\n            json.dump(data, f, ensure_ascii=False, indent=2)\n        \n        logger.info(f\"已保存 {len(profiles)} 个Reddit Profile到 {file_path} (JSON格式，包含user_id字段)\")\n    \n    # 保留旧方法名作为别名，保持向后兼容\n    def save_profiles_to_json(\n        self,\n        profiles: List[OasisAgentProfile],\n        file_path: str,\n        platform: str = \"reddit\"\n    ):\n        \"\"\"[已废弃] 请使用 save_profiles() 方法\"\"\"\n        logger.warning(\"save_profiles_to_json已废弃，请使用save_profiles方法\")\n        self.save_profiles(profiles, file_path, platform)\n\n"
  },
  {
    "path": "backend/app/services/ontology_generator.py",
    "content": "\"\"\"\n本体生成服务\n接口1：分析文本内容，生成适合社会模拟的实体和关系类型定义\n\"\"\"\n\nimport json\nfrom typing import Dict, Any, List, Optional\nfrom ..utils.llm_client import LLMClient\n\n\n# 本体生成的系统提示词\nONTOLOGY_SYSTEM_PROMPT = \"\"\"你是一个专业的知识图谱本体设计专家。你的任务是分析给定的文本内容和模拟需求，设计适合**社交媒体舆论模拟**的实体类型和关系类型。\n\n**重要：你必须输出有效的JSON格式数据，不要输出任何其他内容。**\n\n## 核心任务背景\n\n我们正在构建一个**社交媒体舆论模拟系统**。在这个系统中：\n- 每个实体都是一个可以在社交媒体上发声、互动、传播信息的\"账号\"或\"主体\"\n- 实体之间会相互影响、转发、评论、回应\n- 我们需要模拟舆论事件中各方的反应和信息传播路径\n\n因此，**实体必须是现实中真实存在的、可以在社媒上发声和互动的主体**：\n\n**可以是**：\n- 具体的个人（公众人物、当事人、意见领袖、专家学者、普通人）\n- 公司、企业（包括其官方账号）\n- 组织机构（大学、协会、NGO、工会等）\n- 政府部门、监管机构\n- 媒体机构（报纸、电视台、自媒体、网站）\n- 社交媒体平台本身\n- 特定群体代表（如校友会、粉丝团、维权群体等）\n\n**不可以是**：\n- 抽象概念（如\"舆论\"、\"情绪\"、\"趋势\"）\n- 主题/话题（如\"学术诚信\"、\"教育改革\"）\n- 观点/态度（如\"支持方\"、\"反对方\"）\n\n## 输出格式\n\n请输出JSON格式，包含以下结构：\n\n```json\n{\n    \"entity_types\": [\n        {\n            \"name\": \"实体类型名称（英文，PascalCase）\",\n            \"description\": \"简短描述（英文，不超过100字符）\",\n            \"attributes\": [\n                {\n                    \"name\": \"属性名（英文，snake_case）\",\n                    \"type\": \"text\",\n                    \"description\": \"属性描述\"\n                }\n            ],\n            \"examples\": [\"示例实体1\", \"示例实体2\"]\n        }\n    ],\n    \"edge_types\": [\n        {\n            \"name\": \"关系类型名称（英文，UPPER_SNAKE_CASE）\",\n            \"description\": \"简短描述（英文，不超过100字符）\",\n            \"source_targets\": [\n                {\"source\": \"源实体类型\", \"target\": \"目标实体类型\"}\n            ],\n            \"attributes\": []\n        }\n    ],\n    \"analysis_summary\": \"对文本内容的简要分析说明（中文）\"\n}\n```\n\n## 设计指南（极其重要！）\n\n### 1. 实体类型设计 - 必须严格遵守\n\n**数量要求：必须正好10个实体类型**\n\n**层次结构要求（必须同时包含具体类型和兜底类型）**：\n\n你的10个实体类型必须包含以下层次：\n\nA. **兜底类型（必须包含，放在列表最后2个）**：\n   - `Person`: 任何自然人个体的兜底类型。当一个人不属于其他更具体的人物类型时，归入此类。\n   - `Organization`: 任何组织机构的兜底类型。当一个组织不属于其他更具体的组织类型时，归入此类。\n\nB. **具体类型（8个，根据文本内容设计）**：\n   - 针对文本中出现的主要角色，设计更具体的类型\n   - 例如：如果文本涉及学术事件，可以有 `Student`, `Professor`, `University`\n   - 例如：如果文本涉及商业事件，可以有 `Company`, `CEO`, `Employee`\n\n**为什么需要兜底类型**：\n- 文本中会出现各种人物，如\"中小学教师\"、\"路人甲\"、\"某位网友\"\n- 如果没有专门的类型匹配，他们应该被归入 `Person`\n- 同理，小型组织、临时团体等应该归入 `Organization`\n\n**具体类型的设计原则**：\n- 从文本中识别出高频出现或关键的角色类型\n- 每个具体类型应该有明确的边界，避免重叠\n- description 必须清晰说明这个类型和兜底类型的区别\n\n### 2. 关系类型设计\n\n- 数量：6-10个\n- 关系应该反映社媒互动中的真实联系\n- 确保关系的 source_targets 涵盖你定义的实体类型\n\n### 3. 属性设计\n\n- 每个实体类型1-3个关键属性\n- **注意**：属性名不能使用 `name`、`uuid`、`group_id`、`created_at`、`summary`（这些是系统保留字）\n- 推荐使用：`full_name`, `title`, `role`, `position`, `location`, `description` 等\n\n## 实体类型参考\n\n**个人类（具体）**：\n- Student: 学生\n- Professor: 教授/学者\n- Journalist: 记者\n- Celebrity: 明星/网红\n- Executive: 高管\n- Official: 政府官员\n- Lawyer: 律师\n- Doctor: 医生\n\n**个人类（兜底）**：\n- Person: 任何自然人（不属于上述具体类型时使用）\n\n**组织类（具体）**：\n- University: 高校\n- Company: 公司企业\n- GovernmentAgency: 政府机构\n- MediaOutlet: 媒体机构\n- Hospital: 医院\n- School: 中小学\n- NGO: 非政府组织\n\n**组织类（兜底）**：\n- Organization: 任何组织机构（不属于上述具体类型时使用）\n\n## 关系类型参考\n\n- WORKS_FOR: 工作于\n- STUDIES_AT: 就读于\n- AFFILIATED_WITH: 隶属于\n- REPRESENTS: 代表\n- REGULATES: 监管\n- REPORTS_ON: 报道\n- COMMENTS_ON: 评论\n- RESPONDS_TO: 回应\n- SUPPORTS: 支持\n- OPPOSES: 反对\n- COLLABORATES_WITH: 合作\n- COMPETES_WITH: 竞争\n\"\"\"\n\n\nclass OntologyGenerator:\n    \"\"\"\n    本体生成器\n    分析文本内容，生成实体和关系类型定义\n    \"\"\"\n    \n    def __init__(self, llm_client: Optional[LLMClient] = None):\n        self.llm_client = llm_client or LLMClient()\n    \n    def generate(\n        self,\n        document_texts: List[str],\n        simulation_requirement: str,\n        additional_context: Optional[str] = None\n    ) -> Dict[str, Any]:\n        \"\"\"\n        生成本体定义\n        \n        Args:\n            document_texts: 文档文本列表\n            simulation_requirement: 模拟需求描述\n            additional_context: 额外上下文\n            \n        Returns:\n            本体定义（entity_types, edge_types等）\n        \"\"\"\n        # 构建用户消息\n        user_message = self._build_user_message(\n            document_texts, \n            simulation_requirement,\n            additional_context\n        )\n        \n        messages = [\n            {\"role\": \"system\", \"content\": ONTOLOGY_SYSTEM_PROMPT},\n            {\"role\": \"user\", \"content\": user_message}\n        ]\n        \n        # 调用LLM\n        result = self.llm_client.chat_json(\n            messages=messages,\n            temperature=0.3,\n            max_tokens=4096\n        )\n        \n        # 验证和后处理\n        result = self._validate_and_process(result)\n        \n        return result\n    \n    # 传给 LLM 的文本最大长度（5万字）\n    MAX_TEXT_LENGTH_FOR_LLM = 50000\n    \n    def _build_user_message(\n        self,\n        document_texts: List[str],\n        simulation_requirement: str,\n        additional_context: Optional[str]\n    ) -> str:\n        \"\"\"构建用户消息\"\"\"\n        \n        # 合并文本\n        combined_text = \"\\n\\n---\\n\\n\".join(document_texts)\n        original_length = len(combined_text)\n        \n        # 如果文本超过5万字，截断（仅影响传给LLM的内容，不影响图谱构建）\n        if len(combined_text) > self.MAX_TEXT_LENGTH_FOR_LLM:\n            combined_text = combined_text[:self.MAX_TEXT_LENGTH_FOR_LLM]\n            combined_text += f\"\\n\\n...(原文共{original_length}字，已截取前{self.MAX_TEXT_LENGTH_FOR_LLM}字用于本体分析)...\"\n        \n        message = f\"\"\"## 模拟需求\n\n{simulation_requirement}\n\n## 文档内容\n\n{combined_text}\n\"\"\"\n        \n        if additional_context:\n            message += f\"\"\"\n## 额外说明\n\n{additional_context}\n\"\"\"\n        \n        message += \"\"\"\n请根据以上内容，设计适合社会舆论模拟的实体类型和关系类型。\n\n**必须遵守的规则**：\n1. 必须正好输出10个实体类型\n2. 最后2个必须是兜底类型：Person（个人兜底）和 Organization（组织兜底）\n3. 前8个是根据文本内容设计的具体类型\n4. 所有实体类型必须是现实中可以发声的主体，不能是抽象概念\n5. 属性名不能使用 name、uuid、group_id 等保留字，用 full_name、org_name 等替代\n\"\"\"\n        \n        return message\n    \n    def _validate_and_process(self, result: Dict[str, Any]) -> Dict[str, Any]:\n        \"\"\"验证和后处理结果\"\"\"\n        \n        # 确保必要字段存在\n        if \"entity_types\" not in result:\n            result[\"entity_types\"] = []\n        if \"edge_types\" not in result:\n            result[\"edge_types\"] = []\n        if \"analysis_summary\" not in result:\n            result[\"analysis_summary\"] = \"\"\n        \n        # 验证实体类型\n        for entity in result[\"entity_types\"]:\n            if \"attributes\" not in entity:\n                entity[\"attributes\"] = []\n            if \"examples\" not in entity:\n                entity[\"examples\"] = []\n            # 确保description不超过100字符\n            if len(entity.get(\"description\", \"\")) > 100:\n                entity[\"description\"] = entity[\"description\"][:97] + \"...\"\n        \n        # 验证关系类型\n        for edge in result[\"edge_types\"]:\n            if \"source_targets\" not in edge:\n                edge[\"source_targets\"] = []\n            if \"attributes\" not in edge:\n                edge[\"attributes\"] = []\n            if len(edge.get(\"description\", \"\")) > 100:\n                edge[\"description\"] = edge[\"description\"][:97] + \"...\"\n        \n        # Zep API 限制：最多 10 个自定义实体类型，最多 10 个自定义边类型\n        MAX_ENTITY_TYPES = 10\n        MAX_EDGE_TYPES = 10\n        \n        # 兜底类型定义\n        person_fallback = {\n            \"name\": \"Person\",\n            \"description\": \"Any individual person not fitting other specific person types.\",\n            \"attributes\": [\n                {\"name\": \"full_name\", \"type\": \"text\", \"description\": \"Full name of the person\"},\n                {\"name\": \"role\", \"type\": \"text\", \"description\": \"Role or occupation\"}\n            ],\n            \"examples\": [\"ordinary citizen\", \"anonymous netizen\"]\n        }\n        \n        organization_fallback = {\n            \"name\": \"Organization\",\n            \"description\": \"Any organization not fitting other specific organization types.\",\n            \"attributes\": [\n                {\"name\": \"org_name\", \"type\": \"text\", \"description\": \"Name of the organization\"},\n                {\"name\": \"org_type\", \"type\": \"text\", \"description\": \"Type of organization\"}\n            ],\n            \"examples\": [\"small business\", \"community group\"]\n        }\n        \n        # 检查是否已有兜底类型\n        entity_names = {e[\"name\"] for e in result[\"entity_types\"]}\n        has_person = \"Person\" in entity_names\n        has_organization = \"Organization\" in entity_names\n        \n        # 需要添加的兜底类型\n        fallbacks_to_add = []\n        if not has_person:\n            fallbacks_to_add.append(person_fallback)\n        if not has_organization:\n            fallbacks_to_add.append(organization_fallback)\n        \n        if fallbacks_to_add:\n            current_count = len(result[\"entity_types\"])\n            needed_slots = len(fallbacks_to_add)\n            \n            # 如果添加后会超过 10 个，需要移除一些现有类型\n            if current_count + needed_slots > MAX_ENTITY_TYPES:\n                # 计算需要移除多少个\n                to_remove = current_count + needed_slots - MAX_ENTITY_TYPES\n                # 从末尾移除（保留前面更重要的具体类型）\n                result[\"entity_types\"] = result[\"entity_types\"][:-to_remove]\n            \n            # 添加兜底类型\n            result[\"entity_types\"].extend(fallbacks_to_add)\n        \n        # 最终确保不超过限制（防御性编程）\n        if len(result[\"entity_types\"]) > MAX_ENTITY_TYPES:\n            result[\"entity_types\"] = result[\"entity_types\"][:MAX_ENTITY_TYPES]\n        \n        if len(result[\"edge_types\"]) > MAX_EDGE_TYPES:\n            result[\"edge_types\"] = result[\"edge_types\"][:MAX_EDGE_TYPES]\n        \n        return result\n    \n    def generate_python_code(self, ontology: Dict[str, Any]) -> str:\n        \"\"\"\n        将本体定义转换为Python代码（类似ontology.py）\n        \n        Args:\n            ontology: 本体定义\n            \n        Returns:\n            Python代码字符串\n        \"\"\"\n        code_lines = [\n            '\"\"\"',\n            '自定义实体类型定义',\n            '由MiroFish自动生成，用于社会舆论模拟',\n            '\"\"\"',\n            '',\n            'from pydantic import Field',\n            'from zep_cloud.external_clients.ontology import EntityModel, EntityText, EdgeModel',\n            '',\n            '',\n            '# ============== 实体类型定义 ==============',\n            '',\n        ]\n        \n        # 生成实体类型\n        for entity in ontology.get(\"entity_types\", []):\n            name = entity[\"name\"]\n            desc = entity.get(\"description\", f\"A {name} entity.\")\n            \n            code_lines.append(f'class {name}(EntityModel):')\n            code_lines.append(f'    \"\"\"{desc}\"\"\"')\n            \n            attrs = entity.get(\"attributes\", [])\n            if attrs:\n                for attr in attrs:\n                    attr_name = attr[\"name\"]\n                    attr_desc = attr.get(\"description\", attr_name)\n                    code_lines.append(f'    {attr_name}: EntityText = Field(')\n                    code_lines.append(f'        description=\"{attr_desc}\",')\n                    code_lines.append(f'        default=None')\n                    code_lines.append(f'    )')\n            else:\n                code_lines.append('    pass')\n            \n            code_lines.append('')\n            code_lines.append('')\n        \n        code_lines.append('# ============== 关系类型定义 ==============')\n        code_lines.append('')\n        \n        # 生成关系类型\n        for edge in ontology.get(\"edge_types\", []):\n            name = edge[\"name\"]\n            # 转换为PascalCase类名\n            class_name = ''.join(word.capitalize() for word in name.split('_'))\n            desc = edge.get(\"description\", f\"A {name} relationship.\")\n            \n            code_lines.append(f'class {class_name}(EdgeModel):')\n            code_lines.append(f'    \"\"\"{desc}\"\"\"')\n            \n            attrs = edge.get(\"attributes\", [])\n            if attrs:\n                for attr in attrs:\n                    attr_name = attr[\"name\"]\n                    attr_desc = attr.get(\"description\", attr_name)\n                    code_lines.append(f'    {attr_name}: EntityText = Field(')\n                    code_lines.append(f'        description=\"{attr_desc}\",')\n                    code_lines.append(f'        default=None')\n                    code_lines.append(f'    )')\n            else:\n                code_lines.append('    pass')\n            \n            code_lines.append('')\n            code_lines.append('')\n        \n        # 生成类型字典\n        code_lines.append('# ============== 类型配置 ==============')\n        code_lines.append('')\n        code_lines.append('ENTITY_TYPES = {')\n        for entity in ontology.get(\"entity_types\", []):\n            name = entity[\"name\"]\n            code_lines.append(f'    \"{name}\": {name},')\n        code_lines.append('}')\n        code_lines.append('')\n        code_lines.append('EDGE_TYPES = {')\n        for edge in ontology.get(\"edge_types\", []):\n            name = edge[\"name\"]\n            class_name = ''.join(word.capitalize() for word in name.split('_'))\n            code_lines.append(f'    \"{name}\": {class_name},')\n        code_lines.append('}')\n        code_lines.append('')\n        \n        # 生成边的source_targets映射\n        code_lines.append('EDGE_SOURCE_TARGETS = {')\n        for edge in ontology.get(\"edge_types\", []):\n            name = edge[\"name\"]\n            source_targets = edge.get(\"source_targets\", [])\n            if source_targets:\n                st_list = ', '.join([\n                    f'{{\"source\": \"{st.get(\"source\", \"Entity\")}\", \"target\": \"{st.get(\"target\", \"Entity\")}\"}}'\n                    for st in source_targets\n                ])\n                code_lines.append(f'    \"{name}\": [{st_list}],')\n        code_lines.append('}')\n        \n        return '\\n'.join(code_lines)\n\n"
  },
  {
    "path": "backend/app/services/report_agent.py",
    "content": "\"\"\"\nReport Agent服务\n使用LangChain + Zep实现ReACT模式的模拟报告生成\n\n功能：\n1. 根据模拟需求和Zep图谱信息生成报告\n2. 先规划目录结构，然后分段生成\n3. 每段采用ReACT多轮思考与反思模式\n4. 支持与用户对话，在对话中自主调用检索工具\n\"\"\"\n\nimport os\nimport json\nimport time\nimport re\nfrom typing import Dict, Any, List, Optional, Callable\nfrom dataclasses import dataclass, field\nfrom datetime import datetime\nfrom enum import Enum\n\nfrom ..config import Config\nfrom ..utils.llm_client import LLMClient\nfrom ..utils.logger import get_logger\nfrom .zep_tools import (\n    ZepToolsService, \n    SearchResult, \n    InsightForgeResult, \n    PanoramaResult,\n    InterviewResult\n)\n\nlogger = get_logger('mirofish.report_agent')\n\n\nclass ReportLogger:\n    \"\"\"\n    Report Agent 详细日志记录器\n    \n    在报告文件夹中生成 agent_log.jsonl 文件，记录每一步详细动作。\n    每行是一个完整的 JSON 对象，包含时间戳、动作类型、详细内容等。\n    \"\"\"\n    \n    def __init__(self, report_id: str):\n        \"\"\"\n        初始化日志记录器\n        \n        Args:\n            report_id: 报告ID，用于确定日志文件路径\n        \"\"\"\n        self.report_id = report_id\n        self.log_file_path = os.path.join(\n            Config.UPLOAD_FOLDER, 'reports', report_id, 'agent_log.jsonl'\n        )\n        self.start_time = datetime.now()\n        self._ensure_log_file()\n    \n    def _ensure_log_file(self):\n        \"\"\"确保日志文件所在目录存在\"\"\"\n        log_dir = os.path.dirname(self.log_file_path)\n        os.makedirs(log_dir, exist_ok=True)\n    \n    def _get_elapsed_time(self) -> float:\n        \"\"\"获取从开始到现在的耗时（秒）\"\"\"\n        return (datetime.now() - self.start_time).total_seconds()\n    \n    def log(\n        self, \n        action: str, \n        stage: str,\n        details: Dict[str, Any],\n        section_title: str = None,\n        section_index: int = None\n    ):\n        \"\"\"\n        记录一条日志\n        \n        Args:\n            action: 动作类型，如 'start', 'tool_call', 'llm_response', 'section_complete' 等\n            stage: 当前阶段，如 'planning', 'generating', 'completed'\n            details: 详细内容字典，不截断\n            section_title: 当前章节标题（可选）\n            section_index: 当前章节索引（可选）\n        \"\"\"\n        log_entry = {\n            \"timestamp\": datetime.now().isoformat(),\n            \"elapsed_seconds\": round(self._get_elapsed_time(), 2),\n            \"report_id\": self.report_id,\n            \"action\": action,\n            \"stage\": stage,\n            \"section_title\": section_title,\n            \"section_index\": section_index,\n            \"details\": details\n        }\n        \n        # 追加写入 JSONL 文件\n        with open(self.log_file_path, 'a', encoding='utf-8') as f:\n            f.write(json.dumps(log_entry, ensure_ascii=False) + '\\n')\n    \n    def log_start(self, simulation_id: str, graph_id: str, simulation_requirement: str):\n        \"\"\"记录报告生成开始\"\"\"\n        self.log(\n            action=\"report_start\",\n            stage=\"pending\",\n            details={\n                \"simulation_id\": simulation_id,\n                \"graph_id\": graph_id,\n                \"simulation_requirement\": simulation_requirement,\n                \"message\": \"报告生成任务开始\"\n            }\n        )\n    \n    def log_planning_start(self):\n        \"\"\"记录大纲规划开始\"\"\"\n        self.log(\n            action=\"planning_start\",\n            stage=\"planning\",\n            details={\"message\": \"开始规划报告大纲\"}\n        )\n    \n    def log_planning_context(self, context: Dict[str, Any]):\n        \"\"\"记录规划时获取的上下文信息\"\"\"\n        self.log(\n            action=\"planning_context\",\n            stage=\"planning\",\n            details={\n                \"message\": \"获取模拟上下文信息\",\n                \"context\": context\n            }\n        )\n    \n    def log_planning_complete(self, outline_dict: Dict[str, Any]):\n        \"\"\"记录大纲规划完成\"\"\"\n        self.log(\n            action=\"planning_complete\",\n            stage=\"planning\",\n            details={\n                \"message\": \"大纲规划完成\",\n                \"outline\": outline_dict\n            }\n        )\n    \n    def log_section_start(self, section_title: str, section_index: int):\n        \"\"\"记录章节生成开始\"\"\"\n        self.log(\n            action=\"section_start\",\n            stage=\"generating\",\n            section_title=section_title,\n            section_index=section_index,\n            details={\"message\": f\"开始生成章节: {section_title}\"}\n        )\n    \n    def log_react_thought(self, section_title: str, section_index: int, iteration: int, thought: str):\n        \"\"\"记录 ReACT 思考过程\"\"\"\n        self.log(\n            action=\"react_thought\",\n            stage=\"generating\",\n            section_title=section_title,\n            section_index=section_index,\n            details={\n                \"iteration\": iteration,\n                \"thought\": thought,\n                \"message\": f\"ReACT 第{iteration}轮思考\"\n            }\n        )\n    \n    def log_tool_call(\n        self, \n        section_title: str, \n        section_index: int,\n        tool_name: str, \n        parameters: Dict[str, Any],\n        iteration: int\n    ):\n        \"\"\"记录工具调用\"\"\"\n        self.log(\n            action=\"tool_call\",\n            stage=\"generating\",\n            section_title=section_title,\n            section_index=section_index,\n            details={\n                \"iteration\": iteration,\n                \"tool_name\": tool_name,\n                \"parameters\": parameters,\n                \"message\": f\"调用工具: {tool_name}\"\n            }\n        )\n    \n    def log_tool_result(\n        self,\n        section_title: str,\n        section_index: int,\n        tool_name: str,\n        result: str,\n        iteration: int\n    ):\n        \"\"\"记录工具调用结果（完整内容，不截断）\"\"\"\n        self.log(\n            action=\"tool_result\",\n            stage=\"generating\",\n            section_title=section_title,\n            section_index=section_index,\n            details={\n                \"iteration\": iteration,\n                \"tool_name\": tool_name,\n                \"result\": result,  # 完整结果，不截断\n                \"result_length\": len(result),\n                \"message\": f\"工具 {tool_name} 返回结果\"\n            }\n        )\n    \n    def log_llm_response(\n        self,\n        section_title: str,\n        section_index: int,\n        response: str,\n        iteration: int,\n        has_tool_calls: bool,\n        has_final_answer: bool\n    ):\n        \"\"\"记录 LLM 响应（完整内容，不截断）\"\"\"\n        self.log(\n            action=\"llm_response\",\n            stage=\"generating\",\n            section_title=section_title,\n            section_index=section_index,\n            details={\n                \"iteration\": iteration,\n                \"response\": response,  # 完整响应，不截断\n                \"response_length\": len(response),\n                \"has_tool_calls\": has_tool_calls,\n                \"has_final_answer\": has_final_answer,\n                \"message\": f\"LLM 响应 (工具调用: {has_tool_calls}, 最终答案: {has_final_answer})\"\n            }\n        )\n    \n    def log_section_content(\n        self,\n        section_title: str,\n        section_index: int,\n        content: str,\n        tool_calls_count: int\n    ):\n        \"\"\"记录章节内容生成完成（仅记录内容，不代表整个章节完成）\"\"\"\n        self.log(\n            action=\"section_content\",\n            stage=\"generating\",\n            section_title=section_title,\n            section_index=section_index,\n            details={\n                \"content\": content,  # 完整内容，不截断\n                \"content_length\": len(content),\n                \"tool_calls_count\": tool_calls_count,\n                \"message\": f\"章节 {section_title} 内容生成完成\"\n            }\n        )\n    \n    def log_section_full_complete(\n        self,\n        section_title: str,\n        section_index: int,\n        full_content: str\n    ):\n        \"\"\"\n        记录章节生成完成\n\n        前端应监听此日志来判断一个章节是否真正完成，并获取完整内容\n        \"\"\"\n        self.log(\n            action=\"section_complete\",\n            stage=\"generating\",\n            section_title=section_title,\n            section_index=section_index,\n            details={\n                \"content\": full_content,\n                \"content_length\": len(full_content),\n                \"message\": f\"章节 {section_title} 生成完成\"\n            }\n        )\n    \n    def log_report_complete(self, total_sections: int, total_time_seconds: float):\n        \"\"\"记录报告生成完成\"\"\"\n        self.log(\n            action=\"report_complete\",\n            stage=\"completed\",\n            details={\n                \"total_sections\": total_sections,\n                \"total_time_seconds\": round(total_time_seconds, 2),\n                \"message\": \"报告生成完成\"\n            }\n        )\n    \n    def log_error(self, error_message: str, stage: str, section_title: str = None):\n        \"\"\"记录错误\"\"\"\n        self.log(\n            action=\"error\",\n            stage=stage,\n            section_title=section_title,\n            section_index=None,\n            details={\n                \"error\": error_message,\n                \"message\": f\"发生错误: {error_message}\"\n            }\n        )\n\n\nclass ReportConsoleLogger:\n    \"\"\"\n    Report Agent 控制台日志记录器\n    \n    将控制台风格的日志（INFO、WARNING等）写入报告文件夹中的 console_log.txt 文件。\n    这些日志与 agent_log.jsonl 不同，是纯文本格式的控制台输出。\n    \"\"\"\n    \n    def __init__(self, report_id: str):\n        \"\"\"\n        初始化控制台日志记录器\n        \n        Args:\n            report_id: 报告ID，用于确定日志文件路径\n        \"\"\"\n        self.report_id = report_id\n        self.log_file_path = os.path.join(\n            Config.UPLOAD_FOLDER, 'reports', report_id, 'console_log.txt'\n        )\n        self._ensure_log_file()\n        self._file_handler = None\n        self._setup_file_handler()\n    \n    def _ensure_log_file(self):\n        \"\"\"确保日志文件所在目录存在\"\"\"\n        log_dir = os.path.dirname(self.log_file_path)\n        os.makedirs(log_dir, exist_ok=True)\n    \n    def _setup_file_handler(self):\n        \"\"\"设置文件处理器，将日志同时写入文件\"\"\"\n        import logging\n        \n        # 创建文件处理器\n        self._file_handler = logging.FileHandler(\n            self.log_file_path,\n            mode='a',\n            encoding='utf-8'\n        )\n        self._file_handler.setLevel(logging.INFO)\n        \n        # 使用与控制台相同的简洁格式\n        formatter = logging.Formatter(\n            '[%(asctime)s] %(levelname)s: %(message)s',\n            datefmt='%H:%M:%S'\n        )\n        self._file_handler.setFormatter(formatter)\n        \n        # 添加到 report_agent 相关的 logger\n        loggers_to_attach = [\n            'mirofish.report_agent',\n            'mirofish.zep_tools',\n        ]\n        \n        for logger_name in loggers_to_attach:\n            target_logger = logging.getLogger(logger_name)\n            # 避免重复添加\n            if self._file_handler not in target_logger.handlers:\n                target_logger.addHandler(self._file_handler)\n    \n    def close(self):\n        \"\"\"关闭文件处理器并从 logger 中移除\"\"\"\n        import logging\n        \n        if self._file_handler:\n            loggers_to_detach = [\n                'mirofish.report_agent',\n                'mirofish.zep_tools',\n            ]\n            \n            for logger_name in loggers_to_detach:\n                target_logger = logging.getLogger(logger_name)\n                if self._file_handler in target_logger.handlers:\n                    target_logger.removeHandler(self._file_handler)\n            \n            self._file_handler.close()\n            self._file_handler = None\n    \n    def __del__(self):\n        \"\"\"析构时确保关闭文件处理器\"\"\"\n        self.close()\n\n\nclass ReportStatus(str, Enum):\n    \"\"\"报告状态\"\"\"\n    PENDING = \"pending\"\n    PLANNING = \"planning\"\n    GENERATING = \"generating\"\n    COMPLETED = \"completed\"\n    FAILED = \"failed\"\n\n\n@dataclass\nclass ReportSection:\n    \"\"\"报告章节\"\"\"\n    title: str\n    content: str = \"\"\n\n    def to_dict(self) -> Dict[str, Any]:\n        return {\n            \"title\": self.title,\n            \"content\": self.content\n        }\n\n    def to_markdown(self, level: int = 2) -> str:\n        \"\"\"转换为Markdown格式\"\"\"\n        md = f\"{'#' * level} {self.title}\\n\\n\"\n        if self.content:\n            md += f\"{self.content}\\n\\n\"\n        return md\n\n\n@dataclass\nclass ReportOutline:\n    \"\"\"报告大纲\"\"\"\n    title: str\n    summary: str\n    sections: List[ReportSection]\n    \n    def to_dict(self) -> Dict[str, Any]:\n        return {\n            \"title\": self.title,\n            \"summary\": self.summary,\n            \"sections\": [s.to_dict() for s in self.sections]\n        }\n    \n    def to_markdown(self) -> str:\n        \"\"\"转换为Markdown格式\"\"\"\n        md = f\"# {self.title}\\n\\n\"\n        md += f\"> {self.summary}\\n\\n\"\n        for section in self.sections:\n            md += section.to_markdown()\n        return md\n\n\n@dataclass\nclass Report:\n    \"\"\"完整报告\"\"\"\n    report_id: str\n    simulation_id: str\n    graph_id: str\n    simulation_requirement: str\n    status: ReportStatus\n    outline: Optional[ReportOutline] = None\n    markdown_content: str = \"\"\n    created_at: str = \"\"\n    completed_at: str = \"\"\n    error: Optional[str] = None\n    \n    def to_dict(self) -> Dict[str, Any]:\n        return {\n            \"report_id\": self.report_id,\n            \"simulation_id\": self.simulation_id,\n            \"graph_id\": self.graph_id,\n            \"simulation_requirement\": self.simulation_requirement,\n            \"status\": self.status.value,\n            \"outline\": self.outline.to_dict() if self.outline else None,\n            \"markdown_content\": self.markdown_content,\n            \"created_at\": self.created_at,\n            \"completed_at\": self.completed_at,\n            \"error\": self.error\n        }\n\n\n# ═══════════════════════════════════════════════════════════════\n# Prompt 模板常量\n# ═══════════════════════════════════════════════════════════════\n\n# ── 工具描述 ──\n\nTOOL_DESC_INSIGHT_FORGE = \"\"\"\\\n【深度洞察检索 - 强大的检索工具】\n这是我们强大的检索函数，专为深度分析设计。它会：\n1. 自动将你的问题分解为多个子问题\n2. 从多个维度检索模拟图谱中的信息\n3. 整合语义搜索、实体分析、关系链追踪的结果\n4. 返回最全面、最深度的检索内容\n\n【使用场景】\n- 需要深入分析某个话题\n- 需要了解事件的多个方面\n- 需要获取支撑报告章节的丰富素材\n\n【返回内容】\n- 相关事实原文（可直接引用）\n- 核心实体洞察\n- 关系链分析\"\"\"\n\nTOOL_DESC_PANORAMA_SEARCH = \"\"\"\\\n【广度搜索 - 获取全貌视图】\n这个工具用于获取模拟结果的完整全貌，特别适合了解事件演变过程。它会：\n1. 获取所有相关节点和关系\n2. 区分当前有效的事实和历史/过期的事实\n3. 帮助你了解舆情是如何演变的\n\n【使用场景】\n- 需要了解事件的完整发展脉络\n- 需要对比不同阶段的舆情变化\n- 需要获取全面的实体和关系信息\n\n【返回内容】\n- 当前有效事实（模拟最新结果）\n- 历史/过期事实（演变记录）\n- 所有涉及的实体\"\"\"\n\nTOOL_DESC_QUICK_SEARCH = \"\"\"\\\n【简单搜索 - 快速检索】\n轻量级的快速检索工具，适合简单、直接的信息查询。\n\n【使用场景】\n- 需要快速查找某个具体信息\n- 需要验证某个事实\n- 简单的信息检索\n\n【返回内容】\n- 与查询最相关的事实列表\"\"\"\n\nTOOL_DESC_INTERVIEW_AGENTS = \"\"\"\\\n【深度采访 - 真实Agent采访（双平台）】\n调用OASIS模拟环境的采访API，对正在运行的模拟Agent进行真实采访！\n这不是LLM模拟，而是调用真实的采访接口获取模拟Agent的原始回答。\n默认在Twitter和Reddit两个平台同时采访，获取更全面的观点。\n\n功能流程：\n1. 自动读取人设文件，了解所有模拟Agent\n2. 智能选择与采访主题最相关的Agent（如学生、媒体、官方等）\n3. 自动生成采访问题\n4. 调用 /api/simulation/interview/batch 接口在双平台进行真实采访\n5. 整合所有采访结果，提供多视角分析\n\n【使用场景】\n- 需要从不同角色视角了解事件看法（学生怎么看？媒体怎么看？官方怎么说？）\n- 需要收集多方意见和立场\n- 需要获取模拟Agent的真实回答（来自OASIS模拟环境）\n- 想让报告更生动，包含\"采访实录\"\n\n【返回内容】\n- 被采访Agent的身份信息\n- 各Agent在Twitter和Reddit两个平台的采访回答\n- 关键引言（可直接引用）\n- 采访摘要和观点对比\n\n【重要】需要OASIS模拟环境正在运行才能使用此功能！\"\"\"\n\n# ── 大纲规划 prompt ──\n\nPLAN_SYSTEM_PROMPT = \"\"\"\\\n你是一个「未来预测报告」的撰写专家，拥有对模拟世界的「上帝视角」——你可以洞察模拟中每一位Agent的行为、言论和互动。\n\n【核心理念】\n我们构建了一个模拟世界，并向其中注入了特定的「模拟需求」作为变量。模拟世界的演化结果，就是对未来可能发生情况的预测。你正在观察的不是\"实验数据\"，而是\"未来的预演\"。\n\n【你的任务】\n撰写一份「未来预测报告」，回答：\n1. 在我们设定的条件下，未来发生了什么？\n2. 各类Agent（人群）是如何反应和行动？\n3. 这个模拟揭示了哪些值得关注的未来趋势和风险？\n\n【报告定位】\n- ✅ 这是一份基于模拟的未来预测报告，揭示\"如果这样，未来会怎样\"\n- ✅ 聚焦于预测结果：事件走向、群体反应、涌现现象、潜在风险\n- ✅ 模拟世界中的Agent言行就是对未来人群行为的预测\n- ❌ 不是对现实世界现状的分析\n- ❌ 不是泛泛而谈的舆情综述\n\n【章节数量限制】\n- 最少2个章节，最多5个章节\n- 不需要子章节，每个章节直接撰写完整内容\n- 内容要精炼，聚焦于核心预测发现\n- 章节结构由你根据预测结果自主设计\n\n请输出JSON格式的报告大纲，格式如下：\n{\n    \"title\": \"报告标题\",\n    \"summary\": \"报告摘要（一句话概括核心预测发现）\",\n    \"sections\": [\n        {\n            \"title\": \"章节标题\",\n            \"description\": \"章节内容描述\"\n        }\n    ]\n}\n\n注意：sections数组最少2个，最多5个元素！\"\"\"\n\nPLAN_USER_PROMPT_TEMPLATE = \"\"\"\\\n【预测场景设定】\n我们向模拟世界注入的变量（模拟需求）：{simulation_requirement}\n\n【模拟世界规模】\n- 参与模拟的实体数量: {total_nodes}\n- 实体间产生的关系数量: {total_edges}\n- 实体类型分布: {entity_types}\n- 活跃Agent数量: {total_entities}\n\n【模拟预测到的部分未来事实样本】\n{related_facts_json}\n\n请以「上帝视角」审视这个未来预演：\n1. 在我们设定的条件下，未来呈现出了什么样的状态？\n2. 各类人群（Agent）是如何反应和行动的？\n3. 这个模拟揭示了哪些值得关注的未来趋势？\n\n根据预测结果，设计最合适的报告章节结构。\n\n【再次提醒】报告章节数量：最少2个，最多5个，内容要精炼聚焦于核心预测发现。\"\"\"\n\n# ── 章节生成 prompt ──\n\nSECTION_SYSTEM_PROMPT_TEMPLATE = \"\"\"\\\n你是一个「未来预测报告」的撰写专家，正在撰写报告的一个章节。\n\n报告标题: {report_title}\n报告摘要: {report_summary}\n预测场景（模拟需求）: {simulation_requirement}\n\n当前要撰写的章节: {section_title}\n\n═══════════════════════════════════════════════════════════════\n【核心理念】\n═══════════════════════════════════════════════════════════════\n\n模拟世界是对未来的预演。我们向模拟世界注入了特定条件（模拟需求），\n模拟中Agent的行为和互动，就是对未来人群行为的预测。\n\n你的任务是：\n- 揭示在设定条件下，未来发生了什么\n- 预测各类人群（Agent）是如何反应和行动的\n- 发现值得关注的未来趋势、风险和机会\n\n❌ 不要写成对现实世界现状的分析\n✅ 要聚焦于\"未来会怎样\"——模拟结果就是预测的未来\n\n═══════════════════════════════════════════════════════════════\n【最重要的规则 - 必须遵守】\n═══════════════════════════════════════════════════════════════\n\n1. 【必须调用工具观察模拟世界】\n   - 你正在以「上帝视角」观察未来的预演\n   - 所有内容必须来自模拟世界中发生的事件和Agent言行\n   - 禁止使用你自己的知识来编写报告内容\n   - 每个章节至少调用3次工具（最多5次）来观察模拟的世界，它代表了未来\n\n2. 【必须引用Agent的原始言行】\n   - Agent的发言和行为是对未来人群行为的预测\n   - 在报告中使用引用格式展示这些预测，例如：\n     > \"某类人群会表示：原文内容...\"\n   - 这些引用是模拟预测的核心证据\n\n3. 【语言一致性 - 引用内容必须翻译为报告语言】\n   - 工具返回的内容可能包含英文或中英文混杂的表述\n   - 如果模拟需求和材料原文是中文的，报告必须全部使用中文撰写\n   - 当你引用工具返回的英文或中英混杂内容时，必须将其翻译为流畅的中文后再写入报告\n   - 翻译时保持原意不变，确保表述自然通顺\n   - 这一规则同时适用于正文和引用块（> 格式）中的内容\n\n4. 【忠实呈现预测结果】\n   - 报告内容必须反映模拟世界中的代表未来的模拟结果\n   - 不要添加模拟中不存在的信息\n   - 如果某方面信息不足，如实说明\n\n═══════════════════════════════════════════════════════════════\n【⚠️ 格式规范 - 极其重要！】\n═══════════════════════════════════════════════════════════════\n\n【一个章节 = 最小内容单位】\n- 每个章节是报告的最小分块单位\n- ❌ 禁止在章节内使用任何 Markdown 标题（#、##、###、#### 等）\n- ❌ 禁止在内容开头添加章节主标题\n- ✅ 章节标题由系统自动添加，你只需撰写纯正文内容\n- ✅ 使用**粗体**、段落分隔、引用、列表来组织内容，但不要用标题\n\n【正确示例】\n```\n本章节分析了事件的舆论传播态势。通过对模拟数据的深入分析，我们发现...\n\n**首发引爆阶段**\n\n微博作为舆情的第一现场，承担了信息首发的核心功能：\n\n> \"微博贡献了68%的首发声量...\"\n\n**情绪放大阶段**\n\n抖音平台进一步放大了事件影响力：\n\n- 视觉冲击力强\n- 情绪共鸣度高\n```\n\n【错误示例】\n```\n## 执行摘要          ← 错误！不要添加任何标题\n### 一、首发阶段     ← 错误！不要用###分小节\n#### 1.1 详细分析   ← 错误！不要用####细分\n\n本章节分析了...\n```\n\n═══════════════════════════════════════════════════════════════\n【可用检索工具】（每章节调用3-5次）\n═══════════════════════════════════════════════════════════════\n\n{tools_description}\n\n【工具使用建议 - 请混合使用不同工具，不要只用一种】\n- insight_forge: 深度洞察分析，自动分解问题并多维度检索事实和关系\n- panorama_search: 广角全景搜索，了解事件全貌、时间线和演变过程\n- quick_search: 快速验证某个具体信息点\n- interview_agents: 采访模拟Agent，获取不同角色的第一人称观点和真实反应\n\n═══════════════════════════════════════════════════════════════\n【工作流程】\n═══════════════════════════════════════════════════════════════\n\n每次回复你只能做以下两件事之一（不可同时做）：\n\n选项A - 调用工具：\n输出你的思考，然后用以下格式调用一个工具：\n<tool_call>\n{{\"name\": \"工具名称\", \"parameters\": {{\"参数名\": \"参数值\"}}}}\n</tool_call>\n系统会执行工具并把结果返回给你。你不需要也不能自己编写工具返回结果。\n\n选项B - 输出最终内容：\n当你已通过工具获取了足够信息，以 \"Final Answer:\" 开头输出章节内容。\n\n⚠️ 严格禁止：\n- 禁止在一次回复中同时包含工具调用和 Final Answer\n- 禁止自己编造工具返回结果（Observation），所有工具结果由系统注入\n- 每次回复最多调用一个工具\n\n═══════════════════════════════════════════════════════════════\n【章节内容要求】\n═══════════════════════════════════════════════════════════════\n\n1. 内容必须基于工具检索到的模拟数据\n2. 大量引用原文来展示模拟效果\n3. 使用Markdown格式（但禁止使用标题）：\n   - 使用 **粗体文字** 标记重点（代替子标题）\n   - 使用列表（-或1.2.3.）组织要点\n   - 使用空行分隔不同段落\n   - ❌ 禁止使用 #、##、###、#### 等任何标题语法\n4. 【引用格式规范 - 必须单独成段】\n   引用必须独立成段，前后各有一个空行，不能混在段落中：\n\n   ✅ 正确格式：\n   ```\n   校方的回应被认为缺乏实质内容。\n\n   > \"校方的应对模式在瞬息万变的社交媒体环境中显得僵化和迟缓。\"\n\n   这一评价反映了公众的普遍不满。\n   ```\n\n   ❌ 错误格式：\n   ```\n   校方的回应被认为缺乏实质内容。> \"校方的应对模式...\" 这一评价反映了...\n   ```\n5. 保持与其他章节的逻辑连贯性\n6. 【避免重复】仔细阅读下方已完成的章节内容，不要重复描述相同的信息\n7. 【再次强调】不要添加任何标题！用**粗体**代替小节标题\"\"\"\n\nSECTION_USER_PROMPT_TEMPLATE = \"\"\"\\\n已完成的章节内容（请仔细阅读，避免重复）：\n{previous_content}\n\n═══════════════════════════════════════════════════════════════\n【当前任务】撰写章节: {section_title}\n═══════════════════════════════════════════════════════════════\n\n【重要提醒】\n1. 仔细阅读上方已完成的章节，避免重复相同的内容！\n2. 开始前必须先调用工具获取模拟数据\n3. 请混合使用不同工具，不要只用一种\n4. 报告内容必须来自检索结果，不要使用自己的知识\n\n【⚠️ 格式警告 - 必须遵守】\n- ❌ 不要写任何标题（#、##、###、####都不行）\n- ❌ 不要写\"{section_title}\"作为开头\n- ✅ 章节标题由系统自动添加\n- ✅ 直接写正文，用**粗体**代替小节标题\n\n请开始：\n1. 首先思考（Thought）这个章节需要什么信息\n2. 然后调用工具（Action）获取模拟数据\n3. 收集足够信息后输出 Final Answer（纯正文，无任何标题）\"\"\"\n\n# ── ReACT 循环内消息模板 ──\n\nREACT_OBSERVATION_TEMPLATE = \"\"\"\\\nObservation（检索结果）:\n\n═══ 工具 {tool_name} 返回 ═══\n{result}\n\n═══════════════════════════════════════════════════════════════\n已调用工具 {tool_calls_count}/{max_tool_calls} 次（已用: {used_tools_str}）{unused_hint}\n- 如果信息充分：以 \"Final Answer:\" 开头输出章节内容（必须引用上述原文）\n- 如果需要更多信息：调用一个工具继续检索\n═══════════════════════════════════════════════════════════════\"\"\"\n\nREACT_INSUFFICIENT_TOOLS_MSG = (\n    \"【注意】你只调用了{tool_calls_count}次工具，至少需要{min_tool_calls}次。\"\n    \"请再调用工具获取更多模拟数据，然后再输出 Final Answer。{unused_hint}\"\n)\n\nREACT_INSUFFICIENT_TOOLS_MSG_ALT = (\n    \"当前只调用了 {tool_calls_count} 次工具，至少需要 {min_tool_calls} 次。\"\n    \"请调用工具获取模拟数据。{unused_hint}\"\n)\n\nREACT_TOOL_LIMIT_MSG = (\n    \"工具调用次数已达上限（{tool_calls_count}/{max_tool_calls}），不能再调用工具。\"\n    '请立即基于已获取的信息，以 \"Final Answer:\" 开头输出章节内容。'\n)\n\nREACT_UNUSED_TOOLS_HINT = \"\\n💡 你还没有使用过: {unused_list}，建议尝试不同工具获取多角度信息\"\n\nREACT_FORCE_FINAL_MSG = \"已达到工具调用限制，请直接输出 Final Answer: 并生成章节内容。\"\n\n# ── Chat prompt ──\n\nCHAT_SYSTEM_PROMPT_TEMPLATE = \"\"\"\\\n你是一个简洁高效的模拟预测助手。\n\n【背景】\n预测条件: {simulation_requirement}\n\n【已生成的分析报告】\n{report_content}\n\n【规则】\n1. 优先基于上述报告内容回答问题\n2. 直接回答问题，避免冗长的思考论述\n3. 仅在报告内容不足以回答时，才调用工具检索更多数据\n4. 回答要简洁、清晰、有条理\n\n【可用工具】（仅在需要时使用，最多调用1-2次）\n{tools_description}\n\n【工具调用格式】\n<tool_call>\n{{\"name\": \"工具名称\", \"parameters\": {{\"参数名\": \"参数值\"}}}}\n</tool_call>\n\n【回答风格】\n- 简洁直接，不要长篇大论\n- 使用 > 格式引用关键内容\n- 优先给出结论，再解释原因\"\"\"\n\nCHAT_OBSERVATION_SUFFIX = \"\\n\\n请简洁回答问题。\"\n\n\n# ═══════════════════════════════════════════════════════════════\n# ReportAgent 主类\n# ═══════════════════════════════════════════════════════════════\n\n\nclass ReportAgent:\n    \"\"\"\n    Report Agent - 模拟报告生成Agent\n\n    采用ReACT（Reasoning + Acting）模式：\n    1. 规划阶段：分析模拟需求，规划报告目录结构\n    2. 生成阶段：逐章节生成内容，每章节可多次调用工具获取信息\n    3. 反思阶段：检查内容完整性和准确性\n    \"\"\"\n    \n    # 最大工具调用次数（每个章节）\n    MAX_TOOL_CALLS_PER_SECTION = 5\n    \n    # 最大反思轮数\n    MAX_REFLECTION_ROUNDS = 3\n    \n    # 对话中的最大工具调用次数\n    MAX_TOOL_CALLS_PER_CHAT = 2\n    \n    def __init__(\n        self, \n        graph_id: str,\n        simulation_id: str,\n        simulation_requirement: str,\n        llm_client: Optional[LLMClient] = None,\n        zep_tools: Optional[ZepToolsService] = None\n    ):\n        \"\"\"\n        初始化Report Agent\n        \n        Args:\n            graph_id: 图谱ID\n            simulation_id: 模拟ID\n            simulation_requirement: 模拟需求描述\n            llm_client: LLM客户端（可选）\n            zep_tools: Zep工具服务（可选）\n        \"\"\"\n        self.graph_id = graph_id\n        self.simulation_id = simulation_id\n        self.simulation_requirement = simulation_requirement\n        \n        self.llm = llm_client or LLMClient()\n        self.zep_tools = zep_tools or ZepToolsService()\n        \n        # 工具定义\n        self.tools = self._define_tools()\n        \n        # 日志记录器（在 generate_report 中初始化）\n        self.report_logger: Optional[ReportLogger] = None\n        # 控制台日志记录器（在 generate_report 中初始化）\n        self.console_logger: Optional[ReportConsoleLogger] = None\n        \n        logger.info(f\"ReportAgent 初始化完成: graph_id={graph_id}, simulation_id={simulation_id}\")\n    \n    def _define_tools(self) -> Dict[str, Dict[str, Any]]:\n        \"\"\"定义可用工具\"\"\"\n        return {\n            \"insight_forge\": {\n                \"name\": \"insight_forge\",\n                \"description\": TOOL_DESC_INSIGHT_FORGE,\n                \"parameters\": {\n                    \"query\": \"你想深入分析的问题或话题\",\n                    \"report_context\": \"当前报告章节的上下文（可选，有助于生成更精准的子问题）\"\n                }\n            },\n            \"panorama_search\": {\n                \"name\": \"panorama_search\",\n                \"description\": TOOL_DESC_PANORAMA_SEARCH,\n                \"parameters\": {\n                    \"query\": \"搜索查询，用于相关性排序\",\n                    \"include_expired\": \"是否包含过期/历史内容（默认True）\"\n                }\n            },\n            \"quick_search\": {\n                \"name\": \"quick_search\",\n                \"description\": TOOL_DESC_QUICK_SEARCH,\n                \"parameters\": {\n                    \"query\": \"搜索查询字符串\",\n                    \"limit\": \"返回结果数量（可选，默认10）\"\n                }\n            },\n            \"interview_agents\": {\n                \"name\": \"interview_agents\",\n                \"description\": TOOL_DESC_INTERVIEW_AGENTS,\n                \"parameters\": {\n                    \"interview_topic\": \"采访主题或需求描述（如：'了解学生对宿舍甲醛事件的看法'）\",\n                    \"max_agents\": \"最多采访的Agent数量（可选，默认5，最大10）\"\n                }\n            }\n        }\n    \n    def _execute_tool(self, tool_name: str, parameters: Dict[str, Any], report_context: str = \"\") -> str:\n        \"\"\"\n        执行工具调用\n        \n        Args:\n            tool_name: 工具名称\n            parameters: 工具参数\n            report_context: 报告上下文（用于InsightForge）\n            \n        Returns:\n            工具执行结果（文本格式）\n        \"\"\"\n        logger.info(f\"执行工具: {tool_name}, 参数: {parameters}\")\n        \n        try:\n            if tool_name == \"insight_forge\":\n                query = parameters.get(\"query\", \"\")\n                ctx = parameters.get(\"report_context\", \"\") or report_context\n                result = self.zep_tools.insight_forge(\n                    graph_id=self.graph_id,\n                    query=query,\n                    simulation_requirement=self.simulation_requirement,\n                    report_context=ctx\n                )\n                return result.to_text()\n            \n            elif tool_name == \"panorama_search\":\n                # 广度搜索 - 获取全貌\n                query = parameters.get(\"query\", \"\")\n                include_expired = parameters.get(\"include_expired\", True)\n                if isinstance(include_expired, str):\n                    include_expired = include_expired.lower() in ['true', '1', 'yes']\n                result = self.zep_tools.panorama_search(\n                    graph_id=self.graph_id,\n                    query=query,\n                    include_expired=include_expired\n                )\n                return result.to_text()\n            \n            elif tool_name == \"quick_search\":\n                # 简单搜索 - 快速检索\n                query = parameters.get(\"query\", \"\")\n                limit = parameters.get(\"limit\", 10)\n                if isinstance(limit, str):\n                    limit = int(limit)\n                result = self.zep_tools.quick_search(\n                    graph_id=self.graph_id,\n                    query=query,\n                    limit=limit\n                )\n                return result.to_text()\n            \n            elif tool_name == \"interview_agents\":\n                # 深度采访 - 调用真实的OASIS采访API获取模拟Agent的回答（双平台）\n                interview_topic = parameters.get(\"interview_topic\", parameters.get(\"query\", \"\"))\n                max_agents = parameters.get(\"max_agents\", 5)\n                if isinstance(max_agents, str):\n                    max_agents = int(max_agents)\n                max_agents = min(max_agents, 10)\n                result = self.zep_tools.interview_agents(\n                    simulation_id=self.simulation_id,\n                    interview_requirement=interview_topic,\n                    simulation_requirement=self.simulation_requirement,\n                    max_agents=max_agents\n                )\n                return result.to_text()\n            \n            # ========== 向后兼容的旧工具（内部重定向到新工具） ==========\n            \n            elif tool_name == \"search_graph\":\n                # 重定向到 quick_search\n                logger.info(\"search_graph 已重定向到 quick_search\")\n                return self._execute_tool(\"quick_search\", parameters, report_context)\n            \n            elif tool_name == \"get_graph_statistics\":\n                result = self.zep_tools.get_graph_statistics(self.graph_id)\n                return json.dumps(result, ensure_ascii=False, indent=2)\n            \n            elif tool_name == \"get_entity_summary\":\n                entity_name = parameters.get(\"entity_name\", \"\")\n                result = self.zep_tools.get_entity_summary(\n                    graph_id=self.graph_id,\n                    entity_name=entity_name\n                )\n                return json.dumps(result, ensure_ascii=False, indent=2)\n            \n            elif tool_name == \"get_simulation_context\":\n                # 重定向到 insight_forge，因为它更强大\n                logger.info(\"get_simulation_context 已重定向到 insight_forge\")\n                query = parameters.get(\"query\", self.simulation_requirement)\n                return self._execute_tool(\"insight_forge\", {\"query\": query}, report_context)\n            \n            elif tool_name == \"get_entities_by_type\":\n                entity_type = parameters.get(\"entity_type\", \"\")\n                nodes = self.zep_tools.get_entities_by_type(\n                    graph_id=self.graph_id,\n                    entity_type=entity_type\n                )\n                result = [n.to_dict() for n in nodes]\n                return json.dumps(result, ensure_ascii=False, indent=2)\n            \n            else:\n                return f\"未知工具: {tool_name}。请使用以下工具之一: insight_forge, panorama_search, quick_search\"\n                \n        except Exception as e:\n            logger.error(f\"工具执行失败: {tool_name}, 错误: {str(e)}\")\n            return f\"工具执行失败: {str(e)}\"\n    \n    # 合法的工具名称集合，用于裸 JSON 兜底解析时校验\n    VALID_TOOL_NAMES = {\"insight_forge\", \"panorama_search\", \"quick_search\", \"interview_agents\"}\n\n    def _parse_tool_calls(self, response: str) -> List[Dict[str, Any]]:\n        \"\"\"\n        从LLM响应中解析工具调用\n\n        支持的格式（按优先级）：\n        1. <tool_call>{\"name\": \"tool_name\", \"parameters\": {...}}</tool_call>\n        2. 裸 JSON（响应整体或单行就是一个工具调用 JSON）\n        \"\"\"\n        tool_calls = []\n\n        # 格式1: XML风格（标准格式）\n        xml_pattern = r'<tool_call>\\s*(\\{.*?\\})\\s*</tool_call>'\n        for match in re.finditer(xml_pattern, response, re.DOTALL):\n            try:\n                call_data = json.loads(match.group(1))\n                tool_calls.append(call_data)\n            except json.JSONDecodeError:\n                pass\n\n        if tool_calls:\n            return tool_calls\n\n        # 格式2: 兜底 - LLM 直接输出裸 JSON（没包 <tool_call> 标签）\n        # 只在格式1未匹配时尝试，避免误匹配正文中的 JSON\n        stripped = response.strip()\n        if stripped.startswith('{') and stripped.endswith('}'):\n            try:\n                call_data = json.loads(stripped)\n                if self._is_valid_tool_call(call_data):\n                    tool_calls.append(call_data)\n                    return tool_calls\n            except json.JSONDecodeError:\n                pass\n\n        # 响应可能包含思考文字 + 裸 JSON，尝试提取最后一个 JSON 对象\n        json_pattern = r'(\\{\"(?:name|tool)\"\\s*:.*?\\})\\s*$'\n        match = re.search(json_pattern, stripped, re.DOTALL)\n        if match:\n            try:\n                call_data = json.loads(match.group(1))\n                if self._is_valid_tool_call(call_data):\n                    tool_calls.append(call_data)\n            except json.JSONDecodeError:\n                pass\n\n        return tool_calls\n\n    def _is_valid_tool_call(self, data: dict) -> bool:\n        \"\"\"校验解析出的 JSON 是否是合法的工具调用\"\"\"\n        # 支持 {\"name\": ..., \"parameters\": ...} 和 {\"tool\": ..., \"params\": ...} 两种键名\n        tool_name = data.get(\"name\") or data.get(\"tool\")\n        if tool_name and tool_name in self.VALID_TOOL_NAMES:\n            # 统一键名为 name / parameters\n            if \"tool\" in data:\n                data[\"name\"] = data.pop(\"tool\")\n            if \"params\" in data and \"parameters\" not in data:\n                data[\"parameters\"] = data.pop(\"params\")\n            return True\n        return False\n    \n    def _get_tools_description(self) -> str:\n        \"\"\"生成工具描述文本\"\"\"\n        desc_parts = [\"可用工具：\"]\n        for name, tool in self.tools.items():\n            params_desc = \", \".join([f\"{k}: {v}\" for k, v in tool[\"parameters\"].items()])\n            desc_parts.append(f\"- {name}: {tool['description']}\")\n            if params_desc:\n                desc_parts.append(f\"  参数: {params_desc}\")\n        return \"\\n\".join(desc_parts)\n    \n    def plan_outline(\n        self, \n        progress_callback: Optional[Callable] = None\n    ) -> ReportOutline:\n        \"\"\"\n        规划报告大纲\n        \n        使用LLM分析模拟需求，规划报告的目录结构\n        \n        Args:\n            progress_callback: 进度回调函数\n            \n        Returns:\n            ReportOutline: 报告大纲\n        \"\"\"\n        logger.info(\"开始规划报告大纲...\")\n        \n        if progress_callback:\n            progress_callback(\"planning\", 0, \"正在分析模拟需求...\")\n        \n        # 首先获取模拟上下文\n        context = self.zep_tools.get_simulation_context(\n            graph_id=self.graph_id,\n            simulation_requirement=self.simulation_requirement\n        )\n        \n        if progress_callback:\n            progress_callback(\"planning\", 30, \"正在生成报告大纲...\")\n        \n        system_prompt = PLAN_SYSTEM_PROMPT\n        user_prompt = PLAN_USER_PROMPT_TEMPLATE.format(\n            simulation_requirement=self.simulation_requirement,\n            total_nodes=context.get('graph_statistics', {}).get('total_nodes', 0),\n            total_edges=context.get('graph_statistics', {}).get('total_edges', 0),\n            entity_types=list(context.get('graph_statistics', {}).get('entity_types', {}).keys()),\n            total_entities=context.get('total_entities', 0),\n            related_facts_json=json.dumps(context.get('related_facts', [])[:10], ensure_ascii=False, indent=2),\n        )\n\n        try:\n            response = self.llm.chat_json(\n                messages=[\n                    {\"role\": \"system\", \"content\": system_prompt},\n                    {\"role\": \"user\", \"content\": user_prompt}\n                ],\n                temperature=0.3\n            )\n            \n            if progress_callback:\n                progress_callback(\"planning\", 80, \"正在解析大纲结构...\")\n            \n            # 解析大纲\n            sections = []\n            for section_data in response.get(\"sections\", []):\n                sections.append(ReportSection(\n                    title=section_data.get(\"title\", \"\"),\n                    content=\"\"\n                ))\n            \n            outline = ReportOutline(\n                title=response.get(\"title\", \"模拟分析报告\"),\n                summary=response.get(\"summary\", \"\"),\n                sections=sections\n            )\n            \n            if progress_callback:\n                progress_callback(\"planning\", 100, \"大纲规划完成\")\n            \n            logger.info(f\"大纲规划完成: {len(sections)} 个章节\")\n            return outline\n            \n        except Exception as e:\n            logger.error(f\"大纲规划失败: {str(e)}\")\n            # 返回默认大纲（3个章节，作为fallback）\n            return ReportOutline(\n                title=\"未来预测报告\",\n                summary=\"基于模拟预测的未来趋势与风险分析\",\n                sections=[\n                    ReportSection(title=\"预测场景与核心发现\"),\n                    ReportSection(title=\"人群行为预测分析\"),\n                    ReportSection(title=\"趋势展望与风险提示\")\n                ]\n            )\n    \n    def _generate_section_react(\n        self, \n        section: ReportSection,\n        outline: ReportOutline,\n        previous_sections: List[str],\n        progress_callback: Optional[Callable] = None,\n        section_index: int = 0\n    ) -> str:\n        \"\"\"\n        使用ReACT模式生成单个章节内容\n        \n        ReACT循环：\n        1. Thought（思考）- 分析需要什么信息\n        2. Action（行动）- 调用工具获取信息\n        3. Observation（观察）- 分析工具返回结果\n        4. 重复直到信息足够或达到最大次数\n        5. Final Answer（最终回答）- 生成章节内容\n        \n        Args:\n            section: 要生成的章节\n            outline: 完整大纲\n            previous_sections: 之前章节的内容（用于保持连贯性）\n            progress_callback: 进度回调\n            section_index: 章节索引（用于日志记录）\n            \n        Returns:\n            章节内容（Markdown格式）\n        \"\"\"\n        logger.info(f\"ReACT生成章节: {section.title}\")\n        \n        # 记录章节开始日志\n        if self.report_logger:\n            self.report_logger.log_section_start(section.title, section_index)\n        \n        system_prompt = SECTION_SYSTEM_PROMPT_TEMPLATE.format(\n            report_title=outline.title,\n            report_summary=outline.summary,\n            simulation_requirement=self.simulation_requirement,\n            section_title=section.title,\n            tools_description=self._get_tools_description(),\n        )\n\n        # 构建用户prompt - 每个已完成章节各传入最大4000字\n        if previous_sections:\n            previous_parts = []\n            for sec in previous_sections:\n                # 每个章节最多4000字\n                truncated = sec[:4000] + \"...\" if len(sec) > 4000 else sec\n                previous_parts.append(truncated)\n            previous_content = \"\\n\\n---\\n\\n\".join(previous_parts)\n        else:\n            previous_content = \"（这是第一个章节）\"\n        \n        user_prompt = SECTION_USER_PROMPT_TEMPLATE.format(\n            previous_content=previous_content,\n            section_title=section.title,\n        )\n\n        messages = [\n            {\"role\": \"system\", \"content\": system_prompt},\n            {\"role\": \"user\", \"content\": user_prompt}\n        ]\n        \n        # ReACT循环\n        tool_calls_count = 0\n        max_iterations = 5  # 最大迭代轮数\n        min_tool_calls = 3  # 最少工具调用次数\n        conflict_retries = 0  # 工具调用与Final Answer同时出现的连续冲突次数\n        used_tools = set()  # 记录已调用过的工具名\n        all_tools = {\"insight_forge\", \"panorama_search\", \"quick_search\", \"interview_agents\"}\n\n        # 报告上下文，用于InsightForge的子问题生成\n        report_context = f\"章节标题: {section.title}\\n模拟需求: {self.simulation_requirement}\"\n        \n        for iteration in range(max_iterations):\n            if progress_callback:\n                progress_callback(\n                    \"generating\", \n                    int((iteration / max_iterations) * 100),\n                    f\"深度检索与撰写中 ({tool_calls_count}/{self.MAX_TOOL_CALLS_PER_SECTION})\"\n                )\n            \n            # 调用LLM\n            response = self.llm.chat(\n                messages=messages,\n                temperature=0.5,\n                max_tokens=4096\n            )\n\n            # 检查 LLM 返回是否为 None（API 异常或内容为空）\n            if response is None:\n                logger.warning(f\"章节 {section.title} 第 {iteration + 1} 次迭代: LLM 返回 None\")\n                # 如果还有迭代次数，添加消息并重试\n                if iteration < max_iterations - 1:\n                    messages.append({\"role\": \"assistant\", \"content\": \"（响应为空）\"})\n                    messages.append({\"role\": \"user\", \"content\": \"请继续生成内容。\"})\n                    continue\n                # 最后一次迭代也返回 None，跳出循环进入强制收尾\n                break\n\n            logger.debug(f\"LLM响应: {response[:200]}...\")\n\n            # 解析一次，复用结果\n            tool_calls = self._parse_tool_calls(response)\n            has_tool_calls = bool(tool_calls)\n            has_final_answer = \"Final Answer:\" in response\n\n            # ── 冲突处理：LLM 同时输出了工具调用和 Final Answer ──\n            if has_tool_calls and has_final_answer:\n                conflict_retries += 1\n                logger.warning(\n                    f\"章节 {section.title} 第 {iteration+1} 轮: \"\n                    f\"LLM 同时输出工具调用和 Final Answer（第 {conflict_retries} 次冲突）\"\n                )\n\n                if conflict_retries <= 2:\n                    # 前两次：丢弃本次响应，要求 LLM 重新回复\n                    messages.append({\"role\": \"assistant\", \"content\": response})\n                    messages.append({\n                        \"role\": \"user\",\n                        \"content\": (\n                            \"【格式错误】你在一次回复中同时包含了工具调用和 Final Answer，这是不允许的。\\n\"\n                            \"每次回复只能做以下两件事之一：\\n\"\n                            \"- 调用一个工具（输出一个 <tool_call> 块，不要写 Final Answer）\\n\"\n                            \"- 输出最终内容（以 'Final Answer:' 开头，不要包含 <tool_call>）\\n\"\n                            \"请重新回复，只做其中一件事。\"\n                        ),\n                    })\n                    continue\n                else:\n                    # 第三次：降级处理，截断到第一个工具调用，强制执行\n                    logger.warning(\n                        f\"章节 {section.title}: 连续 {conflict_retries} 次冲突，\"\n                        \"降级为截断执行第一个工具调用\"\n                    )\n                    first_tool_end = response.find('</tool_call>')\n                    if first_tool_end != -1:\n                        response = response[:first_tool_end + len('</tool_call>')]\n                        tool_calls = self._parse_tool_calls(response)\n                        has_tool_calls = bool(tool_calls)\n                    has_final_answer = False\n                    conflict_retries = 0\n\n            # 记录 LLM 响应日志\n            if self.report_logger:\n                self.report_logger.log_llm_response(\n                    section_title=section.title,\n                    section_index=section_index,\n                    response=response,\n                    iteration=iteration + 1,\n                    has_tool_calls=has_tool_calls,\n                    has_final_answer=has_final_answer\n                )\n\n            # ── 情况1：LLM 输出了 Final Answer ──\n            if has_final_answer:\n                # 工具调用次数不足，拒绝并要求继续调工具\n                if tool_calls_count < min_tool_calls:\n                    messages.append({\"role\": \"assistant\", \"content\": response})\n                    unused_tools = all_tools - used_tools\n                    unused_hint = f\"（这些工具还未使用，推荐用一下他们: {', '.join(unused_tools)}）\" if unused_tools else \"\"\n                    messages.append({\n                        \"role\": \"user\",\n                        \"content\": REACT_INSUFFICIENT_TOOLS_MSG.format(\n                            tool_calls_count=tool_calls_count,\n                            min_tool_calls=min_tool_calls,\n                            unused_hint=unused_hint,\n                        ),\n                    })\n                    continue\n\n                # 正常结束\n                final_answer = response.split(\"Final Answer:\")[-1].strip()\n                logger.info(f\"章节 {section.title} 生成完成（工具调用: {tool_calls_count}次）\")\n\n                if self.report_logger:\n                    self.report_logger.log_section_content(\n                        section_title=section.title,\n                        section_index=section_index,\n                        content=final_answer,\n                        tool_calls_count=tool_calls_count\n                    )\n                return final_answer\n\n            # ── 情况2：LLM 尝试调用工具 ──\n            if has_tool_calls:\n                # 工具额度已耗尽 → 明确告知，要求输出 Final Answer\n                if tool_calls_count >= self.MAX_TOOL_CALLS_PER_SECTION:\n                    messages.append({\"role\": \"assistant\", \"content\": response})\n                    messages.append({\n                        \"role\": \"user\",\n                        \"content\": REACT_TOOL_LIMIT_MSG.format(\n                            tool_calls_count=tool_calls_count,\n                            max_tool_calls=self.MAX_TOOL_CALLS_PER_SECTION,\n                        ),\n                    })\n                    continue\n\n                # 只执行第一个工具调用\n                call = tool_calls[0]\n                if len(tool_calls) > 1:\n                    logger.info(f\"LLM 尝试调用 {len(tool_calls)} 个工具，只执行第一个: {call['name']}\")\n\n                if self.report_logger:\n                    self.report_logger.log_tool_call(\n                        section_title=section.title,\n                        section_index=section_index,\n                        tool_name=call[\"name\"],\n                        parameters=call.get(\"parameters\", {}),\n                        iteration=iteration + 1\n                    )\n\n                result = self._execute_tool(\n                    call[\"name\"],\n                    call.get(\"parameters\", {}),\n                    report_context=report_context\n                )\n\n                if self.report_logger:\n                    self.report_logger.log_tool_result(\n                        section_title=section.title,\n                        section_index=section_index,\n                        tool_name=call[\"name\"],\n                        result=result,\n                        iteration=iteration + 1\n                    )\n\n                tool_calls_count += 1\n                used_tools.add(call['name'])\n\n                # 构建未使用工具提示\n                unused_tools = all_tools - used_tools\n                unused_hint = \"\"\n                if unused_tools and tool_calls_count < self.MAX_TOOL_CALLS_PER_SECTION:\n                    unused_hint = REACT_UNUSED_TOOLS_HINT.format(unused_list=\"、\".join(unused_tools))\n\n                messages.append({\"role\": \"assistant\", \"content\": response})\n                messages.append({\n                    \"role\": \"user\",\n                    \"content\": REACT_OBSERVATION_TEMPLATE.format(\n                        tool_name=call[\"name\"],\n                        result=result,\n                        tool_calls_count=tool_calls_count,\n                        max_tool_calls=self.MAX_TOOL_CALLS_PER_SECTION,\n                        used_tools_str=\", \".join(used_tools),\n                        unused_hint=unused_hint,\n                    ),\n                })\n                continue\n\n            # ── 情况3：既没有工具调用，也没有 Final Answer ──\n            messages.append({\"role\": \"assistant\", \"content\": response})\n\n            if tool_calls_count < min_tool_calls:\n                # 工具调用次数不足，推荐未用过的工具\n                unused_tools = all_tools - used_tools\n                unused_hint = f\"（这些工具还未使用，推荐用一下他们: {', '.join(unused_tools)}）\" if unused_tools else \"\"\n\n                messages.append({\n                    \"role\": \"user\",\n                    \"content\": REACT_INSUFFICIENT_TOOLS_MSG_ALT.format(\n                        tool_calls_count=tool_calls_count,\n                        min_tool_calls=min_tool_calls,\n                        unused_hint=unused_hint,\n                    ),\n                })\n                continue\n\n            # 工具调用已足够，LLM 输出了内容但没带 \"Final Answer:\" 前缀\n            # 直接将这段内容作为最终答案，不再空转\n            logger.info(f\"章节 {section.title} 未检测到 'Final Answer:' 前缀，直接采纳LLM输出作为最终内容（工具调用: {tool_calls_count}次）\")\n            final_answer = response.strip()\n\n            if self.report_logger:\n                self.report_logger.log_section_content(\n                    section_title=section.title,\n                    section_index=section_index,\n                    content=final_answer,\n                    tool_calls_count=tool_calls_count\n                )\n            return final_answer\n        \n        # 达到最大迭代次数，强制生成内容\n        logger.warning(f\"章节 {section.title} 达到最大迭代次数，强制生成\")\n        messages.append({\"role\": \"user\", \"content\": REACT_FORCE_FINAL_MSG})\n        \n        response = self.llm.chat(\n            messages=messages,\n            temperature=0.5,\n            max_tokens=4096\n        )\n\n        # 检查强制收尾时 LLM 返回是否为 None\n        if response is None:\n            logger.error(f\"章节 {section.title} 强制收尾时 LLM 返回 None，使用默认错误提示\")\n            final_answer = f\"（本章节生成失败：LLM 返回空响应，请稍后重试）\"\n        elif \"Final Answer:\" in response:\n            final_answer = response.split(\"Final Answer:\")[-1].strip()\n        else:\n            final_answer = response\n        \n        # 记录章节内容生成完成日志\n        if self.report_logger:\n            self.report_logger.log_section_content(\n                section_title=section.title,\n                section_index=section_index,\n                content=final_answer,\n                tool_calls_count=tool_calls_count\n            )\n        \n        return final_answer\n    \n    def generate_report(\n        self, \n        progress_callback: Optional[Callable[[str, int, str], None]] = None,\n        report_id: Optional[str] = None\n    ) -> Report:\n        \"\"\"\n        生成完整报告（分章节实时输出）\n        \n        每个章节生成完成后立即保存到文件夹，不需要等待整个报告完成。\n        文件结构：\n        reports/{report_id}/\n            meta.json       - 报告元信息\n            outline.json    - 报告大纲\n            progress.json   - 生成进度\n            section_01.md   - 第1章节\n            section_02.md   - 第2章节\n            ...\n            full_report.md  - 完整报告\n        \n        Args:\n            progress_callback: 进度回调函数 (stage, progress, message)\n            report_id: 报告ID（可选，如果不传则自动生成）\n            \n        Returns:\n            Report: 完整报告\n        \"\"\"\n        import uuid\n        \n        # 如果没有传入 report_id，则自动生成\n        if not report_id:\n            report_id = f\"report_{uuid.uuid4().hex[:12]}\"\n        start_time = datetime.now()\n        \n        report = Report(\n            report_id=report_id,\n            simulation_id=self.simulation_id,\n            graph_id=self.graph_id,\n            simulation_requirement=self.simulation_requirement,\n            status=ReportStatus.PENDING,\n            created_at=datetime.now().isoformat()\n        )\n        \n        # 已完成的章节标题列表（用于进度追踪）\n        completed_section_titles = []\n        \n        try:\n            # 初始化：创建报告文件夹并保存初始状态\n            ReportManager._ensure_report_folder(report_id)\n            \n            # 初始化日志记录器（结构化日志 agent_log.jsonl）\n            self.report_logger = ReportLogger(report_id)\n            self.report_logger.log_start(\n                simulation_id=self.simulation_id,\n                graph_id=self.graph_id,\n                simulation_requirement=self.simulation_requirement\n            )\n            \n            # 初始化控制台日志记录器（console_log.txt）\n            self.console_logger = ReportConsoleLogger(report_id)\n            \n            ReportManager.update_progress(\n                report_id, \"pending\", 0, \"初始化报告...\",\n                completed_sections=[]\n            )\n            ReportManager.save_report(report)\n            \n            # 阶段1: 规划大纲\n            report.status = ReportStatus.PLANNING\n            ReportManager.update_progress(\n                report_id, \"planning\", 5, \"开始规划报告大纲...\",\n                completed_sections=[]\n            )\n            \n            # 记录规划开始日志\n            self.report_logger.log_planning_start()\n            \n            if progress_callback:\n                progress_callback(\"planning\", 0, \"开始规划报告大纲...\")\n            \n            outline = self.plan_outline(\n                progress_callback=lambda stage, prog, msg: \n                    progress_callback(stage, prog // 5, msg) if progress_callback else None\n            )\n            report.outline = outline\n            \n            # 记录规划完成日志\n            self.report_logger.log_planning_complete(outline.to_dict())\n            \n            # 保存大纲到文件\n            ReportManager.save_outline(report_id, outline)\n            ReportManager.update_progress(\n                report_id, \"planning\", 15, f\"大纲规划完成，共{len(outline.sections)}个章节\",\n                completed_sections=[]\n            )\n            ReportManager.save_report(report)\n            \n            logger.info(f\"大纲已保存到文件: {report_id}/outline.json\")\n            \n            # 阶段2: 逐章节生成（分章节保存）\n            report.status = ReportStatus.GENERATING\n            \n            total_sections = len(outline.sections)\n            generated_sections = []  # 保存内容用于上下文\n            \n            for i, section in enumerate(outline.sections):\n                section_num = i + 1\n                base_progress = 20 + int((i / total_sections) * 70)\n                \n                # 更新进度\n                ReportManager.update_progress(\n                    report_id, \"generating\", base_progress,\n                    f\"正在生成章节: {section.title} ({section_num}/{total_sections})\",\n                    current_section=section.title,\n                    completed_sections=completed_section_titles\n                )\n                \n                if progress_callback:\n                    progress_callback(\n                        \"generating\", \n                        base_progress, \n                        f\"正在生成章节: {section.title} ({section_num}/{total_sections})\"\n                    )\n                \n                # 生成主章节内容\n                section_content = self._generate_section_react(\n                    section=section,\n                    outline=outline,\n                    previous_sections=generated_sections,\n                    progress_callback=lambda stage, prog, msg:\n                        progress_callback(\n                            stage, \n                            base_progress + int(prog * 0.7 / total_sections),\n                            msg\n                        ) if progress_callback else None,\n                    section_index=section_num\n                )\n                \n                section.content = section_content\n                generated_sections.append(f\"## {section.title}\\n\\n{section_content}\")\n\n                # 保存章节\n                ReportManager.save_section(report_id, section_num, section)\n                completed_section_titles.append(section.title)\n\n                # 记录章节完成日志\n                full_section_content = f\"## {section.title}\\n\\n{section_content}\"\n\n                if self.report_logger:\n                    self.report_logger.log_section_full_complete(\n                        section_title=section.title,\n                        section_index=section_num,\n                        full_content=full_section_content.strip()\n                    )\n\n                logger.info(f\"章节已保存: {report_id}/section_{section_num:02d}.md\")\n                \n                # 更新进度\n                ReportManager.update_progress(\n                    report_id, \"generating\", \n                    base_progress + int(70 / total_sections),\n                    f\"章节 {section.title} 已完成\",\n                    current_section=None,\n                    completed_sections=completed_section_titles\n                )\n            \n            # 阶段3: 组装完整报告\n            if progress_callback:\n                progress_callback(\"generating\", 95, \"正在组装完整报告...\")\n            \n            ReportManager.update_progress(\n                report_id, \"generating\", 95, \"正在组装完整报告...\",\n                completed_sections=completed_section_titles\n            )\n            \n            # 使用ReportManager组装完整报告\n            report.markdown_content = ReportManager.assemble_full_report(report_id, outline)\n            report.status = ReportStatus.COMPLETED\n            report.completed_at = datetime.now().isoformat()\n            \n            # 计算总耗时\n            total_time_seconds = (datetime.now() - start_time).total_seconds()\n            \n            # 记录报告完成日志\n            if self.report_logger:\n                self.report_logger.log_report_complete(\n                    total_sections=total_sections,\n                    total_time_seconds=total_time_seconds\n                )\n            \n            # 保存最终报告\n            ReportManager.save_report(report)\n            ReportManager.update_progress(\n                report_id, \"completed\", 100, \"报告生成完成\",\n                completed_sections=completed_section_titles\n            )\n            \n            if progress_callback:\n                progress_callback(\"completed\", 100, \"报告生成完成\")\n            \n            logger.info(f\"报告生成完成: {report_id}\")\n            \n            # 关闭控制台日志记录器\n            if self.console_logger:\n                self.console_logger.close()\n                self.console_logger = None\n            \n            return report\n            \n        except Exception as e:\n            logger.error(f\"报告生成失败: {str(e)}\")\n            report.status = ReportStatus.FAILED\n            report.error = str(e)\n            \n            # 记录错误日志\n            if self.report_logger:\n                self.report_logger.log_error(str(e), \"failed\")\n            \n            # 保存失败状态\n            try:\n                ReportManager.save_report(report)\n                ReportManager.update_progress(\n                    report_id, \"failed\", -1, f\"报告生成失败: {str(e)}\",\n                    completed_sections=completed_section_titles\n                )\n            except Exception:\n                pass  # 忽略保存失败的错误\n            \n            # 关闭控制台日志记录器\n            if self.console_logger:\n                self.console_logger.close()\n                self.console_logger = None\n            \n            return report\n    \n    def chat(\n        self, \n        message: str,\n        chat_history: List[Dict[str, str]] = None\n    ) -> Dict[str, Any]:\n        \"\"\"\n        与Report Agent对话\n        \n        在对话中Agent可以自主调用检索工具来回答问题\n        \n        Args:\n            message: 用户消息\n            chat_history: 对话历史\n            \n        Returns:\n            {\n                \"response\": \"Agent回复\",\n                \"tool_calls\": [调用的工具列表],\n                \"sources\": [信息来源]\n            }\n        \"\"\"\n        logger.info(f\"Report Agent对话: {message[:50]}...\")\n        \n        chat_history = chat_history or []\n        \n        # 获取已生成的报告内容\n        report_content = \"\"\n        try:\n            report = ReportManager.get_report_by_simulation(self.simulation_id)\n            if report and report.markdown_content:\n                # 限制报告长度，避免上下文过长\n                report_content = report.markdown_content[:15000]\n                if len(report.markdown_content) > 15000:\n                    report_content += \"\\n\\n... [报告内容已截断] ...\"\n        except Exception as e:\n            logger.warning(f\"获取报告内容失败: {e}\")\n        \n        system_prompt = CHAT_SYSTEM_PROMPT_TEMPLATE.format(\n            simulation_requirement=self.simulation_requirement,\n            report_content=report_content if report_content else \"（暂无报告）\",\n            tools_description=self._get_tools_description(),\n        )\n\n        # 构建消息\n        messages = [{\"role\": \"system\", \"content\": system_prompt}]\n        \n        # 添加历史对话\n        for h in chat_history[-10:]:  # 限制历史长度\n            messages.append(h)\n        \n        # 添加用户消息\n        messages.append({\n            \"role\": \"user\", \n            \"content\": message\n        })\n        \n        # ReACT循环（简化版）\n        tool_calls_made = []\n        max_iterations = 2  # 减少迭代轮数\n        \n        for iteration in range(max_iterations):\n            response = self.llm.chat(\n                messages=messages,\n                temperature=0.5\n            )\n            \n            # 解析工具调用\n            tool_calls = self._parse_tool_calls(response)\n            \n            if not tool_calls:\n                # 没有工具调用，直接返回响应\n                clean_response = re.sub(r'<tool_call>.*?</tool_call>', '', response, flags=re.DOTALL)\n                clean_response = re.sub(r'\\[TOOL_CALL\\].*?\\)', '', clean_response)\n                \n                return {\n                    \"response\": clean_response.strip(),\n                    \"tool_calls\": tool_calls_made,\n                    \"sources\": [tc.get(\"parameters\", {}).get(\"query\", \"\") for tc in tool_calls_made]\n                }\n            \n            # 执行工具调用（限制数量）\n            tool_results = []\n            for call in tool_calls[:1]:  # 每轮最多执行1次工具调用\n                if len(tool_calls_made) >= self.MAX_TOOL_CALLS_PER_CHAT:\n                    break\n                result = self._execute_tool(call[\"name\"], call.get(\"parameters\", {}))\n                tool_results.append({\n                    \"tool\": call[\"name\"],\n                    \"result\": result[:1500]  # 限制结果长度\n                })\n                tool_calls_made.append(call)\n            \n            # 将结果添加到消息\n            messages.append({\"role\": \"assistant\", \"content\": response})\n            observation = \"\\n\".join([f\"[{r['tool']}结果]\\n{r['result']}\" for r in tool_results])\n            messages.append({\n                \"role\": \"user\",\n                \"content\": observation + CHAT_OBSERVATION_SUFFIX\n            })\n        \n        # 达到最大迭代，获取最终响应\n        final_response = self.llm.chat(\n            messages=messages,\n            temperature=0.5\n        )\n        \n        # 清理响应\n        clean_response = re.sub(r'<tool_call>.*?</tool_call>', '', final_response, flags=re.DOTALL)\n        clean_response = re.sub(r'\\[TOOL_CALL\\].*?\\)', '', clean_response)\n        \n        return {\n            \"response\": clean_response.strip(),\n            \"tool_calls\": tool_calls_made,\n            \"sources\": [tc.get(\"parameters\", {}).get(\"query\", \"\") for tc in tool_calls_made]\n        }\n\n\nclass ReportManager:\n    \"\"\"\n    报告管理器\n    \n    负责报告的持久化存储和检索\n    \n    文件结构（分章节输出）：\n    reports/\n      {report_id}/\n        meta.json          - 报告元信息和状态\n        outline.json       - 报告大纲\n        progress.json      - 生成进度\n        section_01.md      - 第1章节\n        section_02.md      - 第2章节\n        ...\n        full_report.md     - 完整报告\n    \"\"\"\n    \n    # 报告存储目录\n    REPORTS_DIR = os.path.join(Config.UPLOAD_FOLDER, 'reports')\n    \n    @classmethod\n    def _ensure_reports_dir(cls):\n        \"\"\"确保报告根目录存在\"\"\"\n        os.makedirs(cls.REPORTS_DIR, exist_ok=True)\n    \n    @classmethod\n    def _get_report_folder(cls, report_id: str) -> str:\n        \"\"\"获取报告文件夹路径\"\"\"\n        return os.path.join(cls.REPORTS_DIR, report_id)\n    \n    @classmethod\n    def _ensure_report_folder(cls, report_id: str) -> str:\n        \"\"\"确保报告文件夹存在并返回路径\"\"\"\n        folder = cls._get_report_folder(report_id)\n        os.makedirs(folder, exist_ok=True)\n        return folder\n    \n    @classmethod\n    def _get_report_path(cls, report_id: str) -> str:\n        \"\"\"获取报告元信息文件路径\"\"\"\n        return os.path.join(cls._get_report_folder(report_id), \"meta.json\")\n    \n    @classmethod\n    def _get_report_markdown_path(cls, report_id: str) -> str:\n        \"\"\"获取完整报告Markdown文件路径\"\"\"\n        return os.path.join(cls._get_report_folder(report_id), \"full_report.md\")\n    \n    @classmethod\n    def _get_outline_path(cls, report_id: str) -> str:\n        \"\"\"获取大纲文件路径\"\"\"\n        return os.path.join(cls._get_report_folder(report_id), \"outline.json\")\n    \n    @classmethod\n    def _get_progress_path(cls, report_id: str) -> str:\n        \"\"\"获取进度文件路径\"\"\"\n        return os.path.join(cls._get_report_folder(report_id), \"progress.json\")\n    \n    @classmethod\n    def _get_section_path(cls, report_id: str, section_index: int) -> str:\n        \"\"\"获取章节Markdown文件路径\"\"\"\n        return os.path.join(cls._get_report_folder(report_id), f\"section_{section_index:02d}.md\")\n    \n    @classmethod\n    def _get_agent_log_path(cls, report_id: str) -> str:\n        \"\"\"获取 Agent 日志文件路径\"\"\"\n        return os.path.join(cls._get_report_folder(report_id), \"agent_log.jsonl\")\n    \n    @classmethod\n    def _get_console_log_path(cls, report_id: str) -> str:\n        \"\"\"获取控制台日志文件路径\"\"\"\n        return os.path.join(cls._get_report_folder(report_id), \"console_log.txt\")\n    \n    @classmethod\n    def get_console_log(cls, report_id: str, from_line: int = 0) -> Dict[str, Any]:\n        \"\"\"\n        获取控制台日志内容\n        \n        这是报告生成过程中的控制台输出日志（INFO、WARNING等），\n        与 agent_log.jsonl 的结构化日志不同。\n        \n        Args:\n            report_id: 报告ID\n            from_line: 从第几行开始读取（用于增量获取，0 表示从头开始）\n            \n        Returns:\n            {\n                \"logs\": [日志行列表],\n                \"total_lines\": 总行数,\n                \"from_line\": 起始行号,\n                \"has_more\": 是否还有更多日志\n            }\n        \"\"\"\n        log_path = cls._get_console_log_path(report_id)\n        \n        if not os.path.exists(log_path):\n            return {\n                \"logs\": [],\n                \"total_lines\": 0,\n                \"from_line\": 0,\n                \"has_more\": False\n            }\n        \n        logs = []\n        total_lines = 0\n        \n        with open(log_path, 'r', encoding='utf-8') as f:\n            for i, line in enumerate(f):\n                total_lines = i + 1\n                if i >= from_line:\n                    # 保留原始日志行，去掉末尾换行符\n                    logs.append(line.rstrip('\\n\\r'))\n        \n        return {\n            \"logs\": logs,\n            \"total_lines\": total_lines,\n            \"from_line\": from_line,\n            \"has_more\": False  # 已读取到末尾\n        }\n    \n    @classmethod\n    def get_console_log_stream(cls, report_id: str) -> List[str]:\n        \"\"\"\n        获取完整的控制台日志（一次性获取全部）\n        \n        Args:\n            report_id: 报告ID\n            \n        Returns:\n            日志行列表\n        \"\"\"\n        result = cls.get_console_log(report_id, from_line=0)\n        return result[\"logs\"]\n    \n    @classmethod\n    def get_agent_log(cls, report_id: str, from_line: int = 0) -> Dict[str, Any]:\n        \"\"\"\n        获取 Agent 日志内容\n        \n        Args:\n            report_id: 报告ID\n            from_line: 从第几行开始读取（用于增量获取，0 表示从头开始）\n            \n        Returns:\n            {\n                \"logs\": [日志条目列表],\n                \"total_lines\": 总行数,\n                \"from_line\": 起始行号,\n                \"has_more\": 是否还有更多日志\n            }\n        \"\"\"\n        log_path = cls._get_agent_log_path(report_id)\n        \n        if not os.path.exists(log_path):\n            return {\n                \"logs\": [],\n                \"total_lines\": 0,\n                \"from_line\": 0,\n                \"has_more\": False\n            }\n        \n        logs = []\n        total_lines = 0\n        \n        with open(log_path, 'r', encoding='utf-8') as f:\n            for i, line in enumerate(f):\n                total_lines = i + 1\n                if i >= from_line:\n                    try:\n                        log_entry = json.loads(line.strip())\n                        logs.append(log_entry)\n                    except json.JSONDecodeError:\n                        # 跳过解析失败的行\n                        continue\n        \n        return {\n            \"logs\": logs,\n            \"total_lines\": total_lines,\n            \"from_line\": from_line,\n            \"has_more\": False  # 已读取到末尾\n        }\n    \n    @classmethod\n    def get_agent_log_stream(cls, report_id: str) -> List[Dict[str, Any]]:\n        \"\"\"\n        获取完整的 Agent 日志（用于一次性获取全部）\n        \n        Args:\n            report_id: 报告ID\n            \n        Returns:\n            日志条目列表\n        \"\"\"\n        result = cls.get_agent_log(report_id, from_line=0)\n        return result[\"logs\"]\n    \n    @classmethod\n    def save_outline(cls, report_id: str, outline: ReportOutline) -> None:\n        \"\"\"\n        保存报告大纲\n        \n        在规划阶段完成后立即调用\n        \"\"\"\n        cls._ensure_report_folder(report_id)\n        \n        with open(cls._get_outline_path(report_id), 'w', encoding='utf-8') as f:\n            json.dump(outline.to_dict(), f, ensure_ascii=False, indent=2)\n        \n        logger.info(f\"大纲已保存: {report_id}\")\n    \n    @classmethod\n    def save_section(\n        cls,\n        report_id: str,\n        section_index: int,\n        section: ReportSection\n    ) -> str:\n        \"\"\"\n        保存单个章节\n\n        在每个章节生成完成后立即调用，实现分章节输出\n\n        Args:\n            report_id: 报告ID\n            section_index: 章节索引（从1开始）\n            section: 章节对象\n\n        Returns:\n            保存的文件路径\n        \"\"\"\n        cls._ensure_report_folder(report_id)\n\n        # 构建章节Markdown内容 - 清理可能存在的重复标题\n        cleaned_content = cls._clean_section_content(section.content, section.title)\n        md_content = f\"## {section.title}\\n\\n\"\n        if cleaned_content:\n            md_content += f\"{cleaned_content}\\n\\n\"\n\n        # 保存文件\n        file_suffix = f\"section_{section_index:02d}.md\"\n        file_path = os.path.join(cls._get_report_folder(report_id), file_suffix)\n        with open(file_path, 'w', encoding='utf-8') as f:\n            f.write(md_content)\n\n        logger.info(f\"章节已保存: {report_id}/{file_suffix}\")\n        return file_path\n    \n    @classmethod\n    def _clean_section_content(cls, content: str, section_title: str) -> str:\n        \"\"\"\n        清理章节内容\n        \n        1. 移除内容开头与章节标题重复的Markdown标题行\n        2. 将所有 ### 及以下级别的标题转换为粗体文本\n        \n        Args:\n            content: 原始内容\n            section_title: 章节标题\n            \n        Returns:\n            清理后的内容\n        \"\"\"\n        import re\n        \n        if not content:\n            return content\n        \n        content = content.strip()\n        lines = content.split('\\n')\n        cleaned_lines = []\n        skip_next_empty = False\n        \n        for i, line in enumerate(lines):\n            stripped = line.strip()\n            \n            # 检查是否是Markdown标题行\n            heading_match = re.match(r'^(#{1,6})\\s+(.+)$', stripped)\n            \n            if heading_match:\n                level = len(heading_match.group(1))\n                title_text = heading_match.group(2).strip()\n                \n                # 检查是否是与章节标题重复的标题（跳过前5行内的重复）\n                if i < 5:\n                    if title_text == section_title or title_text.replace(' ', '') == section_title.replace(' ', ''):\n                        skip_next_empty = True\n                        continue\n                \n                # 将所有级别的标题（#, ##, ###, ####等）转换为粗体\n                # 因为章节标题由系统添加，内容中不应有任何标题\n                cleaned_lines.append(f\"**{title_text}**\")\n                cleaned_lines.append(\"\")  # 添加空行\n                continue\n            \n            # 如果上一行是被跳过的标题，且当前行为空，也跳过\n            if skip_next_empty and stripped == '':\n                skip_next_empty = False\n                continue\n            \n            skip_next_empty = False\n            cleaned_lines.append(line)\n        \n        # 移除开头的空行\n        while cleaned_lines and cleaned_lines[0].strip() == '':\n            cleaned_lines.pop(0)\n        \n        # 移除开头的分隔线\n        while cleaned_lines and cleaned_lines[0].strip() in ['---', '***', '___']:\n            cleaned_lines.pop(0)\n            # 同时移除分隔线后的空行\n            while cleaned_lines and cleaned_lines[0].strip() == '':\n                cleaned_lines.pop(0)\n        \n        return '\\n'.join(cleaned_lines)\n    \n    @classmethod\n    def update_progress(\n        cls, \n        report_id: str, \n        status: str, \n        progress: int, \n        message: str,\n        current_section: str = None,\n        completed_sections: List[str] = None\n    ) -> None:\n        \"\"\"\n        更新报告生成进度\n        \n        前端可以通过读取progress.json获取实时进度\n        \"\"\"\n        cls._ensure_report_folder(report_id)\n        \n        progress_data = {\n            \"status\": status,\n            \"progress\": progress,\n            \"message\": message,\n            \"current_section\": current_section,\n            \"completed_sections\": completed_sections or [],\n            \"updated_at\": datetime.now().isoformat()\n        }\n        \n        with open(cls._get_progress_path(report_id), 'w', encoding='utf-8') as f:\n            json.dump(progress_data, f, ensure_ascii=False, indent=2)\n    \n    @classmethod\n    def get_progress(cls, report_id: str) -> Optional[Dict[str, Any]]:\n        \"\"\"获取报告生成进度\"\"\"\n        path = cls._get_progress_path(report_id)\n        \n        if not os.path.exists(path):\n            return None\n        \n        with open(path, 'r', encoding='utf-8') as f:\n            return json.load(f)\n    \n    @classmethod\n    def get_generated_sections(cls, report_id: str) -> List[Dict[str, Any]]:\n        \"\"\"\n        获取已生成的章节列表\n        \n        返回所有已保存的章节文件信息\n        \"\"\"\n        folder = cls._get_report_folder(report_id)\n        \n        if not os.path.exists(folder):\n            return []\n        \n        sections = []\n        for filename in sorted(os.listdir(folder)):\n            if filename.startswith('section_') and filename.endswith('.md'):\n                file_path = os.path.join(folder, filename)\n                with open(file_path, 'r', encoding='utf-8') as f:\n                    content = f.read()\n\n                # 从文件名解析章节索引\n                parts = filename.replace('.md', '').split('_')\n                section_index = int(parts[1])\n\n                sections.append({\n                    \"filename\": filename,\n                    \"section_index\": section_index,\n                    \"content\": content\n                })\n\n        return sections\n    \n    @classmethod\n    def assemble_full_report(cls, report_id: str, outline: ReportOutline) -> str:\n        \"\"\"\n        组装完整报告\n        \n        从已保存的章节文件组装完整报告，并进行标题清理\n        \"\"\"\n        folder = cls._get_report_folder(report_id)\n        \n        # 构建报告头部\n        md_content = f\"# {outline.title}\\n\\n\"\n        md_content += f\"> {outline.summary}\\n\\n\"\n        md_content += f\"---\\n\\n\"\n        \n        # 按顺序读取所有章节文件\n        sections = cls.get_generated_sections(report_id)\n        for section_info in sections:\n            md_content += section_info[\"content\"]\n        \n        # 后处理：清理整个报告的标题问题\n        md_content = cls._post_process_report(md_content, outline)\n        \n        # 保存完整报告\n        full_path = cls._get_report_markdown_path(report_id)\n        with open(full_path, 'w', encoding='utf-8') as f:\n            f.write(md_content)\n        \n        logger.info(f\"完整报告已组装: {report_id}\")\n        return md_content\n    \n    @classmethod\n    def _post_process_report(cls, content: str, outline: ReportOutline) -> str:\n        \"\"\"\n        后处理报告内容\n        \n        1. 移除重复的标题\n        2. 保留报告主标题(#)和章节标题(##)，移除其他级别的标题(###, ####等)\n        3. 清理多余的空行和分隔线\n        \n        Args:\n            content: 原始报告内容\n            outline: 报告大纲\n            \n        Returns:\n            处理后的内容\n        \"\"\"\n        import re\n        \n        lines = content.split('\\n')\n        processed_lines = []\n        prev_was_heading = False\n        \n        # 收集大纲中的所有章节标题\n        section_titles = set()\n        for section in outline.sections:\n            section_titles.add(section.title)\n        \n        i = 0\n        while i < len(lines):\n            line = lines[i]\n            stripped = line.strip()\n            \n            # 检查是否是标题行\n            heading_match = re.match(r'^(#{1,6})\\s+(.+)$', stripped)\n            \n            if heading_match:\n                level = len(heading_match.group(1))\n                title = heading_match.group(2).strip()\n                \n                # 检查是否是重复标题（在连续5行内出现相同内容的标题）\n                is_duplicate = False\n                for j in range(max(0, len(processed_lines) - 5), len(processed_lines)):\n                    prev_line = processed_lines[j].strip()\n                    prev_match = re.match(r'^(#{1,6})\\s+(.+)$', prev_line)\n                    if prev_match:\n                        prev_title = prev_match.group(2).strip()\n                        if prev_title == title:\n                            is_duplicate = True\n                            break\n                \n                if is_duplicate:\n                    # 跳过重复标题及其后的空行\n                    i += 1\n                    while i < len(lines) and lines[i].strip() == '':\n                        i += 1\n                    continue\n                \n                # 标题层级处理：\n                # - # (level=1) 只保留报告主标题\n                # - ## (level=2) 保留章节标题\n                # - ### 及以下 (level>=3) 转换为粗体文本\n                \n                if level == 1:\n                    if title == outline.title:\n                        # 保留报告主标题\n                        processed_lines.append(line)\n                        prev_was_heading = True\n                    elif title in section_titles:\n                        # 章节标题错误使用了#，修正为##\n                        processed_lines.append(f\"## {title}\")\n                        prev_was_heading = True\n                    else:\n                        # 其他一级标题转为粗体\n                        processed_lines.append(f\"**{title}**\")\n                        processed_lines.append(\"\")\n                        prev_was_heading = False\n                elif level == 2:\n                    if title in section_titles or title == outline.title:\n                        # 保留章节标题\n                        processed_lines.append(line)\n                        prev_was_heading = True\n                    else:\n                        # 非章节的二级标题转为粗体\n                        processed_lines.append(f\"**{title}**\")\n                        processed_lines.append(\"\")\n                        prev_was_heading = False\n                else:\n                    # ### 及以下级别的标题转换为粗体文本\n                    processed_lines.append(f\"**{title}**\")\n                    processed_lines.append(\"\")\n                    prev_was_heading = False\n                \n                i += 1\n                continue\n            \n            elif stripped == '---' and prev_was_heading:\n                # 跳过标题后紧跟的分隔线\n                i += 1\n                continue\n            \n            elif stripped == '' and prev_was_heading:\n                # 标题后只保留一个空行\n                if processed_lines and processed_lines[-1].strip() != '':\n                    processed_lines.append(line)\n                prev_was_heading = False\n            \n            else:\n                processed_lines.append(line)\n                prev_was_heading = False\n            \n            i += 1\n        \n        # 清理连续的多个空行（保留最多2个）\n        result_lines = []\n        empty_count = 0\n        for line in processed_lines:\n            if line.strip() == '':\n                empty_count += 1\n                if empty_count <= 2:\n                    result_lines.append(line)\n            else:\n                empty_count = 0\n                result_lines.append(line)\n        \n        return '\\n'.join(result_lines)\n    \n    @classmethod\n    def save_report(cls, report: Report) -> None:\n        \"\"\"保存报告元信息和完整报告\"\"\"\n        cls._ensure_report_folder(report.report_id)\n        \n        # 保存元信息JSON\n        with open(cls._get_report_path(report.report_id), 'w', encoding='utf-8') as f:\n            json.dump(report.to_dict(), f, ensure_ascii=False, indent=2)\n        \n        # 保存大纲\n        if report.outline:\n            cls.save_outline(report.report_id, report.outline)\n        \n        # 保存完整Markdown报告\n        if report.markdown_content:\n            with open(cls._get_report_markdown_path(report.report_id), 'w', encoding='utf-8') as f:\n                f.write(report.markdown_content)\n        \n        logger.info(f\"报告已保存: {report.report_id}\")\n    \n    @classmethod\n    def get_report(cls, report_id: str) -> Optional[Report]:\n        \"\"\"获取报告\"\"\"\n        path = cls._get_report_path(report_id)\n        \n        if not os.path.exists(path):\n            # 兼容旧格式：检查直接存储在reports目录下的文件\n            old_path = os.path.join(cls.REPORTS_DIR, f\"{report_id}.json\")\n            if os.path.exists(old_path):\n                path = old_path\n            else:\n                return None\n        \n        with open(path, 'r', encoding='utf-8') as f:\n            data = json.load(f)\n        \n        # 重建Report对象\n        outline = None\n        if data.get('outline'):\n            outline_data = data['outline']\n            sections = []\n            for s in outline_data.get('sections', []):\n                sections.append(ReportSection(\n                    title=s['title'],\n                    content=s.get('content', '')\n                ))\n            outline = ReportOutline(\n                title=outline_data['title'],\n                summary=outline_data['summary'],\n                sections=sections\n            )\n        \n        # 如果markdown_content为空，尝试从full_report.md读取\n        markdown_content = data.get('markdown_content', '')\n        if not markdown_content:\n            full_report_path = cls._get_report_markdown_path(report_id)\n            if os.path.exists(full_report_path):\n                with open(full_report_path, 'r', encoding='utf-8') as f:\n                    markdown_content = f.read()\n        \n        return Report(\n            report_id=data['report_id'],\n            simulation_id=data['simulation_id'],\n            graph_id=data['graph_id'],\n            simulation_requirement=data['simulation_requirement'],\n            status=ReportStatus(data['status']),\n            outline=outline,\n            markdown_content=markdown_content,\n            created_at=data.get('created_at', ''),\n            completed_at=data.get('completed_at', ''),\n            error=data.get('error')\n        )\n    \n    @classmethod\n    def get_report_by_simulation(cls, simulation_id: str) -> Optional[Report]:\n        \"\"\"根据模拟ID获取报告\"\"\"\n        cls._ensure_reports_dir()\n        \n        for item in os.listdir(cls.REPORTS_DIR):\n            item_path = os.path.join(cls.REPORTS_DIR, item)\n            # 新格式：文件夹\n            if os.path.isdir(item_path):\n                report = cls.get_report(item)\n                if report and report.simulation_id == simulation_id:\n                    return report\n            # 兼容旧格式：JSON文件\n            elif item.endswith('.json'):\n                report_id = item[:-5]\n                report = cls.get_report(report_id)\n                if report and report.simulation_id == simulation_id:\n                    return report\n        \n        return None\n    \n    @classmethod\n    def list_reports(cls, simulation_id: Optional[str] = None, limit: int = 50) -> List[Report]:\n        \"\"\"列出报告\"\"\"\n        cls._ensure_reports_dir()\n        \n        reports = []\n        for item in os.listdir(cls.REPORTS_DIR):\n            item_path = os.path.join(cls.REPORTS_DIR, item)\n            # 新格式：文件夹\n            if os.path.isdir(item_path):\n                report = cls.get_report(item)\n                if report:\n                    if simulation_id is None or report.simulation_id == simulation_id:\n                        reports.append(report)\n            # 兼容旧格式：JSON文件\n            elif item.endswith('.json'):\n                report_id = item[:-5]\n                report = cls.get_report(report_id)\n                if report:\n                    if simulation_id is None or report.simulation_id == simulation_id:\n                        reports.append(report)\n        \n        # 按创建时间倒序\n        reports.sort(key=lambda r: r.created_at, reverse=True)\n        \n        return reports[:limit]\n    \n    @classmethod\n    def delete_report(cls, report_id: str) -> bool:\n        \"\"\"删除报告（整个文件夹）\"\"\"\n        import shutil\n        \n        folder_path = cls._get_report_folder(report_id)\n        \n        # 新格式：删除整个文件夹\n        if os.path.exists(folder_path) and os.path.isdir(folder_path):\n            shutil.rmtree(folder_path)\n            logger.info(f\"报告文件夹已删除: {report_id}\")\n            return True\n        \n        # 兼容旧格式：删除单独的文件\n        deleted = False\n        old_json_path = os.path.join(cls.REPORTS_DIR, f\"{report_id}.json\")\n        old_md_path = os.path.join(cls.REPORTS_DIR, f\"{report_id}.md\")\n        \n        if os.path.exists(old_json_path):\n            os.remove(old_json_path)\n            deleted = True\n        if os.path.exists(old_md_path):\n            os.remove(old_md_path)\n            deleted = True\n        \n        return deleted\n"
  },
  {
    "path": "backend/app/services/simulation_config_generator.py",
    "content": "\"\"\"\n模拟配置智能生成器\n使用LLM根据模拟需求、文档内容、图谱信息自动生成细致的模拟参数\n实现全程自动化，无需人工设置参数\n\n采用分步生成策略，避免一次性生成过长内容导致失败：\n1. 生成时间配置\n2. 生成事件配置\n3. 分批生成Agent配置\n4. 生成平台配置\n\"\"\"\n\nimport json\nimport math\nfrom typing import Dict, Any, List, Optional, Callable\nfrom dataclasses import dataclass, field, asdict\nfrom datetime import datetime\n\nfrom openai import OpenAI\n\nfrom ..config import Config\nfrom ..utils.logger import get_logger\nfrom .zep_entity_reader import EntityNode, ZepEntityReader\n\nlogger = get_logger('mirofish.simulation_config')\n\n# 中国作息时间配置（北京时间）\nCHINA_TIMEZONE_CONFIG = {\n    # 深夜时段（几乎无人活动）\n    \"dead_hours\": [0, 1, 2, 3, 4, 5],\n    # 早间时段（逐渐醒来）\n    \"morning_hours\": [6, 7, 8],\n    # 工作时段\n    \"work_hours\": [9, 10, 11, 12, 13, 14, 15, 16, 17, 18],\n    # 晚间高峰（最活跃）\n    \"peak_hours\": [19, 20, 21, 22],\n    # 夜间时段（活跃度下降）\n    \"night_hours\": [23],\n    # 活跃度系数\n    \"activity_multipliers\": {\n        \"dead\": 0.05,      # 凌晨几乎无人\n        \"morning\": 0.4,    # 早间逐渐活跃\n        \"work\": 0.7,       # 工作时段中等\n        \"peak\": 1.5,       # 晚间高峰\n        \"night\": 0.5       # 深夜下降\n    }\n}\n\n\n@dataclass\nclass AgentActivityConfig:\n    \"\"\"单个Agent的活动配置\"\"\"\n    agent_id: int\n    entity_uuid: str\n    entity_name: str\n    entity_type: str\n    \n    # 活跃度配置 (0.0-1.0)\n    activity_level: float = 0.5  # 整体活跃度\n    \n    # 发言频率（每小时预期发言次数）\n    posts_per_hour: float = 1.0\n    comments_per_hour: float = 2.0\n    \n    # 活跃时间段（24小时制，0-23）\n    active_hours: List[int] = field(default_factory=lambda: list(range(8, 23)))\n    \n    # 响应速度（对热点事件的反应延迟，单位：模拟分钟）\n    response_delay_min: int = 5\n    response_delay_max: int = 60\n    \n    # 情感倾向 (-1.0到1.0，负面到正面)\n    sentiment_bias: float = 0.0\n    \n    # 立场（对特定话题的态度）\n    stance: str = \"neutral\"  # supportive, opposing, neutral, observer\n    \n    # 影响力权重（决定其发言被其他Agent看到的概率）\n    influence_weight: float = 1.0\n\n\n@dataclass  \nclass TimeSimulationConfig:\n    \"\"\"时间模拟配置（基于中国人作息习惯）\"\"\"\n    # 模拟总时长（模拟小时数）\n    total_simulation_hours: int = 72  # 默认模拟72小时（3天）\n    \n    # 每轮代表的时间（模拟分钟）- 默认60分钟（1小时），加快时间流速\n    minutes_per_round: int = 60\n    \n    # 每小时激活的Agent数量范围\n    agents_per_hour_min: int = 5\n    agents_per_hour_max: int = 20\n    \n    # 高峰时段（晚间19-22点，中国人最活跃的时间）\n    peak_hours: List[int] = field(default_factory=lambda: [19, 20, 21, 22])\n    peak_activity_multiplier: float = 1.5\n    \n    # 低谷时段（凌晨0-5点，几乎无人活动）\n    off_peak_hours: List[int] = field(default_factory=lambda: [0, 1, 2, 3, 4, 5])\n    off_peak_activity_multiplier: float = 0.05  # 凌晨活跃度极低\n    \n    # 早间时段\n    morning_hours: List[int] = field(default_factory=lambda: [6, 7, 8])\n    morning_activity_multiplier: float = 0.4\n    \n    # 工作时段\n    work_hours: List[int] = field(default_factory=lambda: [9, 10, 11, 12, 13, 14, 15, 16, 17, 18])\n    work_activity_multiplier: float = 0.7\n\n\n@dataclass\nclass EventConfig:\n    \"\"\"事件配置\"\"\"\n    # 初始事件（模拟开始时的触发事件）\n    initial_posts: List[Dict[str, Any]] = field(default_factory=list)\n    \n    # 定时事件（在特定时间触发的事件）\n    scheduled_events: List[Dict[str, Any]] = field(default_factory=list)\n    \n    # 热点话题关键词\n    hot_topics: List[str] = field(default_factory=list)\n    \n    # 舆论引导方向\n    narrative_direction: str = \"\"\n\n\n@dataclass\nclass PlatformConfig:\n    \"\"\"平台特定配置\"\"\"\n    platform: str  # twitter or reddit\n    \n    # 推荐算法权重\n    recency_weight: float = 0.4  # 时间新鲜度\n    popularity_weight: float = 0.3  # 热度\n    relevance_weight: float = 0.3  # 相关性\n    \n    # 病毒传播阈值（达到多少互动后触发扩散）\n    viral_threshold: int = 10\n    \n    # 回声室效应强度（相似观点聚集程度）\n    echo_chamber_strength: float = 0.5\n\n\n@dataclass\nclass SimulationParameters:\n    \"\"\"完整的模拟参数配置\"\"\"\n    # 基础信息\n    simulation_id: str\n    project_id: str\n    graph_id: str\n    simulation_requirement: str\n    \n    # 时间配置\n    time_config: TimeSimulationConfig = field(default_factory=TimeSimulationConfig)\n    \n    # Agent配置列表\n    agent_configs: List[AgentActivityConfig] = field(default_factory=list)\n    \n    # 事件配置\n    event_config: EventConfig = field(default_factory=EventConfig)\n    \n    # 平台配置\n    twitter_config: Optional[PlatformConfig] = None\n    reddit_config: Optional[PlatformConfig] = None\n    \n    # LLM配置\n    llm_model: str = \"\"\n    llm_base_url: str = \"\"\n    \n    # 生成元数据\n    generated_at: str = field(default_factory=lambda: datetime.now().isoformat())\n    generation_reasoning: str = \"\"  # LLM的推理说明\n    \n    def to_dict(self) -> Dict[str, Any]:\n        \"\"\"转换为字典\"\"\"\n        time_dict = asdict(self.time_config)\n        return {\n            \"simulation_id\": self.simulation_id,\n            \"project_id\": self.project_id,\n            \"graph_id\": self.graph_id,\n            \"simulation_requirement\": self.simulation_requirement,\n            \"time_config\": time_dict,\n            \"agent_configs\": [asdict(a) for a in self.agent_configs],\n            \"event_config\": asdict(self.event_config),\n            \"twitter_config\": asdict(self.twitter_config) if self.twitter_config else None,\n            \"reddit_config\": asdict(self.reddit_config) if self.reddit_config else None,\n            \"llm_model\": self.llm_model,\n            \"llm_base_url\": self.llm_base_url,\n            \"generated_at\": self.generated_at,\n            \"generation_reasoning\": self.generation_reasoning,\n        }\n    \n    def to_json(self, indent: int = 2) -> str:\n        \"\"\"转换为JSON字符串\"\"\"\n        return json.dumps(self.to_dict(), ensure_ascii=False, indent=indent)\n\n\nclass SimulationConfigGenerator:\n    \"\"\"\n    模拟配置智能生成器\n    \n    使用LLM分析模拟需求、文档内容、图谱实体信息，\n    自动生成最佳的模拟参数配置\n    \n    采用分步生成策略：\n    1. 生成时间配置和事件配置（轻量级）\n    2. 分批生成Agent配置（每批10-20个）\n    3. 生成平台配置\n    \"\"\"\n    \n    # 上下文最大字符数\n    MAX_CONTEXT_LENGTH = 50000\n    # 每批生成的Agent数量\n    AGENTS_PER_BATCH = 15\n    \n    # 各步骤的上下文截断长度（字符数）\n    TIME_CONFIG_CONTEXT_LENGTH = 10000   # 时间配置\n    EVENT_CONFIG_CONTEXT_LENGTH = 8000   # 事件配置\n    ENTITY_SUMMARY_LENGTH = 300          # 实体摘要\n    AGENT_SUMMARY_LENGTH = 300           # Agent配置中的实体摘要\n    ENTITIES_PER_TYPE_DISPLAY = 20       # 每类实体显示数量\n    \n    def __init__(\n        self,\n        api_key: Optional[str] = None,\n        base_url: Optional[str] = None,\n        model_name: Optional[str] = None\n    ):\n        self.api_key = api_key or Config.LLM_API_KEY\n        self.base_url = base_url or Config.LLM_BASE_URL\n        self.model_name = model_name or Config.LLM_MODEL_NAME\n        \n        if not self.api_key:\n            raise ValueError(\"LLM_API_KEY 未配置\")\n        \n        self.client = OpenAI(\n            api_key=self.api_key,\n            base_url=self.base_url\n        )\n    \n    def generate_config(\n        self,\n        simulation_id: str,\n        project_id: str,\n        graph_id: str,\n        simulation_requirement: str,\n        document_text: str,\n        entities: List[EntityNode],\n        enable_twitter: bool = True,\n        enable_reddit: bool = True,\n        progress_callback: Optional[Callable[[int, int, str], None]] = None,\n    ) -> SimulationParameters:\n        \"\"\"\n        智能生成完整的模拟配置（分步生成）\n        \n        Args:\n            simulation_id: 模拟ID\n            project_id: 项目ID\n            graph_id: 图谱ID\n            simulation_requirement: 模拟需求描述\n            document_text: 原始文档内容\n            entities: 过滤后的实体列表\n            enable_twitter: 是否启用Twitter\n            enable_reddit: 是否启用Reddit\n            progress_callback: 进度回调函数(current_step, total_steps, message)\n            \n        Returns:\n            SimulationParameters: 完整的模拟参数\n        \"\"\"\n        logger.info(f\"开始智能生成模拟配置: simulation_id={simulation_id}, 实体数={len(entities)}\")\n        \n        # 计算总步骤数\n        num_batches = math.ceil(len(entities) / self.AGENTS_PER_BATCH)\n        total_steps = 3 + num_batches  # 时间配置 + 事件配置 + N批Agent + 平台配置\n        current_step = 0\n        \n        def report_progress(step: int, message: str):\n            nonlocal current_step\n            current_step = step\n            if progress_callback:\n                progress_callback(step, total_steps, message)\n            logger.info(f\"[{step}/{total_steps}] {message}\")\n        \n        # 1. 构建基础上下文信息\n        context = self._build_context(\n            simulation_requirement=simulation_requirement,\n            document_text=document_text,\n            entities=entities\n        )\n        \n        reasoning_parts = []\n        \n        # ========== 步骤1: 生成时间配置 ==========\n        report_progress(1, \"生成时间配置...\")\n        num_entities = len(entities)\n        time_config_result = self._generate_time_config(context, num_entities)\n        time_config = self._parse_time_config(time_config_result, num_entities)\n        reasoning_parts.append(f\"时间配置: {time_config_result.get('reasoning', '成功')}\")\n        \n        # ========== 步骤2: 生成事件配置 ==========\n        report_progress(2, \"生成事件配置和热点话题...\")\n        event_config_result = self._generate_event_config(context, simulation_requirement, entities)\n        event_config = self._parse_event_config(event_config_result)\n        reasoning_parts.append(f\"事件配置: {event_config_result.get('reasoning', '成功')}\")\n        \n        # ========== 步骤3-N: 分批生成Agent配置 ==========\n        all_agent_configs = []\n        for batch_idx in range(num_batches):\n            start_idx = batch_idx * self.AGENTS_PER_BATCH\n            end_idx = min(start_idx + self.AGENTS_PER_BATCH, len(entities))\n            batch_entities = entities[start_idx:end_idx]\n            \n            report_progress(\n                3 + batch_idx,\n                f\"生成Agent配置 ({start_idx + 1}-{end_idx}/{len(entities)})...\"\n            )\n            \n            batch_configs = self._generate_agent_configs_batch(\n                context=context,\n                entities=batch_entities,\n                start_idx=start_idx,\n                simulation_requirement=simulation_requirement\n            )\n            all_agent_configs.extend(batch_configs)\n        \n        reasoning_parts.append(f\"Agent配置: 成功生成 {len(all_agent_configs)} 个\")\n        \n        # ========== 为初始帖子分配发布者 Agent ==========\n        logger.info(\"为初始帖子分配合适的发布者 Agent...\")\n        event_config = self._assign_initial_post_agents(event_config, all_agent_configs)\n        assigned_count = len([p for p in event_config.initial_posts if p.get(\"poster_agent_id\") is not None])\n        reasoning_parts.append(f\"初始帖子分配: {assigned_count} 个帖子已分配发布者\")\n        \n        # ========== 最后一步: 生成平台配置 ==========\n        report_progress(total_steps, \"生成平台配置...\")\n        twitter_config = None\n        reddit_config = None\n        \n        if enable_twitter:\n            twitter_config = PlatformConfig(\n                platform=\"twitter\",\n                recency_weight=0.4,\n                popularity_weight=0.3,\n                relevance_weight=0.3,\n                viral_threshold=10,\n                echo_chamber_strength=0.5\n            )\n        \n        if enable_reddit:\n            reddit_config = PlatformConfig(\n                platform=\"reddit\",\n                recency_weight=0.3,\n                popularity_weight=0.4,\n                relevance_weight=0.3,\n                viral_threshold=15,\n                echo_chamber_strength=0.6\n            )\n        \n        # 构建最终参数\n        params = SimulationParameters(\n            simulation_id=simulation_id,\n            project_id=project_id,\n            graph_id=graph_id,\n            simulation_requirement=simulation_requirement,\n            time_config=time_config,\n            agent_configs=all_agent_configs,\n            event_config=event_config,\n            twitter_config=twitter_config,\n            reddit_config=reddit_config,\n            llm_model=self.model_name,\n            llm_base_url=self.base_url,\n            generation_reasoning=\" | \".join(reasoning_parts)\n        )\n        \n        logger.info(f\"模拟配置生成完成: {len(params.agent_configs)} 个Agent配置\")\n        \n        return params\n    \n    def _build_context(\n        self,\n        simulation_requirement: str,\n        document_text: str,\n        entities: List[EntityNode]\n    ) -> str:\n        \"\"\"构建LLM上下文，截断到最大长度\"\"\"\n        \n        # 实体摘要\n        entity_summary = self._summarize_entities(entities)\n        \n        # 构建上下文\n        context_parts = [\n            f\"## 模拟需求\\n{simulation_requirement}\",\n            f\"\\n## 实体信息 ({len(entities)}个)\\n{entity_summary}\",\n        ]\n        \n        current_length = sum(len(p) for p in context_parts)\n        remaining_length = self.MAX_CONTEXT_LENGTH - current_length - 500  # 留500字符余量\n        \n        if remaining_length > 0 and document_text:\n            doc_text = document_text[:remaining_length]\n            if len(document_text) > remaining_length:\n                doc_text += \"\\n...(文档已截断)\"\n            context_parts.append(f\"\\n## 原始文档内容\\n{doc_text}\")\n        \n        return \"\\n\".join(context_parts)\n    \n    def _summarize_entities(self, entities: List[EntityNode]) -> str:\n        \"\"\"生成实体摘要\"\"\"\n        lines = []\n        \n        # 按类型分组\n        by_type: Dict[str, List[EntityNode]] = {}\n        for e in entities:\n            t = e.get_entity_type() or \"Unknown\"\n            if t not in by_type:\n                by_type[t] = []\n            by_type[t].append(e)\n        \n        for entity_type, type_entities in by_type.items():\n            lines.append(f\"\\n### {entity_type} ({len(type_entities)}个)\")\n            # 使用配置的显示数量和摘要长度\n            display_count = self.ENTITIES_PER_TYPE_DISPLAY\n            summary_len = self.ENTITY_SUMMARY_LENGTH\n            for e in type_entities[:display_count]:\n                summary_preview = (e.summary[:summary_len] + \"...\") if len(e.summary) > summary_len else e.summary\n                lines.append(f\"- {e.name}: {summary_preview}\")\n            if len(type_entities) > display_count:\n                lines.append(f\"  ... 还有 {len(type_entities) - display_count} 个\")\n        \n        return \"\\n\".join(lines)\n    \n    def _call_llm_with_retry(self, prompt: str, system_prompt: str) -> Dict[str, Any]:\n        \"\"\"带重试的LLM调用，包含JSON修复逻辑\"\"\"\n        import re\n        \n        max_attempts = 3\n        last_error = None\n        \n        for attempt in range(max_attempts):\n            try:\n                response = self.client.chat.completions.create(\n                    model=self.model_name,\n                    messages=[\n                        {\"role\": \"system\", \"content\": system_prompt},\n                        {\"role\": \"user\", \"content\": prompt}\n                    ],\n                    response_format={\"type\": \"json_object\"},\n                    temperature=0.7 - (attempt * 0.1)  # 每次重试降低温度\n                    # 不设置max_tokens，让LLM自由发挥\n                )\n                \n                content = response.choices[0].message.content\n                finish_reason = response.choices[0].finish_reason\n                \n                # 检查是否被截断\n                if finish_reason == 'length':\n                    logger.warning(f\"LLM输出被截断 (attempt {attempt+1})\")\n                    content = self._fix_truncated_json(content)\n                \n                # 尝试解析JSON\n                try:\n                    return json.loads(content)\n                except json.JSONDecodeError as e:\n                    logger.warning(f\"JSON解析失败 (attempt {attempt+1}): {str(e)[:80]}\")\n                    \n                    # 尝试修复JSON\n                    fixed = self._try_fix_config_json(content)\n                    if fixed:\n                        return fixed\n                    \n                    last_error = e\n                    \n            except Exception as e:\n                logger.warning(f\"LLM调用失败 (attempt {attempt+1}): {str(e)[:80]}\")\n                last_error = e\n                import time\n                time.sleep(2 * (attempt + 1))\n        \n        raise last_error or Exception(\"LLM调用失败\")\n    \n    def _fix_truncated_json(self, content: str) -> str:\n        \"\"\"修复被截断的JSON\"\"\"\n        content = content.strip()\n        \n        # 计算未闭合的括号\n        open_braces = content.count('{') - content.count('}')\n        open_brackets = content.count('[') - content.count(']')\n        \n        # 检查是否有未闭合的字符串\n        if content and content[-1] not in '\",}]':\n            content += '\"'\n        \n        # 闭合括号\n        content += ']' * open_brackets\n        content += '}' * open_braces\n        \n        return content\n    \n    def _try_fix_config_json(self, content: str) -> Optional[Dict[str, Any]]:\n        \"\"\"尝试修复配置JSON\"\"\"\n        import re\n        \n        # 修复被截断的情况\n        content = self._fix_truncated_json(content)\n        \n        # 提取JSON部分\n        json_match = re.search(r'\\{[\\s\\S]*\\}', content)\n        if json_match:\n            json_str = json_match.group()\n            \n            # 移除字符串中的换行符\n            def fix_string(match):\n                s = match.group(0)\n                s = s.replace('\\n', ' ').replace('\\r', ' ')\n                s = re.sub(r'\\s+', ' ', s)\n                return s\n            \n            json_str = re.sub(r'\"[^\"\\\\]*(?:\\\\.[^\"\\\\]*)*\"', fix_string, json_str)\n            \n            try:\n                return json.loads(json_str)\n            except:\n                # 尝试移除所有控制字符\n                json_str = re.sub(r'[\\x00-\\x1f\\x7f-\\x9f]', ' ', json_str)\n                json_str = re.sub(r'\\s+', ' ', json_str)\n                try:\n                    return json.loads(json_str)\n                except:\n                    pass\n        \n        return None\n    \n    def _generate_time_config(self, context: str, num_entities: int) -> Dict[str, Any]:\n        \"\"\"生成时间配置\"\"\"\n        # 使用配置的上下文截断长度\n        context_truncated = context[:self.TIME_CONFIG_CONTEXT_LENGTH]\n        \n        # 计算最大允许值（80%的agent数）\n        max_agents_allowed = max(1, int(num_entities * 0.9))\n        \n        prompt = f\"\"\"基于以下模拟需求，生成时间模拟配置。\n\n{context_truncated}\n\n## 任务\n请生成时间配置JSON。\n\n### 基本原则（仅供参考，需根据具体事件和参与群体灵活调整）：\n- 用户群体为中国人，需符合北京时间作息习惯\n- 凌晨0-5点几乎无人活动（活跃度系数0.05）\n- 早上6-8点逐渐活跃（活跃度系数0.4）\n- 工作时间9-18点中等活跃（活跃度系数0.7）\n- 晚间19-22点是高峰期（活跃度系数1.5）\n- 23点后活跃度下降（活跃度系数0.5）\n- 一般规律：凌晨低活跃、早间渐增、工作时段中等、晚间高峰\n- **重要**：以下示例值仅供参考，你需要根据事件性质、参与群体特点来调整具体时段\n  - 例如：学生群体高峰可能是21-23点；媒体全天活跃；官方机构只在工作时间\n  - 例如：突发热点可能导致深夜也有讨论，off_peak_hours 可适当缩短\n\n### 返回JSON格式（不要markdown）\n\n示例：\n{{\n    \"total_simulation_hours\": 72,\n    \"minutes_per_round\": 60,\n    \"agents_per_hour_min\": 5,\n    \"agents_per_hour_max\": 50,\n    \"peak_hours\": [19, 20, 21, 22],\n    \"off_peak_hours\": [0, 1, 2, 3, 4, 5],\n    \"morning_hours\": [6, 7, 8],\n    \"work_hours\": [9, 10, 11, 12, 13, 14, 15, 16, 17, 18],\n    \"reasoning\": \"针对该事件的时间配置说明\"\n}}\n\n字段说明：\n- total_simulation_hours (int): 模拟总时长，24-168小时，突发事件短、持续话题长\n- minutes_per_round (int): 每轮时长，30-120分钟，建议60分钟\n- agents_per_hour_min (int): 每小时最少激活Agent数（取值范围: 1-{max_agents_allowed}）\n- agents_per_hour_max (int): 每小时最多激活Agent数（取值范围: 1-{max_agents_allowed}）\n- peak_hours (int数组): 高峰时段，根据事件参与群体调整\n- off_peak_hours (int数组): 低谷时段，通常深夜凌晨\n- morning_hours (int数组): 早间时段\n- work_hours (int数组): 工作时段\n- reasoning (string): 简要说明为什么这样配置\"\"\"\n\n        system_prompt = \"你是社交媒体模拟专家。返回纯JSON格式，时间配置需符合中国人作息习惯。\"\n        \n        try:\n            return self._call_llm_with_retry(prompt, system_prompt)\n        except Exception as e:\n            logger.warning(f\"时间配置LLM生成失败: {e}, 使用默认配置\")\n            return self._get_default_time_config(num_entities)\n    \n    def _get_default_time_config(self, num_entities: int) -> Dict[str, Any]:\n        \"\"\"获取默认时间配置（中国人作息）\"\"\"\n        return {\n            \"total_simulation_hours\": 72,\n            \"minutes_per_round\": 60,  # 每轮1小时，加快时间流速\n            \"agents_per_hour_min\": max(1, num_entities // 15),\n            \"agents_per_hour_max\": max(5, num_entities // 5),\n            \"peak_hours\": [19, 20, 21, 22],\n            \"off_peak_hours\": [0, 1, 2, 3, 4, 5],\n            \"morning_hours\": [6, 7, 8],\n            \"work_hours\": [9, 10, 11, 12, 13, 14, 15, 16, 17, 18],\n            \"reasoning\": \"使用默认中国人作息配置（每轮1小时）\"\n        }\n    \n    def _parse_time_config(self, result: Dict[str, Any], num_entities: int) -> TimeSimulationConfig:\n        \"\"\"解析时间配置结果，并验证agents_per_hour值不超过总agent数\"\"\"\n        # 获取原始值\n        agents_per_hour_min = result.get(\"agents_per_hour_min\", max(1, num_entities // 15))\n        agents_per_hour_max = result.get(\"agents_per_hour_max\", max(5, num_entities // 5))\n        \n        # 验证并修正：确保不超过总agent数\n        if agents_per_hour_min > num_entities:\n            logger.warning(f\"agents_per_hour_min ({agents_per_hour_min}) 超过总Agent数 ({num_entities})，已修正\")\n            agents_per_hour_min = max(1, num_entities // 10)\n        \n        if agents_per_hour_max > num_entities:\n            logger.warning(f\"agents_per_hour_max ({agents_per_hour_max}) 超过总Agent数 ({num_entities})，已修正\")\n            agents_per_hour_max = max(agents_per_hour_min + 1, num_entities // 2)\n        \n        # 确保 min < max\n        if agents_per_hour_min >= agents_per_hour_max:\n            agents_per_hour_min = max(1, agents_per_hour_max // 2)\n            logger.warning(f\"agents_per_hour_min >= max，已修正为 {agents_per_hour_min}\")\n        \n        return TimeSimulationConfig(\n            total_simulation_hours=result.get(\"total_simulation_hours\", 72),\n            minutes_per_round=result.get(\"minutes_per_round\", 60),  # 默认每轮1小时\n            agents_per_hour_min=agents_per_hour_min,\n            agents_per_hour_max=agents_per_hour_max,\n            peak_hours=result.get(\"peak_hours\", [19, 20, 21, 22]),\n            off_peak_hours=result.get(\"off_peak_hours\", [0, 1, 2, 3, 4, 5]),\n            off_peak_activity_multiplier=0.05,  # 凌晨几乎无人\n            morning_hours=result.get(\"morning_hours\", [6, 7, 8]),\n            morning_activity_multiplier=0.4,\n            work_hours=result.get(\"work_hours\", list(range(9, 19))),\n            work_activity_multiplier=0.7,\n            peak_activity_multiplier=1.5\n        )\n    \n    def _generate_event_config(\n        self, \n        context: str, \n        simulation_requirement: str,\n        entities: List[EntityNode]\n    ) -> Dict[str, Any]:\n        \"\"\"生成事件配置\"\"\"\n        \n        # 获取可用的实体类型列表，供 LLM 参考\n        entity_types_available = list(set(\n            e.get_entity_type() or \"Unknown\" for e in entities\n        ))\n        \n        # 为每种类型列出代表性实体名称\n        type_examples = {}\n        for e in entities:\n            etype = e.get_entity_type() or \"Unknown\"\n            if etype not in type_examples:\n                type_examples[etype] = []\n            if len(type_examples[etype]) < 3:\n                type_examples[etype].append(e.name)\n        \n        type_info = \"\\n\".join([\n            f\"- {t}: {', '.join(examples)}\" \n            for t, examples in type_examples.items()\n        ])\n        \n        # 使用配置的上下文截断长度\n        context_truncated = context[:self.EVENT_CONFIG_CONTEXT_LENGTH]\n        \n        prompt = f\"\"\"基于以下模拟需求，生成事件配置。\n\n模拟需求: {simulation_requirement}\n\n{context_truncated}\n\n## 可用实体类型及示例\n{type_info}\n\n## 任务\n请生成事件配置JSON：\n- 提取热点话题关键词\n- 描述舆论发展方向\n- 设计初始帖子内容，**每个帖子必须指定 poster_type（发布者类型）**\n\n**重要**: poster_type 必须从上面的\"可用实体类型\"中选择，这样初始帖子才能分配给合适的 Agent 发布。\n例如：官方声明应由 Official/University 类型发布，新闻由 MediaOutlet 发布，学生观点由 Student 发布。\n\n返回JSON格式（不要markdown）：\n{{\n    \"hot_topics\": [\"关键词1\", \"关键词2\", ...],\n    \"narrative_direction\": \"<舆论发展方向描述>\",\n    \"initial_posts\": [\n        {{\"content\": \"帖子内容\", \"poster_type\": \"实体类型（必须从可用类型中选择）\"}},\n        ...\n    ],\n    \"reasoning\": \"<简要说明>\"\n}}\"\"\"\n\n        system_prompt = \"你是舆论分析专家。返回纯JSON格式。注意 poster_type 必须精确匹配可用实体类型。\"\n        \n        try:\n            return self._call_llm_with_retry(prompt, system_prompt)\n        except Exception as e:\n            logger.warning(f\"事件配置LLM生成失败: {e}, 使用默认配置\")\n            return {\n                \"hot_topics\": [],\n                \"narrative_direction\": \"\",\n                \"initial_posts\": [],\n                \"reasoning\": \"使用默认配置\"\n            }\n    \n    def _parse_event_config(self, result: Dict[str, Any]) -> EventConfig:\n        \"\"\"解析事件配置结果\"\"\"\n        return EventConfig(\n            initial_posts=result.get(\"initial_posts\", []),\n            scheduled_events=[],\n            hot_topics=result.get(\"hot_topics\", []),\n            narrative_direction=result.get(\"narrative_direction\", \"\")\n        )\n    \n    def _assign_initial_post_agents(\n        self,\n        event_config: EventConfig,\n        agent_configs: List[AgentActivityConfig]\n    ) -> EventConfig:\n        \"\"\"\n        为初始帖子分配合适的发布者 Agent\n        \n        根据每个帖子的 poster_type 匹配最合适的 agent_id\n        \"\"\"\n        if not event_config.initial_posts:\n            return event_config\n        \n        # 按实体类型建立 agent 索引\n        agents_by_type: Dict[str, List[AgentActivityConfig]] = {}\n        for agent in agent_configs:\n            etype = agent.entity_type.lower()\n            if etype not in agents_by_type:\n                agents_by_type[etype] = []\n            agents_by_type[etype].append(agent)\n        \n        # 类型映射表（处理 LLM 可能输出的不同格式）\n        type_aliases = {\n            \"official\": [\"official\", \"university\", \"governmentagency\", \"government\"],\n            \"university\": [\"university\", \"official\"],\n            \"mediaoutlet\": [\"mediaoutlet\", \"media\"],\n            \"student\": [\"student\", \"person\"],\n            \"professor\": [\"professor\", \"expert\", \"teacher\"],\n            \"alumni\": [\"alumni\", \"person\"],\n            \"organization\": [\"organization\", \"ngo\", \"company\", \"group\"],\n            \"person\": [\"person\", \"student\", \"alumni\"],\n        }\n        \n        # 记录每种类型已使用的 agent 索引，避免重复使用同一个 agent\n        used_indices: Dict[str, int] = {}\n        \n        updated_posts = []\n        for post in event_config.initial_posts:\n            poster_type = post.get(\"poster_type\", \"\").lower()\n            content = post.get(\"content\", \"\")\n            \n            # 尝试找到匹配的 agent\n            matched_agent_id = None\n            \n            # 1. 直接匹配\n            if poster_type in agents_by_type:\n                agents = agents_by_type[poster_type]\n                idx = used_indices.get(poster_type, 0) % len(agents)\n                matched_agent_id = agents[idx].agent_id\n                used_indices[poster_type] = idx + 1\n            else:\n                # 2. 使用别名匹配\n                for alias_key, aliases in type_aliases.items():\n                    if poster_type in aliases or alias_key == poster_type:\n                        for alias in aliases:\n                            if alias in agents_by_type:\n                                agents = agents_by_type[alias]\n                                idx = used_indices.get(alias, 0) % len(agents)\n                                matched_agent_id = agents[idx].agent_id\n                                used_indices[alias] = idx + 1\n                                break\n                    if matched_agent_id is not None:\n                        break\n            \n            # 3. 如果仍未找到，使用影响力最高的 agent\n            if matched_agent_id is None:\n                logger.warning(f\"未找到类型 '{poster_type}' 的匹配 Agent，使用影响力最高的 Agent\")\n                if agent_configs:\n                    # 按影响力排序，选择影响力最高的\n                    sorted_agents = sorted(agent_configs, key=lambda a: a.influence_weight, reverse=True)\n                    matched_agent_id = sorted_agents[0].agent_id\n                else:\n                    matched_agent_id = 0\n            \n            updated_posts.append({\n                \"content\": content,\n                \"poster_type\": post.get(\"poster_type\", \"Unknown\"),\n                \"poster_agent_id\": matched_agent_id\n            })\n            \n            logger.info(f\"初始帖子分配: poster_type='{poster_type}' -> agent_id={matched_agent_id}\")\n        \n        event_config.initial_posts = updated_posts\n        return event_config\n    \n    def _generate_agent_configs_batch(\n        self,\n        context: str,\n        entities: List[EntityNode],\n        start_idx: int,\n        simulation_requirement: str\n    ) -> List[AgentActivityConfig]:\n        \"\"\"分批生成Agent配置\"\"\"\n        \n        # 构建实体信息（使用配置的摘要长度）\n        entity_list = []\n        summary_len = self.AGENT_SUMMARY_LENGTH\n        for i, e in enumerate(entities):\n            entity_list.append({\n                \"agent_id\": start_idx + i,\n                \"entity_name\": e.name,\n                \"entity_type\": e.get_entity_type() or \"Unknown\",\n                \"summary\": e.summary[:summary_len] if e.summary else \"\"\n            })\n        \n        prompt = f\"\"\"基于以下信息，为每个实体生成社交媒体活动配置。\n\n模拟需求: {simulation_requirement}\n\n## 实体列表\n```json\n{json.dumps(entity_list, ensure_ascii=False, indent=2)}\n```\n\n## 任务\n为每个实体生成活动配置，注意：\n- **时间符合中国人作息**：凌晨0-5点几乎不活动，晚间19-22点最活跃\n- **官方机构**（University/GovernmentAgency）：活跃度低(0.1-0.3)，工作时间(9-17)活动，响应慢(60-240分钟)，影响力高(2.5-3.0)\n- **媒体**（MediaOutlet）：活跃度中(0.4-0.6)，全天活动(8-23)，响应快(5-30分钟)，影响力高(2.0-2.5)\n- **个人**（Student/Person/Alumni）：活跃度高(0.6-0.9)，主要晚间活动(18-23)，响应快(1-15分钟)，影响力低(0.8-1.2)\n- **公众人物/专家**：活跃度中(0.4-0.6)，影响力中高(1.5-2.0)\n\n返回JSON格式（不要markdown）：\n{{\n    \"agent_configs\": [\n        {{\n            \"agent_id\": <必须与输入一致>,\n            \"activity_level\": <0.0-1.0>,\n            \"posts_per_hour\": <发帖频率>,\n            \"comments_per_hour\": <评论频率>,\n            \"active_hours\": [<活跃小时列表，考虑中国人作息>],\n            \"response_delay_min\": <最小响应延迟分钟>,\n            \"response_delay_max\": <最大响应延迟分钟>,\n            \"sentiment_bias\": <-1.0到1.0>,\n            \"stance\": \"<supportive/opposing/neutral/observer>\",\n            \"influence_weight\": <影响力权重>\n        }},\n        ...\n    ]\n}}\"\"\"\n\n        system_prompt = \"你是社交媒体行为分析专家。返回纯JSON，配置需符合中国人作息习惯。\"\n        \n        try:\n            result = self._call_llm_with_retry(prompt, system_prompt)\n            llm_configs = {cfg[\"agent_id\"]: cfg for cfg in result.get(\"agent_configs\", [])}\n        except Exception as e:\n            logger.warning(f\"Agent配置批次LLM生成失败: {e}, 使用规则生成\")\n            llm_configs = {}\n        \n        # 构建AgentActivityConfig对象\n        configs = []\n        for i, entity in enumerate(entities):\n            agent_id = start_idx + i\n            cfg = llm_configs.get(agent_id, {})\n            \n            # 如果LLM没有生成，使用规则生成\n            if not cfg:\n                cfg = self._generate_agent_config_by_rule(entity)\n            \n            config = AgentActivityConfig(\n                agent_id=agent_id,\n                entity_uuid=entity.uuid,\n                entity_name=entity.name,\n                entity_type=entity.get_entity_type() or \"Unknown\",\n                activity_level=cfg.get(\"activity_level\", 0.5),\n                posts_per_hour=cfg.get(\"posts_per_hour\", 0.5),\n                comments_per_hour=cfg.get(\"comments_per_hour\", 1.0),\n                active_hours=cfg.get(\"active_hours\", list(range(9, 23))),\n                response_delay_min=cfg.get(\"response_delay_min\", 5),\n                response_delay_max=cfg.get(\"response_delay_max\", 60),\n                sentiment_bias=cfg.get(\"sentiment_bias\", 0.0),\n                stance=cfg.get(\"stance\", \"neutral\"),\n                influence_weight=cfg.get(\"influence_weight\", 1.0)\n            )\n            configs.append(config)\n        \n        return configs\n    \n    def _generate_agent_config_by_rule(self, entity: EntityNode) -> Dict[str, Any]:\n        \"\"\"基于规则生成单个Agent配置（中国人作息）\"\"\"\n        entity_type = (entity.get_entity_type() or \"Unknown\").lower()\n        \n        if entity_type in [\"university\", \"governmentagency\", \"ngo\"]:\n            # 官方机构：工作时间活动，低频率，高影响力\n            return {\n                \"activity_level\": 0.2,\n                \"posts_per_hour\": 0.1,\n                \"comments_per_hour\": 0.05,\n                \"active_hours\": list(range(9, 18)),  # 9:00-17:59\n                \"response_delay_min\": 60,\n                \"response_delay_max\": 240,\n                \"sentiment_bias\": 0.0,\n                \"stance\": \"neutral\",\n                \"influence_weight\": 3.0\n            }\n        elif entity_type in [\"mediaoutlet\"]:\n            # 媒体：全天活动，中等频率，高影响力\n            return {\n                \"activity_level\": 0.5,\n                \"posts_per_hour\": 0.8,\n                \"comments_per_hour\": 0.3,\n                \"active_hours\": list(range(7, 24)),  # 7:00-23:59\n                \"response_delay_min\": 5,\n                \"response_delay_max\": 30,\n                \"sentiment_bias\": 0.0,\n                \"stance\": \"observer\",\n                \"influence_weight\": 2.5\n            }\n        elif entity_type in [\"professor\", \"expert\", \"official\"]:\n            # 专家/教授：工作+晚间活动，中等频率\n            return {\n                \"activity_level\": 0.4,\n                \"posts_per_hour\": 0.3,\n                \"comments_per_hour\": 0.5,\n                \"active_hours\": list(range(8, 22)),  # 8:00-21:59\n                \"response_delay_min\": 15,\n                \"response_delay_max\": 90,\n                \"sentiment_bias\": 0.0,\n                \"stance\": \"neutral\",\n                \"influence_weight\": 2.0\n            }\n        elif entity_type in [\"student\"]:\n            # 学生：晚间为主，高频率\n            return {\n                \"activity_level\": 0.8,\n                \"posts_per_hour\": 0.6,\n                \"comments_per_hour\": 1.5,\n                \"active_hours\": [8, 9, 10, 11, 12, 13, 18, 19, 20, 21, 22, 23],  # 上午+晚间\n                \"response_delay_min\": 1,\n                \"response_delay_max\": 15,\n                \"sentiment_bias\": 0.0,\n                \"stance\": \"neutral\",\n                \"influence_weight\": 0.8\n            }\n        elif entity_type in [\"alumni\"]:\n            # 校友：晚间为主\n            return {\n                \"activity_level\": 0.6,\n                \"posts_per_hour\": 0.4,\n                \"comments_per_hour\": 0.8,\n                \"active_hours\": [12, 13, 19, 20, 21, 22, 23],  # 午休+晚间\n                \"response_delay_min\": 5,\n                \"response_delay_max\": 30,\n                \"sentiment_bias\": 0.0,\n                \"stance\": \"neutral\",\n                \"influence_weight\": 1.0\n            }\n        else:\n            # 普通人：晚间高峰\n            return {\n                \"activity_level\": 0.7,\n                \"posts_per_hour\": 0.5,\n                \"comments_per_hour\": 1.2,\n                \"active_hours\": [9, 10, 11, 12, 13, 18, 19, 20, 21, 22, 23],  # 白天+晚间\n                \"response_delay_min\": 2,\n                \"response_delay_max\": 20,\n                \"sentiment_bias\": 0.0,\n                \"stance\": \"neutral\",\n                \"influence_weight\": 1.0\n            }\n    \n\n"
  },
  {
    "path": "backend/app/services/simulation_ipc.py",
    "content": "\"\"\"\n模拟IPC通信模块\n用于Flask后端和模拟脚本之间的进程间通信\n\n通过文件系统实现简单的命令/响应模式：\n1. Flask写入命令到 commands/ 目录\n2. 模拟脚本轮询命令目录，执行命令并写入响应到 responses/ 目录\n3. Flask轮询响应目录获取结果\n\"\"\"\n\nimport os\nimport json\nimport time\nimport uuid\nfrom typing import Dict, Any, Optional, List\nfrom dataclasses import dataclass, field\nfrom datetime import datetime\nfrom enum import Enum\n\nfrom ..utils.logger import get_logger\n\nlogger = get_logger('mirofish.simulation_ipc')\n\n\nclass CommandType(str, Enum):\n    \"\"\"命令类型\"\"\"\n    INTERVIEW = \"interview\"           # 单个Agent采访\n    BATCH_INTERVIEW = \"batch_interview\"  # 批量采访\n    CLOSE_ENV = \"close_env\"           # 关闭环境\n\n\nclass CommandStatus(str, Enum):\n    \"\"\"命令状态\"\"\"\n    PENDING = \"pending\"\n    PROCESSING = \"processing\"\n    COMPLETED = \"completed\"\n    FAILED = \"failed\"\n\n\n@dataclass\nclass IPCCommand:\n    \"\"\"IPC命令\"\"\"\n    command_id: str\n    command_type: CommandType\n    args: Dict[str, Any]\n    timestamp: str = field(default_factory=lambda: datetime.now().isoformat())\n    \n    def to_dict(self) -> Dict[str, Any]:\n        return {\n            \"command_id\": self.command_id,\n            \"command_type\": self.command_type.value,\n            \"args\": self.args,\n            \"timestamp\": self.timestamp\n        }\n    \n    @classmethod\n    def from_dict(cls, data: Dict[str, Any]) -> 'IPCCommand':\n        return cls(\n            command_id=data[\"command_id\"],\n            command_type=CommandType(data[\"command_type\"]),\n            args=data.get(\"args\", {}),\n            timestamp=data.get(\"timestamp\", datetime.now().isoformat())\n        )\n\n\n@dataclass\nclass IPCResponse:\n    \"\"\"IPC响应\"\"\"\n    command_id: str\n    status: CommandStatus\n    result: Optional[Dict[str, Any]] = None\n    error: Optional[str] = None\n    timestamp: str = field(default_factory=lambda: datetime.now().isoformat())\n    \n    def to_dict(self) -> Dict[str, Any]:\n        return {\n            \"command_id\": self.command_id,\n            \"status\": self.status.value,\n            \"result\": self.result,\n            \"error\": self.error,\n            \"timestamp\": self.timestamp\n        }\n    \n    @classmethod\n    def from_dict(cls, data: Dict[str, Any]) -> 'IPCResponse':\n        return cls(\n            command_id=data[\"command_id\"],\n            status=CommandStatus(data[\"status\"]),\n            result=data.get(\"result\"),\n            error=data.get(\"error\"),\n            timestamp=data.get(\"timestamp\", datetime.now().isoformat())\n        )\n\n\nclass SimulationIPCClient:\n    \"\"\"\n    模拟IPC客户端（Flask端使用）\n    \n    用于向模拟进程发送命令并等待响应\n    \"\"\"\n    \n    def __init__(self, simulation_dir: str):\n        \"\"\"\n        初始化IPC客户端\n        \n        Args:\n            simulation_dir: 模拟数据目录\n        \"\"\"\n        self.simulation_dir = simulation_dir\n        self.commands_dir = os.path.join(simulation_dir, \"ipc_commands\")\n        self.responses_dir = os.path.join(simulation_dir, \"ipc_responses\")\n        \n        # 确保目录存在\n        os.makedirs(self.commands_dir, exist_ok=True)\n        os.makedirs(self.responses_dir, exist_ok=True)\n    \n    def send_command(\n        self,\n        command_type: CommandType,\n        args: Dict[str, Any],\n        timeout: float = 60.0,\n        poll_interval: float = 0.5\n    ) -> IPCResponse:\n        \"\"\"\n        发送命令并等待响应\n        \n        Args:\n            command_type: 命令类型\n            args: 命令参数\n            timeout: 超时时间（秒）\n            poll_interval: 轮询间隔（秒）\n            \n        Returns:\n            IPCResponse\n            \n        Raises:\n            TimeoutError: 等待响应超时\n        \"\"\"\n        command_id = str(uuid.uuid4())\n        command = IPCCommand(\n            command_id=command_id,\n            command_type=command_type,\n            args=args\n        )\n        \n        # 写入命令文件\n        command_file = os.path.join(self.commands_dir, f\"{command_id}.json\")\n        with open(command_file, 'w', encoding='utf-8') as f:\n            json.dump(command.to_dict(), f, ensure_ascii=False, indent=2)\n        \n        logger.info(f\"发送IPC命令: {command_type.value}, command_id={command_id}\")\n        \n        # 等待响应\n        response_file = os.path.join(self.responses_dir, f\"{command_id}.json\")\n        start_time = time.time()\n        \n        while time.time() - start_time < timeout:\n            if os.path.exists(response_file):\n                try:\n                    with open(response_file, 'r', encoding='utf-8') as f:\n                        response_data = json.load(f)\n                    response = IPCResponse.from_dict(response_data)\n                    \n                    # 清理命令和响应文件\n                    try:\n                        os.remove(command_file)\n                        os.remove(response_file)\n                    except OSError:\n                        pass\n                    \n                    logger.info(f\"收到IPC响应: command_id={command_id}, status={response.status.value}\")\n                    return response\n                except (json.JSONDecodeError, KeyError) as e:\n                    logger.warning(f\"解析响应失败: {e}\")\n            \n            time.sleep(poll_interval)\n        \n        # 超时\n        logger.error(f\"等待IPC响应超时: command_id={command_id}\")\n        \n        # 清理命令文件\n        try:\n            os.remove(command_file)\n        except OSError:\n            pass\n        \n        raise TimeoutError(f\"等待命令响应超时 ({timeout}秒)\")\n    \n    def send_interview(\n        self,\n        agent_id: int,\n        prompt: str,\n        platform: str = None,\n        timeout: float = 60.0\n    ) -> IPCResponse:\n        \"\"\"\n        发送单个Agent采访命令\n        \n        Args:\n            agent_id: Agent ID\n            prompt: 采访问题\n            platform: 指定平台（可选）\n                - \"twitter\": 只采访Twitter平台\n                - \"reddit\": 只采访Reddit平台  \n                - None: 双平台模拟时同时采访两个平台，单平台模拟时采访该平台\n            timeout: 超时时间\n            \n        Returns:\n            IPCResponse，result字段包含采访结果\n        \"\"\"\n        args = {\n            \"agent_id\": agent_id,\n            \"prompt\": prompt\n        }\n        if platform:\n            args[\"platform\"] = platform\n            \n        return self.send_command(\n            command_type=CommandType.INTERVIEW,\n            args=args,\n            timeout=timeout\n        )\n    \n    def send_batch_interview(\n        self,\n        interviews: List[Dict[str, Any]],\n        platform: str = None,\n        timeout: float = 120.0\n    ) -> IPCResponse:\n        \"\"\"\n        发送批量采访命令\n        \n        Args:\n            interviews: 采访列表，每个元素包含 {\"agent_id\": int, \"prompt\": str, \"platform\": str(可选)}\n            platform: 默认平台（可选，会被每个采访项的platform覆盖）\n                - \"twitter\": 默认只采访Twitter平台\n                - \"reddit\": 默认只采访Reddit平台\n                - None: 双平台模拟时每个Agent同时采访两个平台\n            timeout: 超时时间\n            \n        Returns:\n            IPCResponse，result字段包含所有采访结果\n        \"\"\"\n        args = {\"interviews\": interviews}\n        if platform:\n            args[\"platform\"] = platform\n            \n        return self.send_command(\n            command_type=CommandType.BATCH_INTERVIEW,\n            args=args,\n            timeout=timeout\n        )\n    \n    def send_close_env(self, timeout: float = 30.0) -> IPCResponse:\n        \"\"\"\n        发送关闭环境命令\n        \n        Args:\n            timeout: 超时时间\n            \n        Returns:\n            IPCResponse\n        \"\"\"\n        return self.send_command(\n            command_type=CommandType.CLOSE_ENV,\n            args={},\n            timeout=timeout\n        )\n    \n    def check_env_alive(self) -> bool:\n        \"\"\"\n        检查模拟环境是否存活\n        \n        通过检查 env_status.json 文件来判断\n        \"\"\"\n        status_file = os.path.join(self.simulation_dir, \"env_status.json\")\n        if not os.path.exists(status_file):\n            return False\n        \n        try:\n            with open(status_file, 'r', encoding='utf-8') as f:\n                status = json.load(f)\n            return status.get(\"status\") == \"alive\"\n        except (json.JSONDecodeError, OSError):\n            return False\n\n\nclass SimulationIPCServer:\n    \"\"\"\n    模拟IPC服务器（模拟脚本端使用）\n    \n    轮询命令目录，执行命令并返回响应\n    \"\"\"\n    \n    def __init__(self, simulation_dir: str):\n        \"\"\"\n        初始化IPC服务器\n        \n        Args:\n            simulation_dir: 模拟数据目录\n        \"\"\"\n        self.simulation_dir = simulation_dir\n        self.commands_dir = os.path.join(simulation_dir, \"ipc_commands\")\n        self.responses_dir = os.path.join(simulation_dir, \"ipc_responses\")\n        \n        # 确保目录存在\n        os.makedirs(self.commands_dir, exist_ok=True)\n        os.makedirs(self.responses_dir, exist_ok=True)\n        \n        # 环境状态\n        self._running = False\n    \n    def start(self):\n        \"\"\"标记服务器为运行状态\"\"\"\n        self._running = True\n        self._update_env_status(\"alive\")\n    \n    def stop(self):\n        \"\"\"标记服务器为停止状态\"\"\"\n        self._running = False\n        self._update_env_status(\"stopped\")\n    \n    def _update_env_status(self, status: str):\n        \"\"\"更新环境状态文件\"\"\"\n        status_file = os.path.join(self.simulation_dir, \"env_status.json\")\n        with open(status_file, 'w', encoding='utf-8') as f:\n            json.dump({\n                \"status\": status,\n                \"timestamp\": datetime.now().isoformat()\n            }, f, ensure_ascii=False, indent=2)\n    \n    def poll_commands(self) -> Optional[IPCCommand]:\n        \"\"\"\n        轮询命令目录，返回第一个待处理的命令\n        \n        Returns:\n            IPCCommand 或 None\n        \"\"\"\n        if not os.path.exists(self.commands_dir):\n            return None\n        \n        # 按时间排序获取命令文件\n        command_files = []\n        for filename in os.listdir(self.commands_dir):\n            if filename.endswith('.json'):\n                filepath = os.path.join(self.commands_dir, filename)\n                command_files.append((filepath, os.path.getmtime(filepath)))\n        \n        command_files.sort(key=lambda x: x[1])\n        \n        for filepath, _ in command_files:\n            try:\n                with open(filepath, 'r', encoding='utf-8') as f:\n                    data = json.load(f)\n                return IPCCommand.from_dict(data)\n            except (json.JSONDecodeError, KeyError, OSError) as e:\n                logger.warning(f\"读取命令文件失败: {filepath}, {e}\")\n                continue\n        \n        return None\n    \n    def send_response(self, response: IPCResponse):\n        \"\"\"\n        发送响应\n        \n        Args:\n            response: IPC响应\n        \"\"\"\n        response_file = os.path.join(self.responses_dir, f\"{response.command_id}.json\")\n        with open(response_file, 'w', encoding='utf-8') as f:\n            json.dump(response.to_dict(), f, ensure_ascii=False, indent=2)\n        \n        # 删除命令文件\n        command_file = os.path.join(self.commands_dir, f\"{response.command_id}.json\")\n        try:\n            os.remove(command_file)\n        except OSError:\n            pass\n    \n    def send_success(self, command_id: str, result: Dict[str, Any]):\n        \"\"\"发送成功响应\"\"\"\n        self.send_response(IPCResponse(\n            command_id=command_id,\n            status=CommandStatus.COMPLETED,\n            result=result\n        ))\n    \n    def send_error(self, command_id: str, error: str):\n        \"\"\"发送错误响应\"\"\"\n        self.send_response(IPCResponse(\n            command_id=command_id,\n            status=CommandStatus.FAILED,\n            error=error\n        ))\n"
  },
  {
    "path": "backend/app/services/simulation_manager.py",
    "content": "\"\"\"\nOASIS模拟管理器\n管理Twitter和Reddit双平台并行模拟\n使用预设脚本 + LLM智能生成配置参数\n\"\"\"\n\nimport os\nimport json\nimport shutil\nfrom typing import Dict, Any, List, Optional\nfrom dataclasses import dataclass, field\nfrom datetime import datetime\nfrom enum import Enum\n\nfrom ..config import Config\nfrom ..utils.logger import get_logger\nfrom .zep_entity_reader import ZepEntityReader, FilteredEntities\nfrom .oasis_profile_generator import OasisProfileGenerator, OasisAgentProfile\nfrom .simulation_config_generator import SimulationConfigGenerator, SimulationParameters\n\nlogger = get_logger('mirofish.simulation')\n\n\nclass SimulationStatus(str, Enum):\n    \"\"\"模拟状态\"\"\"\n    CREATED = \"created\"\n    PREPARING = \"preparing\"\n    READY = \"ready\"\n    RUNNING = \"running\"\n    PAUSED = \"paused\"\n    STOPPED = \"stopped\"      # 模拟被手动停止\n    COMPLETED = \"completed\"  # 模拟自然完成\n    FAILED = \"failed\"\n\n\nclass PlatformType(str, Enum):\n    \"\"\"平台类型\"\"\"\n    TWITTER = \"twitter\"\n    REDDIT = \"reddit\"\n\n\n@dataclass\nclass SimulationState:\n    \"\"\"模拟状态\"\"\"\n    simulation_id: str\n    project_id: str\n    graph_id: str\n    \n    # 平台启用状态\n    enable_twitter: bool = True\n    enable_reddit: bool = True\n    \n    # 状态\n    status: SimulationStatus = SimulationStatus.CREATED\n    \n    # 准备阶段数据\n    entities_count: int = 0\n    profiles_count: int = 0\n    entity_types: List[str] = field(default_factory=list)\n    \n    # 配置生成信息\n    config_generated: bool = False\n    config_reasoning: str = \"\"\n    \n    # 运行时数据\n    current_round: int = 0\n    twitter_status: str = \"not_started\"\n    reddit_status: str = \"not_started\"\n    \n    # 时间戳\n    created_at: str = field(default_factory=lambda: datetime.now().isoformat())\n    updated_at: str = field(default_factory=lambda: datetime.now().isoformat())\n    \n    # 错误信息\n    error: Optional[str] = None\n    \n    def to_dict(self) -> Dict[str, Any]:\n        \"\"\"完整状态字典（内部使用）\"\"\"\n        return {\n            \"simulation_id\": self.simulation_id,\n            \"project_id\": self.project_id,\n            \"graph_id\": self.graph_id,\n            \"enable_twitter\": self.enable_twitter,\n            \"enable_reddit\": self.enable_reddit,\n            \"status\": self.status.value,\n            \"entities_count\": self.entities_count,\n            \"profiles_count\": self.profiles_count,\n            \"entity_types\": self.entity_types,\n            \"config_generated\": self.config_generated,\n            \"config_reasoning\": self.config_reasoning,\n            \"current_round\": self.current_round,\n            \"twitter_status\": self.twitter_status,\n            \"reddit_status\": self.reddit_status,\n            \"created_at\": self.created_at,\n            \"updated_at\": self.updated_at,\n            \"error\": self.error,\n        }\n    \n    def to_simple_dict(self) -> Dict[str, Any]:\n        \"\"\"简化状态字典（API返回使用）\"\"\"\n        return {\n            \"simulation_id\": self.simulation_id,\n            \"project_id\": self.project_id,\n            \"graph_id\": self.graph_id,\n            \"status\": self.status.value,\n            \"entities_count\": self.entities_count,\n            \"profiles_count\": self.profiles_count,\n            \"entity_types\": self.entity_types,\n            \"config_generated\": self.config_generated,\n            \"error\": self.error,\n        }\n\n\nclass SimulationManager:\n    \"\"\"\n    模拟管理器\n    \n    核心功能：\n    1. 从Zep图谱读取实体并过滤\n    2. 生成OASIS Agent Profile\n    3. 使用LLM智能生成模拟配置参数\n    4. 准备预设脚本所需的所有文件\n    \"\"\"\n    \n    # 模拟数据存储目录\n    SIMULATION_DATA_DIR = os.path.join(\n        os.path.dirname(__file__), \n        '../../uploads/simulations'\n    )\n    \n    def __init__(self):\n        # 确保目录存在\n        os.makedirs(self.SIMULATION_DATA_DIR, exist_ok=True)\n        \n        # 内存中的模拟状态缓存\n        self._simulations: Dict[str, SimulationState] = {}\n    \n    def _get_simulation_dir(self, simulation_id: str) -> str:\n        \"\"\"获取模拟数据目录\"\"\"\n        sim_dir = os.path.join(self.SIMULATION_DATA_DIR, simulation_id)\n        os.makedirs(sim_dir, exist_ok=True)\n        return sim_dir\n    \n    def _save_simulation_state(self, state: SimulationState):\n        \"\"\"保存模拟状态到文件\"\"\"\n        sim_dir = self._get_simulation_dir(state.simulation_id)\n        state_file = os.path.join(sim_dir, \"state.json\")\n        \n        state.updated_at = datetime.now().isoformat()\n        \n        with open(state_file, 'w', encoding='utf-8') as f:\n            json.dump(state.to_dict(), f, ensure_ascii=False, indent=2)\n        \n        self._simulations[state.simulation_id] = state\n    \n    def _load_simulation_state(self, simulation_id: str) -> Optional[SimulationState]:\n        \"\"\"从文件加载模拟状态\"\"\"\n        if simulation_id in self._simulations:\n            return self._simulations[simulation_id]\n        \n        sim_dir = self._get_simulation_dir(simulation_id)\n        state_file = os.path.join(sim_dir, \"state.json\")\n        \n        if not os.path.exists(state_file):\n            return None\n        \n        with open(state_file, 'r', encoding='utf-8') as f:\n            data = json.load(f)\n        \n        state = SimulationState(\n            simulation_id=simulation_id,\n            project_id=data.get(\"project_id\", \"\"),\n            graph_id=data.get(\"graph_id\", \"\"),\n            enable_twitter=data.get(\"enable_twitter\", True),\n            enable_reddit=data.get(\"enable_reddit\", True),\n            status=SimulationStatus(data.get(\"status\", \"created\")),\n            entities_count=data.get(\"entities_count\", 0),\n            profiles_count=data.get(\"profiles_count\", 0),\n            entity_types=data.get(\"entity_types\", []),\n            config_generated=data.get(\"config_generated\", False),\n            config_reasoning=data.get(\"config_reasoning\", \"\"),\n            current_round=data.get(\"current_round\", 0),\n            twitter_status=data.get(\"twitter_status\", \"not_started\"),\n            reddit_status=data.get(\"reddit_status\", \"not_started\"),\n            created_at=data.get(\"created_at\", datetime.now().isoformat()),\n            updated_at=data.get(\"updated_at\", datetime.now().isoformat()),\n            error=data.get(\"error\"),\n        )\n        \n        self._simulations[simulation_id] = state\n        return state\n    \n    def create_simulation(\n        self,\n        project_id: str,\n        graph_id: str,\n        enable_twitter: bool = True,\n        enable_reddit: bool = True,\n    ) -> SimulationState:\n        \"\"\"\n        创建新的模拟\n        \n        Args:\n            project_id: 项目ID\n            graph_id: Zep图谱ID\n            enable_twitter: 是否启用Twitter模拟\n            enable_reddit: 是否启用Reddit模拟\n            \n        Returns:\n            SimulationState\n        \"\"\"\n        import uuid\n        simulation_id = f\"sim_{uuid.uuid4().hex[:12]}\"\n        \n        state = SimulationState(\n            simulation_id=simulation_id,\n            project_id=project_id,\n            graph_id=graph_id,\n            enable_twitter=enable_twitter,\n            enable_reddit=enable_reddit,\n            status=SimulationStatus.CREATED,\n        )\n        \n        self._save_simulation_state(state)\n        logger.info(f\"创建模拟: {simulation_id}, project={project_id}, graph={graph_id}\")\n        \n        return state\n    \n    def prepare_simulation(\n        self,\n        simulation_id: str,\n        simulation_requirement: str,\n        document_text: str,\n        defined_entity_types: Optional[List[str]] = None,\n        use_llm_for_profiles: bool = True,\n        progress_callback: Optional[callable] = None,\n        parallel_profile_count: int = 3\n    ) -> SimulationState:\n        \"\"\"\n        准备模拟环境（全程自动化）\n        \n        步骤：\n        1. 从Zep图谱读取并过滤实体\n        2. 为每个实体生成OASIS Agent Profile（可选LLM增强，支持并行）\n        3. 使用LLM智能生成模拟配置参数（时间、活跃度、发言频率等）\n        4. 保存配置文件和Profile文件\n        5. 复制预设脚本到模拟目录\n        \n        Args:\n            simulation_id: 模拟ID\n            simulation_requirement: 模拟需求描述（用于LLM生成配置）\n            document_text: 原始文档内容（用于LLM理解背景）\n            defined_entity_types: 预定义的实体类型（可选）\n            use_llm_for_profiles: 是否使用LLM生成详细人设\n            progress_callback: 进度回调函数 (stage, progress, message)\n            parallel_profile_count: 并行生成人设的数量，默认3\n            \n        Returns:\n            SimulationState\n        \"\"\"\n        state = self._load_simulation_state(simulation_id)\n        if not state:\n            raise ValueError(f\"模拟不存在: {simulation_id}\")\n        \n        try:\n            state.status = SimulationStatus.PREPARING\n            self._save_simulation_state(state)\n            \n            sim_dir = self._get_simulation_dir(simulation_id)\n            \n            # ========== 阶段1: 读取并过滤实体 ==========\n            if progress_callback:\n                progress_callback(\"reading\", 0, \"正在连接Zep图谱...\")\n            \n            reader = ZepEntityReader()\n            \n            if progress_callback:\n                progress_callback(\"reading\", 30, \"正在读取节点数据...\")\n            \n            filtered = reader.filter_defined_entities(\n                graph_id=state.graph_id,\n                defined_entity_types=defined_entity_types,\n                enrich_with_edges=True\n            )\n            \n            state.entities_count = filtered.filtered_count\n            state.entity_types = list(filtered.entity_types)\n            \n            if progress_callback:\n                progress_callback(\n                    \"reading\", 100, \n                    f\"完成，共 {filtered.filtered_count} 个实体\",\n                    current=filtered.filtered_count,\n                    total=filtered.filtered_count\n                )\n            \n            if filtered.filtered_count == 0:\n                state.status = SimulationStatus.FAILED\n                state.error = \"没有找到符合条件的实体，请检查图谱是否正确构建\"\n                self._save_simulation_state(state)\n                return state\n            \n            # ========== 阶段2: 生成Agent Profile ==========\n            total_entities = len(filtered.entities)\n            \n            if progress_callback:\n                progress_callback(\n                    \"generating_profiles\", 0, \n                    \"开始生成...\",\n                    current=0,\n                    total=total_entities\n                )\n            \n            # 传入graph_id以启用Zep检索功能，获取更丰富的上下文\n            generator = OasisProfileGenerator(graph_id=state.graph_id)\n            \n            def profile_progress(current, total, msg):\n                if progress_callback:\n                    progress_callback(\n                        \"generating_profiles\", \n                        int(current / total * 100), \n                        msg,\n                        current=current,\n                        total=total,\n                        item_name=msg\n                    )\n            \n            # 设置实时保存的文件路径（优先使用 Reddit JSON 格式）\n            realtime_output_path = None\n            realtime_platform = \"reddit\"\n            if state.enable_reddit:\n                realtime_output_path = os.path.join(sim_dir, \"reddit_profiles.json\")\n                realtime_platform = \"reddit\"\n            elif state.enable_twitter:\n                realtime_output_path = os.path.join(sim_dir, \"twitter_profiles.csv\")\n                realtime_platform = \"twitter\"\n            \n            profiles = generator.generate_profiles_from_entities(\n                entities=filtered.entities,\n                use_llm=use_llm_for_profiles,\n                progress_callback=profile_progress,\n                graph_id=state.graph_id,  # 传入graph_id用于Zep检索\n                parallel_count=parallel_profile_count,  # 并行生成数量\n                realtime_output_path=realtime_output_path,  # 实时保存路径\n                output_platform=realtime_platform  # 输出格式\n            )\n            \n            state.profiles_count = len(profiles)\n            \n            # 保存Profile文件（注意：Twitter使用CSV格式，Reddit使用JSON格式）\n            # Reddit 已经在生成过程中实时保存了，这里再保存一次确保完整性\n            if progress_callback:\n                progress_callback(\n                    \"generating_profiles\", 95, \n                    \"保存Profile文件...\",\n                    current=total_entities,\n                    total=total_entities\n                )\n            \n            if state.enable_reddit:\n                generator.save_profiles(\n                    profiles=profiles,\n                    file_path=os.path.join(sim_dir, \"reddit_profiles.json\"),\n                    platform=\"reddit\"\n                )\n            \n            if state.enable_twitter:\n                # Twitter使用CSV格式！这是OASIS的要求\n                generator.save_profiles(\n                    profiles=profiles,\n                    file_path=os.path.join(sim_dir, \"twitter_profiles.csv\"),\n                    platform=\"twitter\"\n                )\n            \n            if progress_callback:\n                progress_callback(\n                    \"generating_profiles\", 100, \n                    f\"完成，共 {len(profiles)} 个Profile\",\n                    current=len(profiles),\n                    total=len(profiles)\n                )\n            \n            # ========== 阶段3: LLM智能生成模拟配置 ==========\n            if progress_callback:\n                progress_callback(\n                    \"generating_config\", 0, \n                    \"正在分析模拟需求...\",\n                    current=0,\n                    total=3\n                )\n            \n            config_generator = SimulationConfigGenerator()\n            \n            if progress_callback:\n                progress_callback(\n                    \"generating_config\", 30, \n                    \"正在调用LLM生成配置...\",\n                    current=1,\n                    total=3\n                )\n            \n            sim_params = config_generator.generate_config(\n                simulation_id=simulation_id,\n                project_id=state.project_id,\n                graph_id=state.graph_id,\n                simulation_requirement=simulation_requirement,\n                document_text=document_text,\n                entities=filtered.entities,\n                enable_twitter=state.enable_twitter,\n                enable_reddit=state.enable_reddit\n            )\n            \n            if progress_callback:\n                progress_callback(\n                    \"generating_config\", 70, \n                    \"正在保存配置文件...\",\n                    current=2,\n                    total=3\n                )\n            \n            # 保存配置文件\n            config_path = os.path.join(sim_dir, \"simulation_config.json\")\n            with open(config_path, 'w', encoding='utf-8') as f:\n                f.write(sim_params.to_json())\n            \n            state.config_generated = True\n            state.config_reasoning = sim_params.generation_reasoning\n            \n            if progress_callback:\n                progress_callback(\n                    \"generating_config\", 100, \n                    \"配置生成完成\",\n                    current=3,\n                    total=3\n                )\n            \n            # 注意：运行脚本保留在 backend/scripts/ 目录，不再复制到模拟目录\n            # 启动模拟时，simulation_runner 会从 scripts/ 目录运行脚本\n            \n            # 更新状态\n            state.status = SimulationStatus.READY\n            self._save_simulation_state(state)\n            \n            logger.info(f\"模拟准备完成: {simulation_id}, \"\n                       f\"entities={state.entities_count}, profiles={state.profiles_count}\")\n            \n            return state\n            \n        except Exception as e:\n            logger.error(f\"模拟准备失败: {simulation_id}, error={str(e)}\")\n            import traceback\n            logger.error(traceback.format_exc())\n            state.status = SimulationStatus.FAILED\n            state.error = str(e)\n            self._save_simulation_state(state)\n            raise\n    \n    def get_simulation(self, simulation_id: str) -> Optional[SimulationState]:\n        \"\"\"获取模拟状态\"\"\"\n        return self._load_simulation_state(simulation_id)\n    \n    def list_simulations(self, project_id: Optional[str] = None) -> List[SimulationState]:\n        \"\"\"列出所有模拟\"\"\"\n        simulations = []\n        \n        if os.path.exists(self.SIMULATION_DATA_DIR):\n            for sim_id in os.listdir(self.SIMULATION_DATA_DIR):\n                # 跳过隐藏文件（如 .DS_Store）和非目录文件\n                sim_path = os.path.join(self.SIMULATION_DATA_DIR, sim_id)\n                if sim_id.startswith('.') or not os.path.isdir(sim_path):\n                    continue\n                \n                state = self._load_simulation_state(sim_id)\n                if state:\n                    if project_id is None or state.project_id == project_id:\n                        simulations.append(state)\n        \n        return simulations\n    \n    def get_profiles(self, simulation_id: str, platform: str = \"reddit\") -> List[Dict[str, Any]]:\n        \"\"\"获取模拟的Agent Profile\"\"\"\n        state = self._load_simulation_state(simulation_id)\n        if not state:\n            raise ValueError(f\"模拟不存在: {simulation_id}\")\n        \n        sim_dir = self._get_simulation_dir(simulation_id)\n        profile_path = os.path.join(sim_dir, f\"{platform}_profiles.json\")\n        \n        if not os.path.exists(profile_path):\n            return []\n        \n        with open(profile_path, 'r', encoding='utf-8') as f:\n            return json.load(f)\n    \n    def get_simulation_config(self, simulation_id: str) -> Optional[Dict[str, Any]]:\n        \"\"\"获取模拟配置\"\"\"\n        sim_dir = self._get_simulation_dir(simulation_id)\n        config_path = os.path.join(sim_dir, \"simulation_config.json\")\n        \n        if not os.path.exists(config_path):\n            return None\n        \n        with open(config_path, 'r', encoding='utf-8') as f:\n            return json.load(f)\n    \n    def get_run_instructions(self, simulation_id: str) -> Dict[str, str]:\n        \"\"\"获取运行说明\"\"\"\n        sim_dir = self._get_simulation_dir(simulation_id)\n        config_path = os.path.join(sim_dir, \"simulation_config.json\")\n        scripts_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../scripts'))\n        \n        return {\n            \"simulation_dir\": sim_dir,\n            \"scripts_dir\": scripts_dir,\n            \"config_file\": config_path,\n            \"commands\": {\n                \"twitter\": f\"python {scripts_dir}/run_twitter_simulation.py --config {config_path}\",\n                \"reddit\": f\"python {scripts_dir}/run_reddit_simulation.py --config {config_path}\",\n                \"parallel\": f\"python {scripts_dir}/run_parallel_simulation.py --config {config_path}\",\n            },\n            \"instructions\": (\n                f\"1. 激活conda环境: conda activate MiroFish\\n\"\n                f\"2. 运行模拟 (脚本位于 {scripts_dir}):\\n\"\n                f\"   - 单独运行Twitter: python {scripts_dir}/run_twitter_simulation.py --config {config_path}\\n\"\n                f\"   - 单独运行Reddit: python {scripts_dir}/run_reddit_simulation.py --config {config_path}\\n\"\n                f\"   - 并行运行双平台: python {scripts_dir}/run_parallel_simulation.py --config {config_path}\"\n            )\n        }\n"
  },
  {
    "path": "backend/app/services/simulation_runner.py",
    "content": "\"\"\"\nOASIS模拟运行器\n在后台运行模拟并记录每个Agent的动作，支持实时状态监控\n\"\"\"\n\nimport os\nimport sys\nimport json\nimport time\nimport asyncio\nimport threading\nimport subprocess\nimport signal\nimport atexit\nfrom typing import Dict, Any, List, Optional, Union\nfrom dataclasses import dataclass, field\nfrom datetime import datetime\nfrom enum import Enum\nfrom queue import Queue\n\nfrom ..config import Config\nfrom ..utils.logger import get_logger\nfrom .zep_graph_memory_updater import ZepGraphMemoryManager\nfrom .simulation_ipc import SimulationIPCClient, CommandType, IPCResponse\n\nlogger = get_logger('mirofish.simulation_runner')\n\n# 标记是否已注册清理函数\n_cleanup_registered = False\n\n# 平台检测\nIS_WINDOWS = sys.platform == 'win32'\n\n\nclass RunnerStatus(str, Enum):\n    \"\"\"运行器状态\"\"\"\n    IDLE = \"idle\"\n    STARTING = \"starting\"\n    RUNNING = \"running\"\n    PAUSED = \"paused\"\n    STOPPING = \"stopping\"\n    STOPPED = \"stopped\"\n    COMPLETED = \"completed\"\n    FAILED = \"failed\"\n\n\n@dataclass\nclass AgentAction:\n    \"\"\"Agent动作记录\"\"\"\n    round_num: int\n    timestamp: str\n    platform: str  # twitter / reddit\n    agent_id: int\n    agent_name: str\n    action_type: str  # CREATE_POST, LIKE_POST, etc.\n    action_args: Dict[str, Any] = field(default_factory=dict)\n    result: Optional[str] = None\n    success: bool = True\n    \n    def to_dict(self) -> Dict[str, Any]:\n        return {\n            \"round_num\": self.round_num,\n            \"timestamp\": self.timestamp,\n            \"platform\": self.platform,\n            \"agent_id\": self.agent_id,\n            \"agent_name\": self.agent_name,\n            \"action_type\": self.action_type,\n            \"action_args\": self.action_args,\n            \"result\": self.result,\n            \"success\": self.success,\n        }\n\n\n@dataclass\nclass RoundSummary:\n    \"\"\"每轮摘要\"\"\"\n    round_num: int\n    start_time: str\n    end_time: Optional[str] = None\n    simulated_hour: int = 0\n    twitter_actions: int = 0\n    reddit_actions: int = 0\n    active_agents: List[int] = field(default_factory=list)\n    actions: List[AgentAction] = field(default_factory=list)\n    \n    def to_dict(self) -> Dict[str, Any]:\n        return {\n            \"round_num\": self.round_num,\n            \"start_time\": self.start_time,\n            \"end_time\": self.end_time,\n            \"simulated_hour\": self.simulated_hour,\n            \"twitter_actions\": self.twitter_actions,\n            \"reddit_actions\": self.reddit_actions,\n            \"active_agents\": self.active_agents,\n            \"actions_count\": len(self.actions),\n            \"actions\": [a.to_dict() for a in self.actions],\n        }\n\n\n@dataclass\nclass SimulationRunState:\n    \"\"\"模拟运行状态（实时）\"\"\"\n    simulation_id: str\n    runner_status: RunnerStatus = RunnerStatus.IDLE\n    \n    # 进度信息\n    current_round: int = 0\n    total_rounds: int = 0\n    simulated_hours: int = 0\n    total_simulation_hours: int = 0\n    \n    # 各平台独立轮次和模拟时间（用于双平台并行显示）\n    twitter_current_round: int = 0\n    reddit_current_round: int = 0\n    twitter_simulated_hours: int = 0\n    reddit_simulated_hours: int = 0\n    \n    # 平台状态\n    twitter_running: bool = False\n    reddit_running: bool = False\n    twitter_actions_count: int = 0\n    reddit_actions_count: int = 0\n    \n    # 平台完成状态（通过检测 actions.jsonl 中的 simulation_end 事件）\n    twitter_completed: bool = False\n    reddit_completed: bool = False\n    \n    # 每轮摘要\n    rounds: List[RoundSummary] = field(default_factory=list)\n    \n    # 最近动作（用于前端实时展示）\n    recent_actions: List[AgentAction] = field(default_factory=list)\n    max_recent_actions: int = 50\n    \n    # 时间戳\n    started_at: Optional[str] = None\n    updated_at: str = field(default_factory=lambda: datetime.now().isoformat())\n    completed_at: Optional[str] = None\n    \n    # 错误信息\n    error: Optional[str] = None\n    \n    # 进程ID（用于停止）\n    process_pid: Optional[int] = None\n    \n    def add_action(self, action: AgentAction):\n        \"\"\"添加动作到最近动作列表\"\"\"\n        self.recent_actions.insert(0, action)\n        if len(self.recent_actions) > self.max_recent_actions:\n            self.recent_actions = self.recent_actions[:self.max_recent_actions]\n        \n        if action.platform == \"twitter\":\n            self.twitter_actions_count += 1\n        else:\n            self.reddit_actions_count += 1\n        \n        self.updated_at = datetime.now().isoformat()\n    \n    def to_dict(self) -> Dict[str, Any]:\n        return {\n            \"simulation_id\": self.simulation_id,\n            \"runner_status\": self.runner_status.value,\n            \"current_round\": self.current_round,\n            \"total_rounds\": self.total_rounds,\n            \"simulated_hours\": self.simulated_hours,\n            \"total_simulation_hours\": self.total_simulation_hours,\n            \"progress_percent\": round(self.current_round / max(self.total_rounds, 1) * 100, 1),\n            # 各平台独立轮次和时间\n            \"twitter_current_round\": self.twitter_current_round,\n            \"reddit_current_round\": self.reddit_current_round,\n            \"twitter_simulated_hours\": self.twitter_simulated_hours,\n            \"reddit_simulated_hours\": self.reddit_simulated_hours,\n            \"twitter_running\": self.twitter_running,\n            \"reddit_running\": self.reddit_running,\n            \"twitter_completed\": self.twitter_completed,\n            \"reddit_completed\": self.reddit_completed,\n            \"twitter_actions_count\": self.twitter_actions_count,\n            \"reddit_actions_count\": self.reddit_actions_count,\n            \"total_actions_count\": self.twitter_actions_count + self.reddit_actions_count,\n            \"started_at\": self.started_at,\n            \"updated_at\": self.updated_at,\n            \"completed_at\": self.completed_at,\n            \"error\": self.error,\n            \"process_pid\": self.process_pid,\n        }\n    \n    def to_detail_dict(self) -> Dict[str, Any]:\n        \"\"\"包含最近动作的详细信息\"\"\"\n        result = self.to_dict()\n        result[\"recent_actions\"] = [a.to_dict() for a in self.recent_actions]\n        result[\"rounds_count\"] = len(self.rounds)\n        return result\n\n\nclass SimulationRunner:\n    \"\"\"\n    模拟运行器\n    \n    负责：\n    1. 在后台进程中运行OASIS模拟\n    2. 解析运行日志，记录每个Agent的动作\n    3. 提供实时状态查询接口\n    4. 支持暂停/停止/恢复操作\n    \"\"\"\n    \n    # 运行状态存储目录\n    RUN_STATE_DIR = os.path.join(\n        os.path.dirname(__file__),\n        '../../uploads/simulations'\n    )\n    \n    # 脚本目录\n    SCRIPTS_DIR = os.path.join(\n        os.path.dirname(__file__),\n        '../../scripts'\n    )\n    \n    # 内存中的运行状态\n    _run_states: Dict[str, SimulationRunState] = {}\n    _processes: Dict[str, subprocess.Popen] = {}\n    _action_queues: Dict[str, Queue] = {}\n    _monitor_threads: Dict[str, threading.Thread] = {}\n    _stdout_files: Dict[str, Any] = {}  # 存储 stdout 文件句柄\n    _stderr_files: Dict[str, Any] = {}  # 存储 stderr 文件句柄\n    \n    # 图谱记忆更新配置\n    _graph_memory_enabled: Dict[str, bool] = {}  # simulation_id -> enabled\n    \n    @classmethod\n    def get_run_state(cls, simulation_id: str) -> Optional[SimulationRunState]:\n        \"\"\"获取运行状态\"\"\"\n        if simulation_id in cls._run_states:\n            return cls._run_states[simulation_id]\n        \n        # 尝试从文件加载\n        state = cls._load_run_state(simulation_id)\n        if state:\n            cls._run_states[simulation_id] = state\n        return state\n    \n    @classmethod\n    def _load_run_state(cls, simulation_id: str) -> Optional[SimulationRunState]:\n        \"\"\"从文件加载运行状态\"\"\"\n        state_file = os.path.join(cls.RUN_STATE_DIR, simulation_id, \"run_state.json\")\n        if not os.path.exists(state_file):\n            return None\n        \n        try:\n            with open(state_file, 'r', encoding='utf-8') as f:\n                data = json.load(f)\n            \n            state = SimulationRunState(\n                simulation_id=simulation_id,\n                runner_status=RunnerStatus(data.get(\"runner_status\", \"idle\")),\n                current_round=data.get(\"current_round\", 0),\n                total_rounds=data.get(\"total_rounds\", 0),\n                simulated_hours=data.get(\"simulated_hours\", 0),\n                total_simulation_hours=data.get(\"total_simulation_hours\", 0),\n                # 各平台独立轮次和时间\n                twitter_current_round=data.get(\"twitter_current_round\", 0),\n                reddit_current_round=data.get(\"reddit_current_round\", 0),\n                twitter_simulated_hours=data.get(\"twitter_simulated_hours\", 0),\n                reddit_simulated_hours=data.get(\"reddit_simulated_hours\", 0),\n                twitter_running=data.get(\"twitter_running\", False),\n                reddit_running=data.get(\"reddit_running\", False),\n                twitter_completed=data.get(\"twitter_completed\", False),\n                reddit_completed=data.get(\"reddit_completed\", False),\n                twitter_actions_count=data.get(\"twitter_actions_count\", 0),\n                reddit_actions_count=data.get(\"reddit_actions_count\", 0),\n                started_at=data.get(\"started_at\"),\n                updated_at=data.get(\"updated_at\", datetime.now().isoformat()),\n                completed_at=data.get(\"completed_at\"),\n                error=data.get(\"error\"),\n                process_pid=data.get(\"process_pid\"),\n            )\n            \n            # 加载最近动作\n            actions_data = data.get(\"recent_actions\", [])\n            for a in actions_data:\n                state.recent_actions.append(AgentAction(\n                    round_num=a.get(\"round_num\", 0),\n                    timestamp=a.get(\"timestamp\", \"\"),\n                    platform=a.get(\"platform\", \"\"),\n                    agent_id=a.get(\"agent_id\", 0),\n                    agent_name=a.get(\"agent_name\", \"\"),\n                    action_type=a.get(\"action_type\", \"\"),\n                    action_args=a.get(\"action_args\", {}),\n                    result=a.get(\"result\"),\n                    success=a.get(\"success\", True),\n                ))\n            \n            return state\n        except Exception as e:\n            logger.error(f\"加载运行状态失败: {str(e)}\")\n            return None\n    \n    @classmethod\n    def _save_run_state(cls, state: SimulationRunState):\n        \"\"\"保存运行状态到文件\"\"\"\n        sim_dir = os.path.join(cls.RUN_STATE_DIR, state.simulation_id)\n        os.makedirs(sim_dir, exist_ok=True)\n        state_file = os.path.join(sim_dir, \"run_state.json\")\n        \n        data = state.to_detail_dict()\n        \n        with open(state_file, 'w', encoding='utf-8') as f:\n            json.dump(data, f, ensure_ascii=False, indent=2)\n        \n        cls._run_states[state.simulation_id] = state\n    \n    @classmethod\n    def start_simulation(\n        cls,\n        simulation_id: str,\n        platform: str = \"parallel\",  # twitter / reddit / parallel\n        max_rounds: int = None,  # 最大模拟轮数（可选，用于截断过长的模拟）\n        enable_graph_memory_update: bool = False,  # 是否将活动更新到Zep图谱\n        graph_id: str = None  # Zep图谱ID（启用图谱更新时必需）\n    ) -> SimulationRunState:\n        \"\"\"\n        启动模拟\n        \n        Args:\n            simulation_id: 模拟ID\n            platform: 运行平台 (twitter/reddit/parallel)\n            max_rounds: 最大模拟轮数（可选，用于截断过长的模拟）\n            enable_graph_memory_update: 是否将Agent活动动态更新到Zep图谱\n            graph_id: Zep图谱ID（启用图谱更新时必需）\n            \n        Returns:\n            SimulationRunState\n        \"\"\"\n        # 检查是否已在运行\n        existing = cls.get_run_state(simulation_id)\n        if existing and existing.runner_status in [RunnerStatus.RUNNING, RunnerStatus.STARTING]:\n            raise ValueError(f\"模拟已在运行中: {simulation_id}\")\n        \n        # 加载模拟配置\n        sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id)\n        config_path = os.path.join(sim_dir, \"simulation_config.json\")\n        \n        if not os.path.exists(config_path):\n            raise ValueError(f\"模拟配置不存在，请先调用 /prepare 接口\")\n        \n        with open(config_path, 'r', encoding='utf-8') as f:\n            config = json.load(f)\n        \n        # 初始化运行状态\n        time_config = config.get(\"time_config\", {})\n        total_hours = time_config.get(\"total_simulation_hours\", 72)\n        minutes_per_round = time_config.get(\"minutes_per_round\", 30)\n        total_rounds = int(total_hours * 60 / minutes_per_round)\n        \n        # 如果指定了最大轮数，则截断\n        if max_rounds is not None and max_rounds > 0:\n            original_rounds = total_rounds\n            total_rounds = min(total_rounds, max_rounds)\n            if total_rounds < original_rounds:\n                logger.info(f\"轮数已截断: {original_rounds} -> {total_rounds} (max_rounds={max_rounds})\")\n        \n        state = SimulationRunState(\n            simulation_id=simulation_id,\n            runner_status=RunnerStatus.STARTING,\n            total_rounds=total_rounds,\n            total_simulation_hours=total_hours,\n            started_at=datetime.now().isoformat(),\n        )\n        \n        cls._save_run_state(state)\n        \n        # 如果启用图谱记忆更新，创建更新器\n        if enable_graph_memory_update:\n            if not graph_id:\n                raise ValueError(\"启用图谱记忆更新时必须提供 graph_id\")\n            \n            try:\n                ZepGraphMemoryManager.create_updater(simulation_id, graph_id)\n                cls._graph_memory_enabled[simulation_id] = True\n                logger.info(f\"已启用图谱记忆更新: simulation_id={simulation_id}, graph_id={graph_id}\")\n            except Exception as e:\n                logger.error(f\"创建图谱记忆更新器失败: {e}\")\n                cls._graph_memory_enabled[simulation_id] = False\n        else:\n            cls._graph_memory_enabled[simulation_id] = False\n        \n        # 确定运行哪个脚本（脚本位于 backend/scripts/ 目录）\n        if platform == \"twitter\":\n            script_name = \"run_twitter_simulation.py\"\n            state.twitter_running = True\n        elif platform == \"reddit\":\n            script_name = \"run_reddit_simulation.py\"\n            state.reddit_running = True\n        else:\n            script_name = \"run_parallel_simulation.py\"\n            state.twitter_running = True\n            state.reddit_running = True\n        \n        script_path = os.path.join(cls.SCRIPTS_DIR, script_name)\n        \n        if not os.path.exists(script_path):\n            raise ValueError(f\"脚本不存在: {script_path}\")\n        \n        # 创建动作队列\n        action_queue = Queue()\n        cls._action_queues[simulation_id] = action_queue\n        \n        # 启动模拟进程\n        try:\n            # 构建运行命令，使用完整路径\n            # 新的日志结构：\n            #   twitter/actions.jsonl - Twitter 动作日志\n            #   reddit/actions.jsonl  - Reddit 动作日志\n            #   simulation.log        - 主进程日志\n            \n            cmd = [\n                sys.executable,  # Python解释器\n                script_path,\n                \"--config\", config_path,  # 使用完整配置文件路径\n            ]\n            \n            # 如果指定了最大轮数，添加到命令行参数\n            if max_rounds is not None and max_rounds > 0:\n                cmd.extend([\"--max-rounds\", str(max_rounds)])\n            \n            # 创建主日志文件，避免 stdout/stderr 管道缓冲区满导致进程阻塞\n            main_log_path = os.path.join(sim_dir, \"simulation.log\")\n            main_log_file = open(main_log_path, 'w', encoding='utf-8')\n            \n            # 设置子进程环境变量，确保 Windows 上使用 UTF-8 编码\n            # 这可以修复第三方库（如 OASIS）读取文件时未指定编码的问题\n            env = os.environ.copy()\n            env['PYTHONUTF8'] = '1'  # Python 3.7+ 支持，让所有 open() 默认使用 UTF-8\n            env['PYTHONIOENCODING'] = 'utf-8'  # 确保 stdout/stderr 使用 UTF-8\n            \n            # 设置工作目录为模拟目录（数据库等文件会生成在此）\n            # 使用 start_new_session=True 创建新的进程组，确保可以通过 os.killpg 终止所有子进程\n            process = subprocess.Popen(\n                cmd,\n                cwd=sim_dir,\n                stdout=main_log_file,\n                stderr=subprocess.STDOUT,  # stderr 也写入同一个文件\n                text=True,\n                encoding='utf-8',  # 显式指定编码\n                bufsize=1,\n                env=env,  # 传递带有 UTF-8 设置的环境变量\n                start_new_session=True,  # 创建新进程组，确保服务器关闭时能终止所有相关进程\n            )\n            \n            # 保存文件句柄以便后续关闭\n            cls._stdout_files[simulation_id] = main_log_file\n            cls._stderr_files[simulation_id] = None  # 不再需要单独的 stderr\n            \n            state.process_pid = process.pid\n            state.runner_status = RunnerStatus.RUNNING\n            cls._processes[simulation_id] = process\n            cls._save_run_state(state)\n            \n            # 启动监控线程\n            monitor_thread = threading.Thread(\n                target=cls._monitor_simulation,\n                args=(simulation_id,),\n                daemon=True\n            )\n            monitor_thread.start()\n            cls._monitor_threads[simulation_id] = monitor_thread\n            \n            logger.info(f\"模拟启动成功: {simulation_id}, pid={process.pid}, platform={platform}\")\n            \n        except Exception as e:\n            state.runner_status = RunnerStatus.FAILED\n            state.error = str(e)\n            cls._save_run_state(state)\n            raise\n        \n        return state\n    \n    @classmethod\n    def _monitor_simulation(cls, simulation_id: str):\n        \"\"\"监控模拟进程，解析动作日志\"\"\"\n        sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id)\n        \n        # 新的日志结构：分平台的动作日志\n        twitter_actions_log = os.path.join(sim_dir, \"twitter\", \"actions.jsonl\")\n        reddit_actions_log = os.path.join(sim_dir, \"reddit\", \"actions.jsonl\")\n        \n        process = cls._processes.get(simulation_id)\n        state = cls.get_run_state(simulation_id)\n        \n        if not process or not state:\n            return\n        \n        twitter_position = 0\n        reddit_position = 0\n        \n        try:\n            while process.poll() is None:  # 进程仍在运行\n                # 读取 Twitter 动作日志\n                if os.path.exists(twitter_actions_log):\n                    twitter_position = cls._read_action_log(\n                        twitter_actions_log, twitter_position, state, \"twitter\"\n                    )\n                \n                # 读取 Reddit 动作日志\n                if os.path.exists(reddit_actions_log):\n                    reddit_position = cls._read_action_log(\n                        reddit_actions_log, reddit_position, state, \"reddit\"\n                    )\n                \n                # 更新状态\n                cls._save_run_state(state)\n                time.sleep(2)\n            \n            # 进程结束后，最后读取一次日志\n            if os.path.exists(twitter_actions_log):\n                cls._read_action_log(twitter_actions_log, twitter_position, state, \"twitter\")\n            if os.path.exists(reddit_actions_log):\n                cls._read_action_log(reddit_actions_log, reddit_position, state, \"reddit\")\n            \n            # 进程结束\n            exit_code = process.returncode\n            \n            if exit_code == 0:\n                state.runner_status = RunnerStatus.COMPLETED\n                state.completed_at = datetime.now().isoformat()\n                logger.info(f\"模拟完成: {simulation_id}\")\n            else:\n                state.runner_status = RunnerStatus.FAILED\n                # 从主日志文件读取错误信息\n                main_log_path = os.path.join(sim_dir, \"simulation.log\")\n                error_info = \"\"\n                try:\n                    if os.path.exists(main_log_path):\n                        with open(main_log_path, 'r', encoding='utf-8') as f:\n                            error_info = f.read()[-2000:]  # 取最后2000字符\n                except Exception:\n                    pass\n                state.error = f\"进程退出码: {exit_code}, 错误: {error_info}\"\n                logger.error(f\"模拟失败: {simulation_id}, error={state.error}\")\n            \n            state.twitter_running = False\n            state.reddit_running = False\n            cls._save_run_state(state)\n            \n        except Exception as e:\n            logger.error(f\"监控线程异常: {simulation_id}, error={str(e)}\")\n            state.runner_status = RunnerStatus.FAILED\n            state.error = str(e)\n            cls._save_run_state(state)\n        \n        finally:\n            # 停止图谱记忆更新器\n            if cls._graph_memory_enabled.get(simulation_id, False):\n                try:\n                    ZepGraphMemoryManager.stop_updater(simulation_id)\n                    logger.info(f\"已停止图谱记忆更新: simulation_id={simulation_id}\")\n                except Exception as e:\n                    logger.error(f\"停止图谱记忆更新器失败: {e}\")\n                cls._graph_memory_enabled.pop(simulation_id, None)\n            \n            # 清理进程资源\n            cls._processes.pop(simulation_id, None)\n            cls._action_queues.pop(simulation_id, None)\n            \n            # 关闭日志文件句柄\n            if simulation_id in cls._stdout_files:\n                try:\n                    cls._stdout_files[simulation_id].close()\n                except Exception:\n                    pass\n                cls._stdout_files.pop(simulation_id, None)\n            if simulation_id in cls._stderr_files and cls._stderr_files[simulation_id]:\n                try:\n                    cls._stderr_files[simulation_id].close()\n                except Exception:\n                    pass\n                cls._stderr_files.pop(simulation_id, None)\n    \n    @classmethod\n    def _read_action_log(\n        cls, \n        log_path: str, \n        position: int, \n        state: SimulationRunState,\n        platform: str\n    ) -> int:\n        \"\"\"\n        读取动作日志文件\n        \n        Args:\n            log_path: 日志文件路径\n            position: 上次读取位置\n            state: 运行状态对象\n            platform: 平台名称 (twitter/reddit)\n            \n        Returns:\n            新的读取位置\n        \"\"\"\n        # 检查是否启用了图谱记忆更新\n        graph_memory_enabled = cls._graph_memory_enabled.get(state.simulation_id, False)\n        graph_updater = None\n        if graph_memory_enabled:\n            graph_updater = ZepGraphMemoryManager.get_updater(state.simulation_id)\n        \n        try:\n            with open(log_path, 'r', encoding='utf-8') as f:\n                f.seek(position)\n                for line in f:\n                    line = line.strip()\n                    if line:\n                        try:\n                            action_data = json.loads(line)\n                            \n                            # 处理事件类型的条目\n                            if \"event_type\" in action_data:\n                                event_type = action_data.get(\"event_type\")\n                                \n                                # 检测 simulation_end 事件，标记平台已完成\n                                if event_type == \"simulation_end\":\n                                    if platform == \"twitter\":\n                                        state.twitter_completed = True\n                                        state.twitter_running = False\n                                        logger.info(f\"Twitter 模拟已完成: {state.simulation_id}, total_rounds={action_data.get('total_rounds')}, total_actions={action_data.get('total_actions')}\")\n                                    elif platform == \"reddit\":\n                                        state.reddit_completed = True\n                                        state.reddit_running = False\n                                        logger.info(f\"Reddit 模拟已完成: {state.simulation_id}, total_rounds={action_data.get('total_rounds')}, total_actions={action_data.get('total_actions')}\")\n                                    \n                                    # 检查是否所有启用的平台都已完成\n                                    # 如果只运行了一个平台，只检查那个平台\n                                    # 如果运行了两个平台，需要两个都完成\n                                    all_completed = cls._check_all_platforms_completed(state)\n                                    if all_completed:\n                                        state.runner_status = RunnerStatus.COMPLETED\n                                        state.completed_at = datetime.now().isoformat()\n                                        logger.info(f\"所有平台模拟已完成: {state.simulation_id}\")\n                                \n                                # 更新轮次信息（从 round_end 事件）\n                                elif event_type == \"round_end\":\n                                    round_num = action_data.get(\"round\", 0)\n                                    simulated_hours = action_data.get(\"simulated_hours\", 0)\n                                    \n                                    # 更新各平台独立的轮次和时间\n                                    if platform == \"twitter\":\n                                        if round_num > state.twitter_current_round:\n                                            state.twitter_current_round = round_num\n                                        state.twitter_simulated_hours = simulated_hours\n                                    elif platform == \"reddit\":\n                                        if round_num > state.reddit_current_round:\n                                            state.reddit_current_round = round_num\n                                        state.reddit_simulated_hours = simulated_hours\n                                    \n                                    # 总体轮次取两个平台的最大值\n                                    if round_num > state.current_round:\n                                        state.current_round = round_num\n                                    # 总体时间取两个平台的最大值\n                                    state.simulated_hours = max(state.twitter_simulated_hours, state.reddit_simulated_hours)\n                                \n                                continue\n                            \n                            action = AgentAction(\n                                round_num=action_data.get(\"round\", 0),\n                                timestamp=action_data.get(\"timestamp\", datetime.now().isoformat()),\n                                platform=platform,\n                                agent_id=action_data.get(\"agent_id\", 0),\n                                agent_name=action_data.get(\"agent_name\", \"\"),\n                                action_type=action_data.get(\"action_type\", \"\"),\n                                action_args=action_data.get(\"action_args\", {}),\n                                result=action_data.get(\"result\"),\n                                success=action_data.get(\"success\", True),\n                            )\n                            state.add_action(action)\n                            \n                            # 更新轮次\n                            if action.round_num and action.round_num > state.current_round:\n                                state.current_round = action.round_num\n                            \n                            # 如果启用了图谱记忆更新，将活动发送到Zep\n                            if graph_updater:\n                                graph_updater.add_activity_from_dict(action_data, platform)\n                            \n                        except json.JSONDecodeError:\n                            pass\n                return f.tell()\n        except Exception as e:\n            logger.warning(f\"读取动作日志失败: {log_path}, error={e}\")\n            return position\n    \n    @classmethod\n    def _check_all_platforms_completed(cls, state: SimulationRunState) -> bool:\n        \"\"\"\n        检查所有启用的平台是否都已完成模拟\n        \n        通过检查对应的 actions.jsonl 文件是否存在来判断平台是否被启用\n        \n        Returns:\n            True 如果所有启用的平台都已完成\n        \"\"\"\n        sim_dir = os.path.join(cls.RUN_STATE_DIR, state.simulation_id)\n        twitter_log = os.path.join(sim_dir, \"twitter\", \"actions.jsonl\")\n        reddit_log = os.path.join(sim_dir, \"reddit\", \"actions.jsonl\")\n        \n        # 检查哪些平台被启用（通过文件是否存在判断）\n        twitter_enabled = os.path.exists(twitter_log)\n        reddit_enabled = os.path.exists(reddit_log)\n        \n        # 如果平台被启用但未完成，则返回 False\n        if twitter_enabled and not state.twitter_completed:\n            return False\n        if reddit_enabled and not state.reddit_completed:\n            return False\n        \n        # 至少有一个平台被启用且已完成\n        return twitter_enabled or reddit_enabled\n    \n    @classmethod\n    def _terminate_process(cls, process: subprocess.Popen, simulation_id: str, timeout: int = 10):\n        \"\"\"\n        跨平台终止进程及其子进程\n        \n        Args:\n            process: 要终止的进程\n            simulation_id: 模拟ID（用于日志）\n            timeout: 等待进程退出的超时时间（秒）\n        \"\"\"\n        if IS_WINDOWS:\n            # Windows: 使用 taskkill 命令终止进程树\n            # /F = 强制终止, /T = 终止进程树（包括子进程）\n            logger.info(f\"终止进程树 (Windows): simulation={simulation_id}, pid={process.pid}\")\n            try:\n                # 先尝试优雅终止\n                subprocess.run(\n                    ['taskkill', '/PID', str(process.pid), '/T'],\n                    capture_output=True,\n                    timeout=5\n                )\n                try:\n                    process.wait(timeout=timeout)\n                except subprocess.TimeoutExpired:\n                    # 强制终止\n                    logger.warning(f\"进程未响应，强制终止: {simulation_id}\")\n                    subprocess.run(\n                        ['taskkill', '/F', '/PID', str(process.pid), '/T'],\n                        capture_output=True,\n                        timeout=5\n                    )\n                    process.wait(timeout=5)\n            except Exception as e:\n                logger.warning(f\"taskkill 失败，尝试 terminate: {e}\")\n                process.terminate()\n                try:\n                    process.wait(timeout=5)\n                except subprocess.TimeoutExpired:\n                    process.kill()\n        else:\n            # Unix: 使用进程组终止\n            # 由于使用了 start_new_session=True，进程组 ID 等于主进程 PID\n            pgid = os.getpgid(process.pid)\n            logger.info(f\"终止进程组 (Unix): simulation={simulation_id}, pgid={pgid}\")\n            \n            # 先发送 SIGTERM 给整个进程组\n            os.killpg(pgid, signal.SIGTERM)\n            \n            try:\n                process.wait(timeout=timeout)\n            except subprocess.TimeoutExpired:\n                # 如果超时后还没结束，强制发送 SIGKILL\n                logger.warning(f\"进程组未响应 SIGTERM，强制终止: {simulation_id}\")\n                os.killpg(pgid, signal.SIGKILL)\n                process.wait(timeout=5)\n    \n    @classmethod\n    def stop_simulation(cls, simulation_id: str) -> SimulationRunState:\n        \"\"\"停止模拟\"\"\"\n        state = cls.get_run_state(simulation_id)\n        if not state:\n            raise ValueError(f\"模拟不存在: {simulation_id}\")\n        \n        if state.runner_status not in [RunnerStatus.RUNNING, RunnerStatus.PAUSED]:\n            raise ValueError(f\"模拟未在运行: {simulation_id}, status={state.runner_status}\")\n        \n        state.runner_status = RunnerStatus.STOPPING\n        cls._save_run_state(state)\n        \n        # 终止进程\n        process = cls._processes.get(simulation_id)\n        if process and process.poll() is None:\n            try:\n                cls._terminate_process(process, simulation_id)\n            except ProcessLookupError:\n                # 进程已经不存在\n                pass\n            except Exception as e:\n                logger.error(f\"终止进程组失败: {simulation_id}, error={e}\")\n                # 回退到直接终止进程\n                try:\n                    process.terminate()\n                    process.wait(timeout=5)\n                except Exception:\n                    process.kill()\n        \n        state.runner_status = RunnerStatus.STOPPED\n        state.twitter_running = False\n        state.reddit_running = False\n        state.completed_at = datetime.now().isoformat()\n        cls._save_run_state(state)\n        \n        # 停止图谱记忆更新器\n        if cls._graph_memory_enabled.get(simulation_id, False):\n            try:\n                ZepGraphMemoryManager.stop_updater(simulation_id)\n                logger.info(f\"已停止图谱记忆更新: simulation_id={simulation_id}\")\n            except Exception as e:\n                logger.error(f\"停止图谱记忆更新器失败: {e}\")\n            cls._graph_memory_enabled.pop(simulation_id, None)\n        \n        logger.info(f\"模拟已停止: {simulation_id}\")\n        return state\n    \n    @classmethod\n    def _read_actions_from_file(\n        cls,\n        file_path: str,\n        default_platform: Optional[str] = None,\n        platform_filter: Optional[str] = None,\n        agent_id: Optional[int] = None,\n        round_num: Optional[int] = None\n    ) -> List[AgentAction]:\n        \"\"\"\n        从单个动作文件中读取动作\n        \n        Args:\n            file_path: 动作日志文件路径\n            default_platform: 默认平台（当动作记录中没有 platform 字段时使用）\n            platform_filter: 过滤平台\n            agent_id: 过滤 Agent ID\n            round_num: 过滤轮次\n        \"\"\"\n        if not os.path.exists(file_path):\n            return []\n        \n        actions = []\n        \n        with open(file_path, 'r', encoding='utf-8') as f:\n            for line in f:\n                line = line.strip()\n                if not line:\n                    continue\n                \n                try:\n                    data = json.loads(line)\n                    \n                    # 跳过非动作记录（如 simulation_start, round_start, round_end 等事件）\n                    if \"event_type\" in data:\n                        continue\n                    \n                    # 跳过没有 agent_id 的记录（非 Agent 动作）\n                    if \"agent_id\" not in data:\n                        continue\n                    \n                    # 获取平台：优先使用记录中的 platform，否则使用默认平台\n                    record_platform = data.get(\"platform\") or default_platform or \"\"\n                    \n                    # 过滤\n                    if platform_filter and record_platform != platform_filter:\n                        continue\n                    if agent_id is not None and data.get(\"agent_id\") != agent_id:\n                        continue\n                    if round_num is not None and data.get(\"round\") != round_num:\n                        continue\n                    \n                    actions.append(AgentAction(\n                        round_num=data.get(\"round\", 0),\n                        timestamp=data.get(\"timestamp\", \"\"),\n                        platform=record_platform,\n                        agent_id=data.get(\"agent_id\", 0),\n                        agent_name=data.get(\"agent_name\", \"\"),\n                        action_type=data.get(\"action_type\", \"\"),\n                        action_args=data.get(\"action_args\", {}),\n                        result=data.get(\"result\"),\n                        success=data.get(\"success\", True),\n                    ))\n                    \n                except json.JSONDecodeError:\n                    continue\n        \n        return actions\n    \n    @classmethod\n    def get_all_actions(\n        cls,\n        simulation_id: str,\n        platform: Optional[str] = None,\n        agent_id: Optional[int] = None,\n        round_num: Optional[int] = None\n    ) -> List[AgentAction]:\n        \"\"\"\n        获取所有平台的完整动作历史（无分页限制）\n        \n        Args:\n            simulation_id: 模拟ID\n            platform: 过滤平台（twitter/reddit）\n            agent_id: 过滤Agent\n            round_num: 过滤轮次\n            \n        Returns:\n            完整的动作列表（按时间戳排序，新的在前）\n        \"\"\"\n        sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id)\n        actions = []\n        \n        # 读取 Twitter 动作文件（根据文件路径自动设置 platform 为 twitter）\n        twitter_actions_log = os.path.join(sim_dir, \"twitter\", \"actions.jsonl\")\n        if not platform or platform == \"twitter\":\n            actions.extend(cls._read_actions_from_file(\n                twitter_actions_log,\n                default_platform=\"twitter\",  # 自动填充 platform 字段\n                platform_filter=platform,\n                agent_id=agent_id, \n                round_num=round_num\n            ))\n        \n        # 读取 Reddit 动作文件（根据文件路径自动设置 platform 为 reddit）\n        reddit_actions_log = os.path.join(sim_dir, \"reddit\", \"actions.jsonl\")\n        if not platform or platform == \"reddit\":\n            actions.extend(cls._read_actions_from_file(\n                reddit_actions_log,\n                default_platform=\"reddit\",  # 自动填充 platform 字段\n                platform_filter=platform,\n                agent_id=agent_id,\n                round_num=round_num\n            ))\n        \n        # 如果分平台文件不存在，尝试读取旧的单一文件格式\n        if not actions:\n            actions_log = os.path.join(sim_dir, \"actions.jsonl\")\n            actions = cls._read_actions_from_file(\n                actions_log,\n                default_platform=None,  # 旧格式文件中应该有 platform 字段\n                platform_filter=platform,\n                agent_id=agent_id,\n                round_num=round_num\n            )\n        \n        # 按时间戳排序（新的在前）\n        actions.sort(key=lambda x: x.timestamp, reverse=True)\n        \n        return actions\n    \n    @classmethod\n    def get_actions(\n        cls,\n        simulation_id: str,\n        limit: int = 100,\n        offset: int = 0,\n        platform: Optional[str] = None,\n        agent_id: Optional[int] = None,\n        round_num: Optional[int] = None\n    ) -> List[AgentAction]:\n        \"\"\"\n        获取动作历史（带分页）\n        \n        Args:\n            simulation_id: 模拟ID\n            limit: 返回数量限制\n            offset: 偏移量\n            platform: 过滤平台\n            agent_id: 过滤Agent\n            round_num: 过滤轮次\n            \n        Returns:\n            动作列表\n        \"\"\"\n        actions = cls.get_all_actions(\n            simulation_id=simulation_id,\n            platform=platform,\n            agent_id=agent_id,\n            round_num=round_num\n        )\n        \n        # 分页\n        return actions[offset:offset + limit]\n    \n    @classmethod\n    def get_timeline(\n        cls,\n        simulation_id: str,\n        start_round: int = 0,\n        end_round: Optional[int] = None\n    ) -> List[Dict[str, Any]]:\n        \"\"\"\n        获取模拟时间线（按轮次汇总）\n        \n        Args:\n            simulation_id: 模拟ID\n            start_round: 起始轮次\n            end_round: 结束轮次\n            \n        Returns:\n            每轮的汇总信息\n        \"\"\"\n        actions = cls.get_actions(simulation_id, limit=10000)\n        \n        # 按轮次分组\n        rounds: Dict[int, Dict[str, Any]] = {}\n        \n        for action in actions:\n            round_num = action.round_num\n            \n            if round_num < start_round:\n                continue\n            if end_round is not None and round_num > end_round:\n                continue\n            \n            if round_num not in rounds:\n                rounds[round_num] = {\n                    \"round_num\": round_num,\n                    \"twitter_actions\": 0,\n                    \"reddit_actions\": 0,\n                    \"active_agents\": set(),\n                    \"action_types\": {},\n                    \"first_action_time\": action.timestamp,\n                    \"last_action_time\": action.timestamp,\n                }\n            \n            r = rounds[round_num]\n            \n            if action.platform == \"twitter\":\n                r[\"twitter_actions\"] += 1\n            else:\n                r[\"reddit_actions\"] += 1\n            \n            r[\"active_agents\"].add(action.agent_id)\n            r[\"action_types\"][action.action_type] = r[\"action_types\"].get(action.action_type, 0) + 1\n            r[\"last_action_time\"] = action.timestamp\n        \n        # 转换为列表\n        result = []\n        for round_num in sorted(rounds.keys()):\n            r = rounds[round_num]\n            result.append({\n                \"round_num\": round_num,\n                \"twitter_actions\": r[\"twitter_actions\"],\n                \"reddit_actions\": r[\"reddit_actions\"],\n                \"total_actions\": r[\"twitter_actions\"] + r[\"reddit_actions\"],\n                \"active_agents_count\": len(r[\"active_agents\"]),\n                \"active_agents\": list(r[\"active_agents\"]),\n                \"action_types\": r[\"action_types\"],\n                \"first_action_time\": r[\"first_action_time\"],\n                \"last_action_time\": r[\"last_action_time\"],\n            })\n        \n        return result\n    \n    @classmethod\n    def get_agent_stats(cls, simulation_id: str) -> List[Dict[str, Any]]:\n        \"\"\"\n        获取每个Agent的统计信息\n        \n        Returns:\n            Agent统计列表\n        \"\"\"\n        actions = cls.get_actions(simulation_id, limit=10000)\n        \n        agent_stats: Dict[int, Dict[str, Any]] = {}\n        \n        for action in actions:\n            agent_id = action.agent_id\n            \n            if agent_id not in agent_stats:\n                agent_stats[agent_id] = {\n                    \"agent_id\": agent_id,\n                    \"agent_name\": action.agent_name,\n                    \"total_actions\": 0,\n                    \"twitter_actions\": 0,\n                    \"reddit_actions\": 0,\n                    \"action_types\": {},\n                    \"first_action_time\": action.timestamp,\n                    \"last_action_time\": action.timestamp,\n                }\n            \n            stats = agent_stats[agent_id]\n            stats[\"total_actions\"] += 1\n            \n            if action.platform == \"twitter\":\n                stats[\"twitter_actions\"] += 1\n            else:\n                stats[\"reddit_actions\"] += 1\n            \n            stats[\"action_types\"][action.action_type] = stats[\"action_types\"].get(action.action_type, 0) + 1\n            stats[\"last_action_time\"] = action.timestamp\n        \n        # 按总动作数排序\n        result = sorted(agent_stats.values(), key=lambda x: x[\"total_actions\"], reverse=True)\n        \n        return result\n    \n    @classmethod\n    def cleanup_simulation_logs(cls, simulation_id: str) -> Dict[str, Any]:\n        \"\"\"\n        清理模拟的运行日志（用于强制重新开始模拟）\n        \n        会删除以下文件：\n        - run_state.json\n        - twitter/actions.jsonl\n        - reddit/actions.jsonl\n        - simulation.log\n        - stdout.log / stderr.log\n        - twitter_simulation.db（模拟数据库）\n        - reddit_simulation.db（模拟数据库）\n        - env_status.json（环境状态）\n        \n        注意：不会删除配置文件（simulation_config.json）和 profile 文件\n        \n        Args:\n            simulation_id: 模拟ID\n            \n        Returns:\n            清理结果信息\n        \"\"\"\n        import shutil\n        \n        sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id)\n        \n        if not os.path.exists(sim_dir):\n            return {\"success\": True, \"message\": \"模拟目录不存在，无需清理\"}\n        \n        cleaned_files = []\n        errors = []\n        \n        # 要删除的文件列表（包括数据库文件）\n        files_to_delete = [\n            \"run_state.json\",\n            \"simulation.log\",\n            \"stdout.log\",\n            \"stderr.log\",\n            \"twitter_simulation.db\",  # Twitter 平台数据库\n            \"reddit_simulation.db\",   # Reddit 平台数据库\n            \"env_status.json\",        # 环境状态文件\n        ]\n        \n        # 要删除的目录列表（包含动作日志）\n        dirs_to_clean = [\"twitter\", \"reddit\"]\n        \n        # 删除文件\n        for filename in files_to_delete:\n            file_path = os.path.join(sim_dir, filename)\n            if os.path.exists(file_path):\n                try:\n                    os.remove(file_path)\n                    cleaned_files.append(filename)\n                except Exception as e:\n                    errors.append(f\"删除 {filename} 失败: {str(e)}\")\n        \n        # 清理平台目录中的动作日志\n        for dir_name in dirs_to_clean:\n            dir_path = os.path.join(sim_dir, dir_name)\n            if os.path.exists(dir_path):\n                actions_file = os.path.join(dir_path, \"actions.jsonl\")\n                if os.path.exists(actions_file):\n                    try:\n                        os.remove(actions_file)\n                        cleaned_files.append(f\"{dir_name}/actions.jsonl\")\n                    except Exception as e:\n                        errors.append(f\"删除 {dir_name}/actions.jsonl 失败: {str(e)}\")\n        \n        # 清理内存中的运行状态\n        if simulation_id in cls._run_states:\n            del cls._run_states[simulation_id]\n        \n        logger.info(f\"清理模拟日志完成: {simulation_id}, 删除文件: {cleaned_files}\")\n        \n        return {\n            \"success\": len(errors) == 0,\n            \"cleaned_files\": cleaned_files,\n            \"errors\": errors if errors else None\n        }\n    \n    # 防止重复清理的标志\n    _cleanup_done = False\n    \n    @classmethod\n    def cleanup_all_simulations(cls):\n        \"\"\"\n        清理所有运行中的模拟进程\n        \n        在服务器关闭时调用，确保所有子进程被终止\n        \"\"\"\n        # 防止重复清理\n        if cls._cleanup_done:\n            return\n        cls._cleanup_done = True\n        \n        # 检查是否有内容需要清理（避免空进程的进程打印无用日志）\n        has_processes = bool(cls._processes)\n        has_updaters = bool(cls._graph_memory_enabled)\n        \n        if not has_processes and not has_updaters:\n            return  # 没有需要清理的内容，静默返回\n        \n        logger.info(\"正在清理所有模拟进程...\")\n        \n        # 首先停止所有图谱记忆更新器（stop_all 内部会打印日志）\n        try:\n            ZepGraphMemoryManager.stop_all()\n        except Exception as e:\n            logger.error(f\"停止图谱记忆更新器失败: {e}\")\n        cls._graph_memory_enabled.clear()\n        \n        # 复制字典以避免在迭代时修改\n        processes = list(cls._processes.items())\n        \n        for simulation_id, process in processes:\n            try:\n                if process.poll() is None:  # 进程仍在运行\n                    logger.info(f\"终止模拟进程: {simulation_id}, pid={process.pid}\")\n                    \n                    try:\n                        # 使用跨平台的进程终止方法\n                        cls._terminate_process(process, simulation_id, timeout=5)\n                    except (ProcessLookupError, OSError):\n                        # 进程可能已经不存在，尝试直接终止\n                        try:\n                            process.terminate()\n                            process.wait(timeout=3)\n                        except Exception:\n                            process.kill()\n                    \n                    # 更新 run_state.json\n                    state = cls.get_run_state(simulation_id)\n                    if state:\n                        state.runner_status = RunnerStatus.STOPPED\n                        state.twitter_running = False\n                        state.reddit_running = False\n                        state.completed_at = datetime.now().isoformat()\n                        state.error = \"服务器关闭，模拟被终止\"\n                        cls._save_run_state(state)\n                    \n                    # 同时更新 state.json，将状态设为 stopped\n                    try:\n                        sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id)\n                        state_file = os.path.join(sim_dir, \"state.json\")\n                        logger.info(f\"尝试更新 state.json: {state_file}\")\n                        if os.path.exists(state_file):\n                            with open(state_file, 'r', encoding='utf-8') as f:\n                                state_data = json.load(f)\n                            state_data['status'] = 'stopped'\n                            state_data['updated_at'] = datetime.now().isoformat()\n                            with open(state_file, 'w', encoding='utf-8') as f:\n                                json.dump(state_data, f, indent=2, ensure_ascii=False)\n                            logger.info(f\"已更新 state.json 状态为 stopped: {simulation_id}\")\n                        else:\n                            logger.warning(f\"state.json 不存在: {state_file}\")\n                    except Exception as state_err:\n                        logger.warning(f\"更新 state.json 失败: {simulation_id}, error={state_err}\")\n                        \n            except Exception as e:\n                logger.error(f\"清理进程失败: {simulation_id}, error={e}\")\n        \n        # 清理文件句柄\n        for simulation_id, file_handle in list(cls._stdout_files.items()):\n            try:\n                if file_handle:\n                    file_handle.close()\n            except Exception:\n                pass\n        cls._stdout_files.clear()\n        \n        for simulation_id, file_handle in list(cls._stderr_files.items()):\n            try:\n                if file_handle:\n                    file_handle.close()\n            except Exception:\n                pass\n        cls._stderr_files.clear()\n        \n        # 清理内存中的状态\n        cls._processes.clear()\n        cls._action_queues.clear()\n        \n        logger.info(\"模拟进程清理完成\")\n    \n    @classmethod\n    def register_cleanup(cls):\n        \"\"\"\n        注册清理函数\n        \n        在 Flask 应用启动时调用，确保服务器关闭时清理所有模拟进程\n        \"\"\"\n        global _cleanup_registered\n        \n        if _cleanup_registered:\n            return\n        \n        # Flask debug 模式下，只在 reloader 子进程中注册清理（实际运行应用的进程）\n        # WERKZEUG_RUN_MAIN=true 表示是 reloader 子进程\n        # 如果不是 debug 模式，则没有这个环境变量，也需要注册\n        is_reloader_process = os.environ.get('WERKZEUG_RUN_MAIN') == 'true'\n        is_debug_mode = os.environ.get('FLASK_DEBUG') == '1' or os.environ.get('WERKZEUG_RUN_MAIN') is not None\n        \n        # 在 debug 模式下，只在 reloader 子进程中注册；非 debug 模式下始终注册\n        if is_debug_mode and not is_reloader_process:\n            _cleanup_registered = True  # 标记已注册，防止子进程再次尝试\n            return\n        \n        # 保存原有的信号处理器\n        original_sigint = signal.getsignal(signal.SIGINT)\n        original_sigterm = signal.getsignal(signal.SIGTERM)\n        # SIGHUP 只在 Unix 系统存在（macOS/Linux），Windows 没有\n        original_sighup = None\n        has_sighup = hasattr(signal, 'SIGHUP')\n        if has_sighup:\n            original_sighup = signal.getsignal(signal.SIGHUP)\n        \n        def cleanup_handler(signum=None, frame=None):\n            \"\"\"信号处理器：先清理模拟进程，再调用原处理器\"\"\"\n            # 只有在有进程需要清理时才打印日志\n            if cls._processes or cls._graph_memory_enabled:\n                logger.info(f\"收到信号 {signum}，开始清理...\")\n            cls.cleanup_all_simulations()\n            \n            # 调用原有的信号处理器，让 Flask 正常退出\n            if signum == signal.SIGINT and callable(original_sigint):\n                original_sigint(signum, frame)\n            elif signum == signal.SIGTERM and callable(original_sigterm):\n                original_sigterm(signum, frame)\n            elif has_sighup and signum == signal.SIGHUP:\n                # SIGHUP: 终端关闭时发送\n                if callable(original_sighup):\n                    original_sighup(signum, frame)\n                else:\n                    # 默认行为：正常退出\n                    sys.exit(0)\n            else:\n                # 如果原处理器不可调用（如 SIG_DFL），则使用默认行为\n                raise KeyboardInterrupt\n        \n        # 注册 atexit 处理器（作为备用）\n        atexit.register(cls.cleanup_all_simulations)\n        \n        # 注册信号处理器（仅在主线程中）\n        try:\n            # SIGTERM: kill 命令默认信号\n            signal.signal(signal.SIGTERM, cleanup_handler)\n            # SIGINT: Ctrl+C\n            signal.signal(signal.SIGINT, cleanup_handler)\n            # SIGHUP: 终端关闭（仅 Unix 系统）\n            if has_sighup:\n                signal.signal(signal.SIGHUP, cleanup_handler)\n        except ValueError:\n            # 不在主线程中，只能使用 atexit\n            logger.warning(\"无法注册信号处理器（不在主线程），仅使用 atexit\")\n        \n        _cleanup_registered = True\n    \n    @classmethod\n    def get_running_simulations(cls) -> List[str]:\n        \"\"\"\n        获取所有正在运行的模拟ID列表\n        \"\"\"\n        running = []\n        for sim_id, process in cls._processes.items():\n            if process.poll() is None:\n                running.append(sim_id)\n        return running\n    \n    # ============== Interview 功能 ==============\n    \n    @classmethod\n    def check_env_alive(cls, simulation_id: str) -> bool:\n        \"\"\"\n        检查模拟环境是否存活（可以接收Interview命令）\n\n        Args:\n            simulation_id: 模拟ID\n\n        Returns:\n            True 表示环境存活，False 表示环境已关闭\n        \"\"\"\n        sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id)\n        if not os.path.exists(sim_dir):\n            return False\n\n        ipc_client = SimulationIPCClient(sim_dir)\n        return ipc_client.check_env_alive()\n\n    @classmethod\n    def get_env_status_detail(cls, simulation_id: str) -> Dict[str, Any]:\n        \"\"\"\n        获取模拟环境的详细状态信息\n\n        Args:\n            simulation_id: 模拟ID\n\n        Returns:\n            状态详情字典，包含 status, twitter_available, reddit_available, timestamp\n        \"\"\"\n        sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id)\n        status_file = os.path.join(sim_dir, \"env_status.json\")\n        \n        default_status = {\n            \"status\": \"stopped\",\n            \"twitter_available\": False,\n            \"reddit_available\": False,\n            \"timestamp\": None\n        }\n        \n        if not os.path.exists(status_file):\n            return default_status\n        \n        try:\n            with open(status_file, 'r', encoding='utf-8') as f:\n                status = json.load(f)\n            return {\n                \"status\": status.get(\"status\", \"stopped\"),\n                \"twitter_available\": status.get(\"twitter_available\", False),\n                \"reddit_available\": status.get(\"reddit_available\", False),\n                \"timestamp\": status.get(\"timestamp\")\n            }\n        except (json.JSONDecodeError, OSError):\n            return default_status\n\n    @classmethod\n    def interview_agent(\n        cls,\n        simulation_id: str,\n        agent_id: int,\n        prompt: str,\n        platform: str = None,\n        timeout: float = 60.0\n    ) -> Dict[str, Any]:\n        \"\"\"\n        采访单个Agent\n\n        Args:\n            simulation_id: 模拟ID\n            agent_id: Agent ID\n            prompt: 采访问题\n            platform: 指定平台（可选）\n                - \"twitter\": 只采访Twitter平台\n                - \"reddit\": 只采访Reddit平台\n                - None: 双平台模拟时同时采访两个平台，返回整合结果\n            timeout: 超时时间（秒）\n\n        Returns:\n            采访结果字典\n\n        Raises:\n            ValueError: 模拟不存在或环境未运行\n            TimeoutError: 等待响应超时\n        \"\"\"\n        sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id)\n        if not os.path.exists(sim_dir):\n            raise ValueError(f\"模拟不存在: {simulation_id}\")\n\n        ipc_client = SimulationIPCClient(sim_dir)\n\n        if not ipc_client.check_env_alive():\n            raise ValueError(f\"模拟环境未运行或已关闭，无法执行Interview: {simulation_id}\")\n\n        logger.info(f\"发送Interview命令: simulation_id={simulation_id}, agent_id={agent_id}, platform={platform}\")\n\n        response = ipc_client.send_interview(\n            agent_id=agent_id,\n            prompt=prompt,\n            platform=platform,\n            timeout=timeout\n        )\n\n        if response.status.value == \"completed\":\n            return {\n                \"success\": True,\n                \"agent_id\": agent_id,\n                \"prompt\": prompt,\n                \"result\": response.result,\n                \"timestamp\": response.timestamp\n            }\n        else:\n            return {\n                \"success\": False,\n                \"agent_id\": agent_id,\n                \"prompt\": prompt,\n                \"error\": response.error,\n                \"timestamp\": response.timestamp\n            }\n    \n    @classmethod\n    def interview_agents_batch(\n        cls,\n        simulation_id: str,\n        interviews: List[Dict[str, Any]],\n        platform: str = None,\n        timeout: float = 120.0\n    ) -> Dict[str, Any]:\n        \"\"\"\n        批量采访多个Agent\n\n        Args:\n            simulation_id: 模拟ID\n            interviews: 采访列表，每个元素包含 {\"agent_id\": int, \"prompt\": str, \"platform\": str(可选)}\n            platform: 默认平台（可选，会被每个采访项的platform覆盖）\n                - \"twitter\": 默认只采访Twitter平台\n                - \"reddit\": 默认只采访Reddit平台\n                - None: 双平台模拟时每个Agent同时采访两个平台\n            timeout: 超时时间（秒）\n\n        Returns:\n            批量采访结果字典\n\n        Raises:\n            ValueError: 模拟不存在或环境未运行\n            TimeoutError: 等待响应超时\n        \"\"\"\n        sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id)\n        if not os.path.exists(sim_dir):\n            raise ValueError(f\"模拟不存在: {simulation_id}\")\n\n        ipc_client = SimulationIPCClient(sim_dir)\n\n        if not ipc_client.check_env_alive():\n            raise ValueError(f\"模拟环境未运行或已关闭，无法执行Interview: {simulation_id}\")\n\n        logger.info(f\"发送批量Interview命令: simulation_id={simulation_id}, count={len(interviews)}, platform={platform}\")\n\n        response = ipc_client.send_batch_interview(\n            interviews=interviews,\n            platform=platform,\n            timeout=timeout\n        )\n\n        if response.status.value == \"completed\":\n            return {\n                \"success\": True,\n                \"interviews_count\": len(interviews),\n                \"result\": response.result,\n                \"timestamp\": response.timestamp\n            }\n        else:\n            return {\n                \"success\": False,\n                \"interviews_count\": len(interviews),\n                \"error\": response.error,\n                \"timestamp\": response.timestamp\n            }\n    \n    @classmethod\n    def interview_all_agents(\n        cls,\n        simulation_id: str,\n        prompt: str,\n        platform: str = None,\n        timeout: float = 180.0\n    ) -> Dict[str, Any]:\n        \"\"\"\n        采访所有Agent（全局采访）\n\n        使用相同的问题采访模拟中的所有Agent\n\n        Args:\n            simulation_id: 模拟ID\n            prompt: 采访问题（所有Agent使用相同问题）\n            platform: 指定平台（可选）\n                - \"twitter\": 只采访Twitter平台\n                - \"reddit\": 只采访Reddit平台\n                - None: 双平台模拟时每个Agent同时采访两个平台\n            timeout: 超时时间（秒）\n\n        Returns:\n            全局采访结果字典\n        \"\"\"\n        sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id)\n        if not os.path.exists(sim_dir):\n            raise ValueError(f\"模拟不存在: {simulation_id}\")\n\n        # 从配置文件获取所有Agent信息\n        config_path = os.path.join(sim_dir, \"simulation_config.json\")\n        if not os.path.exists(config_path):\n            raise ValueError(f\"模拟配置不存在: {simulation_id}\")\n\n        with open(config_path, 'r', encoding='utf-8') as f:\n            config = json.load(f)\n\n        agent_configs = config.get(\"agent_configs\", [])\n        if not agent_configs:\n            raise ValueError(f\"模拟配置中没有Agent: {simulation_id}\")\n\n        # 构建批量采访列表\n        interviews = []\n        for agent_config in agent_configs:\n            agent_id = agent_config.get(\"agent_id\")\n            if agent_id is not None:\n                interviews.append({\n                    \"agent_id\": agent_id,\n                    \"prompt\": prompt\n                })\n\n        logger.info(f\"发送全局Interview命令: simulation_id={simulation_id}, agent_count={len(interviews)}, platform={platform}\")\n\n        return cls.interview_agents_batch(\n            simulation_id=simulation_id,\n            interviews=interviews,\n            platform=platform,\n            timeout=timeout\n        )\n    \n    @classmethod\n    def close_simulation_env(\n        cls,\n        simulation_id: str,\n        timeout: float = 30.0\n    ) -> Dict[str, Any]:\n        \"\"\"\n        关闭模拟环境（而不是停止模拟进程）\n        \n        向模拟发送关闭环境命令，使其优雅退出等待命令模式\n        \n        Args:\n            simulation_id: 模拟ID\n            timeout: 超时时间（秒）\n            \n        Returns:\n            操作结果字典\n        \"\"\"\n        sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id)\n        if not os.path.exists(sim_dir):\n            raise ValueError(f\"模拟不存在: {simulation_id}\")\n        \n        ipc_client = SimulationIPCClient(sim_dir)\n        \n        if not ipc_client.check_env_alive():\n            return {\n                \"success\": True,\n                \"message\": \"环境已经关闭\"\n            }\n        \n        logger.info(f\"发送关闭环境命令: simulation_id={simulation_id}\")\n        \n        try:\n            response = ipc_client.send_close_env(timeout=timeout)\n            \n            return {\n                \"success\": response.status.value == \"completed\",\n                \"message\": \"环境关闭命令已发送\",\n                \"result\": response.result,\n                \"timestamp\": response.timestamp\n            }\n        except TimeoutError:\n            # 超时可能是因为环境正在关闭\n            return {\n                \"success\": True,\n                \"message\": \"环境关闭命令已发送（等待响应超时，环境可能正在关闭）\"\n            }\n    \n    @classmethod\n    def _get_interview_history_from_db(\n        cls,\n        db_path: str,\n        platform_name: str,\n        agent_id: Optional[int] = None,\n        limit: int = 100\n    ) -> List[Dict[str, Any]]:\n        \"\"\"从单个数据库获取Interview历史\"\"\"\n        import sqlite3\n        \n        if not os.path.exists(db_path):\n            return []\n        \n        results = []\n        \n        try:\n            conn = sqlite3.connect(db_path)\n            cursor = conn.cursor()\n            \n            if agent_id is not None:\n                cursor.execute(\"\"\"\n                    SELECT user_id, info, created_at\n                    FROM trace\n                    WHERE action = 'interview' AND user_id = ?\n                    ORDER BY created_at DESC\n                    LIMIT ?\n                \"\"\", (agent_id, limit))\n            else:\n                cursor.execute(\"\"\"\n                    SELECT user_id, info, created_at\n                    FROM trace\n                    WHERE action = 'interview'\n                    ORDER BY created_at DESC\n                    LIMIT ?\n                \"\"\", (limit,))\n            \n            for user_id, info_json, created_at in cursor.fetchall():\n                try:\n                    info = json.loads(info_json) if info_json else {}\n                except json.JSONDecodeError:\n                    info = {\"raw\": info_json}\n                \n                results.append({\n                    \"agent_id\": user_id,\n                    \"response\": info.get(\"response\", info),\n                    \"prompt\": info.get(\"prompt\", \"\"),\n                    \"timestamp\": created_at,\n                    \"platform\": platform_name\n                })\n            \n            conn.close()\n            \n        except Exception as e:\n            logger.error(f\"读取Interview历史失败 ({platform_name}): {e}\")\n        \n        return results\n\n    @classmethod\n    def get_interview_history(\n        cls,\n        simulation_id: str,\n        platform: str = None,\n        agent_id: Optional[int] = None,\n        limit: int = 100\n    ) -> List[Dict[str, Any]]:\n        \"\"\"\n        获取Interview历史记录（从数据库读取）\n        \n        Args:\n            simulation_id: 模拟ID\n            platform: 平台类型（reddit/twitter/None）\n                - \"reddit\": 只获取Reddit平台的历史\n                - \"twitter\": 只获取Twitter平台的历史\n                - None: 获取两个平台的所有历史\n            agent_id: 指定Agent ID（可选，只获取该Agent的历史）\n            limit: 每个平台返回数量限制\n            \n        Returns:\n            Interview历史记录列表\n        \"\"\"\n        sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id)\n        \n        results = []\n        \n        # 确定要查询的平台\n        if platform in (\"reddit\", \"twitter\"):\n            platforms = [platform]\n        else:\n            # 不指定platform时，查询两个平台\n            platforms = [\"twitter\", \"reddit\"]\n        \n        for p in platforms:\n            db_path = os.path.join(sim_dir, f\"{p}_simulation.db\")\n            platform_results = cls._get_interview_history_from_db(\n                db_path=db_path,\n                platform_name=p,\n                agent_id=agent_id,\n                limit=limit\n            )\n            results.extend(platform_results)\n        \n        # 按时间降序排序\n        results.sort(key=lambda x: x.get(\"timestamp\", \"\"), reverse=True)\n        \n        # 如果查询了多个平台，限制总数\n        if len(platforms) > 1 and len(results) > limit:\n            results = results[:limit]\n        \n        return results\n\n"
  },
  {
    "path": "backend/app/services/text_processor.py",
    "content": "\"\"\"\n文本处理服务\n\"\"\"\n\nfrom typing import List, Optional\nfrom ..utils.file_parser import FileParser, split_text_into_chunks\n\n\nclass TextProcessor:\n    \"\"\"文本处理器\"\"\"\n    \n    @staticmethod\n    def extract_from_files(file_paths: List[str]) -> str:\n        \"\"\"从多个文件提取文本\"\"\"\n        return FileParser.extract_from_multiple(file_paths)\n    \n    @staticmethod\n    def split_text(\n        text: str,\n        chunk_size: int = 500,\n        overlap: int = 50\n    ) -> List[str]:\n        \"\"\"\n        分割文本\n        \n        Args:\n            text: 原始文本\n            chunk_size: 块大小\n            overlap: 重叠大小\n            \n        Returns:\n            文本块列表\n        \"\"\"\n        return split_text_into_chunks(text, chunk_size, overlap)\n    \n    @staticmethod\n    def preprocess_text(text: str) -> str:\n        \"\"\"\n        预处理文本\n        - 移除多余空白\n        - 标准化换行\n        \n        Args:\n            text: 原始文本\n            \n        Returns:\n            处理后的文本\n        \"\"\"\n        import re\n        \n        # 标准化换行\n        text = text.replace('\\r\\n', '\\n').replace('\\r', '\\n')\n        \n        # 移除连续空行（保留最多两个换行）\n        text = re.sub(r'\\n{3,}', '\\n\\n', text)\n        \n        # 移除行首行尾空白\n        lines = [line.strip() for line in text.split('\\n')]\n        text = '\\n'.join(lines)\n        \n        return text.strip()\n    \n    @staticmethod\n    def get_text_stats(text: str) -> dict:\n        \"\"\"获取文本统计信息\"\"\"\n        return {\n            \"total_chars\": len(text),\n            \"total_lines\": text.count('\\n') + 1,\n            \"total_words\": len(text.split()),\n        }\n\n"
  },
  {
    "path": "backend/app/services/zep_entity_reader.py",
    "content": "\"\"\"\nZep实体读取与过滤服务\n从Zep图谱中读取节点，筛选出符合预定义实体类型的节点\n\"\"\"\n\nimport time\nfrom typing import Dict, Any, List, Optional, Set, Callable, TypeVar\nfrom dataclasses import dataclass, field\n\nfrom zep_cloud.client import Zep\n\nfrom ..config import Config\nfrom ..utils.logger import get_logger\nfrom ..utils.zep_paging import fetch_all_nodes, fetch_all_edges\n\nlogger = get_logger('mirofish.zep_entity_reader')\n\n# 用于泛型返回类型\nT = TypeVar('T')\n\n\n@dataclass\nclass EntityNode:\n    \"\"\"实体节点数据结构\"\"\"\n    uuid: str\n    name: str\n    labels: List[str]\n    summary: str\n    attributes: Dict[str, Any]\n    # 相关的边信息\n    related_edges: List[Dict[str, Any]] = field(default_factory=list)\n    # 相关的其他节点信息\n    related_nodes: List[Dict[str, Any]] = field(default_factory=list)\n    \n    def to_dict(self) -> Dict[str, Any]:\n        return {\n            \"uuid\": self.uuid,\n            \"name\": self.name,\n            \"labels\": self.labels,\n            \"summary\": self.summary,\n            \"attributes\": self.attributes,\n            \"related_edges\": self.related_edges,\n            \"related_nodes\": self.related_nodes,\n        }\n    \n    def get_entity_type(self) -> Optional[str]:\n        \"\"\"获取实体类型（排除默认的Entity标签）\"\"\"\n        for label in self.labels:\n            if label not in [\"Entity\", \"Node\"]:\n                return label\n        return None\n\n\n@dataclass\nclass FilteredEntities:\n    \"\"\"过滤后的实体集合\"\"\"\n    entities: List[EntityNode]\n    entity_types: Set[str]\n    total_count: int\n    filtered_count: int\n    \n    def to_dict(self) -> Dict[str, Any]:\n        return {\n            \"entities\": [e.to_dict() for e in self.entities],\n            \"entity_types\": list(self.entity_types),\n            \"total_count\": self.total_count,\n            \"filtered_count\": self.filtered_count,\n        }\n\n\nclass ZepEntityReader:\n    \"\"\"\n    Zep实体读取与过滤服务\n    \n    主要功能：\n    1. 从Zep图谱读取所有节点\n    2. 筛选出符合预定义实体类型的节点（Labels不只是Entity的节点）\n    3. 获取每个实体的相关边和关联节点信息\n    \"\"\"\n    \n    def __init__(self, api_key: Optional[str] = None):\n        self.api_key = api_key or Config.ZEP_API_KEY\n        if not self.api_key:\n            raise ValueError(\"ZEP_API_KEY 未配置\")\n        \n        self.client = Zep(api_key=self.api_key)\n    \n    def _call_with_retry(\n        self, \n        func: Callable[[], T], \n        operation_name: str,\n        max_retries: int = 3,\n        initial_delay: float = 2.0\n    ) -> T:\n        \"\"\"\n        带重试机制的Zep API调用\n        \n        Args:\n            func: 要执行的函数（无参数的lambda或callable）\n            operation_name: 操作名称，用于日志\n            max_retries: 最大重试次数（默认3次，即最多尝试3次）\n            initial_delay: 初始延迟秒数\n            \n        Returns:\n            API调用结果\n        \"\"\"\n        last_exception = None\n        delay = initial_delay\n        \n        for attempt in range(max_retries):\n            try:\n                return func()\n            except Exception as e:\n                last_exception = e\n                if attempt < max_retries - 1:\n                    logger.warning(\n                        f\"Zep {operation_name} 第 {attempt + 1} 次尝试失败: {str(e)[:100]}, \"\n                        f\"{delay:.1f}秒后重试...\"\n                    )\n                    time.sleep(delay)\n                    delay *= 2  # 指数退避\n                else:\n                    logger.error(f\"Zep {operation_name} 在 {max_retries} 次尝试后仍失败: {str(e)}\")\n        \n        raise last_exception\n    \n    def get_all_nodes(self, graph_id: str) -> List[Dict[str, Any]]:\n        \"\"\"\n        获取图谱的所有节点（分页获取）\n\n        Args:\n            graph_id: 图谱ID\n\n        Returns:\n            节点列表\n        \"\"\"\n        logger.info(f\"获取图谱 {graph_id} 的所有节点...\")\n\n        nodes = fetch_all_nodes(self.client, graph_id)\n\n        nodes_data = []\n        for node in nodes:\n            nodes_data.append({\n                \"uuid\": getattr(node, 'uuid_', None) or getattr(node, 'uuid', ''),\n                \"name\": node.name or \"\",\n                \"labels\": node.labels or [],\n                \"summary\": node.summary or \"\",\n                \"attributes\": node.attributes or {},\n            })\n\n        logger.info(f\"共获取 {len(nodes_data)} 个节点\")\n        return nodes_data\n\n    def get_all_edges(self, graph_id: str) -> List[Dict[str, Any]]:\n        \"\"\"\n        获取图谱的所有边（分页获取）\n\n        Args:\n            graph_id: 图谱ID\n\n        Returns:\n            边列表\n        \"\"\"\n        logger.info(f\"获取图谱 {graph_id} 的所有边...\")\n\n        edges = fetch_all_edges(self.client, graph_id)\n\n        edges_data = []\n        for edge in edges:\n            edges_data.append({\n                \"uuid\": getattr(edge, 'uuid_', None) or getattr(edge, 'uuid', ''),\n                \"name\": edge.name or \"\",\n                \"fact\": edge.fact or \"\",\n                \"source_node_uuid\": edge.source_node_uuid,\n                \"target_node_uuid\": edge.target_node_uuid,\n                \"attributes\": edge.attributes or {},\n            })\n\n        logger.info(f\"共获取 {len(edges_data)} 条边\")\n        return edges_data\n    \n    def get_node_edges(self, node_uuid: str) -> List[Dict[str, Any]]:\n        \"\"\"\n        获取指定节点的所有相关边（带重试机制）\n        \n        Args:\n            node_uuid: 节点UUID\n            \n        Returns:\n            边列表\n        \"\"\"\n        try:\n            # 使用重试机制调用Zep API\n            edges = self._call_with_retry(\n                func=lambda: self.client.graph.node.get_entity_edges(node_uuid=node_uuid),\n                operation_name=f\"获取节点边(node={node_uuid[:8]}...)\"\n            )\n            \n            edges_data = []\n            for edge in edges:\n                edges_data.append({\n                    \"uuid\": getattr(edge, 'uuid_', None) or getattr(edge, 'uuid', ''),\n                    \"name\": edge.name or \"\",\n                    \"fact\": edge.fact or \"\",\n                    \"source_node_uuid\": edge.source_node_uuid,\n                    \"target_node_uuid\": edge.target_node_uuid,\n                    \"attributes\": edge.attributes or {},\n                })\n            \n            return edges_data\n        except Exception as e:\n            logger.warning(f\"获取节点 {node_uuid} 的边失败: {str(e)}\")\n            return []\n    \n    def filter_defined_entities(\n        self, \n        graph_id: str,\n        defined_entity_types: Optional[List[str]] = None,\n        enrich_with_edges: bool = True\n    ) -> FilteredEntities:\n        \"\"\"\n        筛选出符合预定义实体类型的节点\n        \n        筛选逻辑：\n        - 如果节点的Labels只有一个\"Entity\"，说明这个实体不符合我们预定义的类型，跳过\n        - 如果节点的Labels包含除\"Entity\"和\"Node\"之外的标签，说明符合预定义类型，保留\n        \n        Args:\n            graph_id: 图谱ID\n            defined_entity_types: 预定义的实体类型列表（可选，如果提供则只保留这些类型）\n            enrich_with_edges: 是否获取每个实体的相关边信息\n            \n        Returns:\n            FilteredEntities: 过滤后的实体集合\n        \"\"\"\n        logger.info(f\"开始筛选图谱 {graph_id} 的实体...\")\n        \n        # 获取所有节点\n        all_nodes = self.get_all_nodes(graph_id)\n        total_count = len(all_nodes)\n        \n        # 获取所有边（用于后续关联查找）\n        all_edges = self.get_all_edges(graph_id) if enrich_with_edges else []\n        \n        # 构建节点UUID到节点数据的映射\n        node_map = {n[\"uuid\"]: n for n in all_nodes}\n        \n        # 筛选符合条件的实体\n        filtered_entities = []\n        entity_types_found = set()\n        \n        for node in all_nodes:\n            labels = node.get(\"labels\", [])\n            \n            # 筛选逻辑：Labels必须包含除\"Entity\"和\"Node\"之外的标签\n            custom_labels = [l for l in labels if l not in [\"Entity\", \"Node\"]]\n            \n            if not custom_labels:\n                # 只有默认标签，跳过\n                continue\n            \n            # 如果指定了预定义类型，检查是否匹配\n            if defined_entity_types:\n                matching_labels = [l for l in custom_labels if l in defined_entity_types]\n                if not matching_labels:\n                    continue\n                entity_type = matching_labels[0]\n            else:\n                entity_type = custom_labels[0]\n            \n            entity_types_found.add(entity_type)\n            \n            # 创建实体节点对象\n            entity = EntityNode(\n                uuid=node[\"uuid\"],\n                name=node[\"name\"],\n                labels=labels,\n                summary=node[\"summary\"],\n                attributes=node[\"attributes\"],\n            )\n            \n            # 获取相关边和节点\n            if enrich_with_edges:\n                related_edges = []\n                related_node_uuids = set()\n                \n                for edge in all_edges:\n                    if edge[\"source_node_uuid\"] == node[\"uuid\"]:\n                        related_edges.append({\n                            \"direction\": \"outgoing\",\n                            \"edge_name\": edge[\"name\"],\n                            \"fact\": edge[\"fact\"],\n                            \"target_node_uuid\": edge[\"target_node_uuid\"],\n                        })\n                        related_node_uuids.add(edge[\"target_node_uuid\"])\n                    elif edge[\"target_node_uuid\"] == node[\"uuid\"]:\n                        related_edges.append({\n                            \"direction\": \"incoming\",\n                            \"edge_name\": edge[\"name\"],\n                            \"fact\": edge[\"fact\"],\n                            \"source_node_uuid\": edge[\"source_node_uuid\"],\n                        })\n                        related_node_uuids.add(edge[\"source_node_uuid\"])\n                \n                entity.related_edges = related_edges\n                \n                # 获取关联节点的基本信息\n                related_nodes = []\n                for related_uuid in related_node_uuids:\n                    if related_uuid in node_map:\n                        related_node = node_map[related_uuid]\n                        related_nodes.append({\n                            \"uuid\": related_node[\"uuid\"],\n                            \"name\": related_node[\"name\"],\n                            \"labels\": related_node[\"labels\"],\n                            \"summary\": related_node.get(\"summary\", \"\"),\n                        })\n                \n                entity.related_nodes = related_nodes\n            \n            filtered_entities.append(entity)\n        \n        logger.info(f\"筛选完成: 总节点 {total_count}, 符合条件 {len(filtered_entities)}, \"\n                   f\"实体类型: {entity_types_found}\")\n        \n        return FilteredEntities(\n            entities=filtered_entities,\n            entity_types=entity_types_found,\n            total_count=total_count,\n            filtered_count=len(filtered_entities),\n        )\n    \n    def get_entity_with_context(\n        self, \n        graph_id: str, \n        entity_uuid: str\n    ) -> Optional[EntityNode]:\n        \"\"\"\n        获取单个实体及其完整上下文（边和关联节点，带重试机制）\n        \n        Args:\n            graph_id: 图谱ID\n            entity_uuid: 实体UUID\n            \n        Returns:\n            EntityNode或None\n        \"\"\"\n        try:\n            # 使用重试机制获取节点\n            node = self._call_with_retry(\n                func=lambda: self.client.graph.node.get(uuid_=entity_uuid),\n                operation_name=f\"获取节点详情(uuid={entity_uuid[:8]}...)\"\n            )\n            \n            if not node:\n                return None\n            \n            # 获取节点的边\n            edges = self.get_node_edges(entity_uuid)\n            \n            # 获取所有节点用于关联查找\n            all_nodes = self.get_all_nodes(graph_id)\n            node_map = {n[\"uuid\"]: n for n in all_nodes}\n            \n            # 处理相关边和节点\n            related_edges = []\n            related_node_uuids = set()\n            \n            for edge in edges:\n                if edge[\"source_node_uuid\"] == entity_uuid:\n                    related_edges.append({\n                        \"direction\": \"outgoing\",\n                        \"edge_name\": edge[\"name\"],\n                        \"fact\": edge[\"fact\"],\n                        \"target_node_uuid\": edge[\"target_node_uuid\"],\n                    })\n                    related_node_uuids.add(edge[\"target_node_uuid\"])\n                else:\n                    related_edges.append({\n                        \"direction\": \"incoming\",\n                        \"edge_name\": edge[\"name\"],\n                        \"fact\": edge[\"fact\"],\n                        \"source_node_uuid\": edge[\"source_node_uuid\"],\n                    })\n                    related_node_uuids.add(edge[\"source_node_uuid\"])\n            \n            # 获取关联节点信息\n            related_nodes = []\n            for related_uuid in related_node_uuids:\n                if related_uuid in node_map:\n                    related_node = node_map[related_uuid]\n                    related_nodes.append({\n                        \"uuid\": related_node[\"uuid\"],\n                        \"name\": related_node[\"name\"],\n                        \"labels\": related_node[\"labels\"],\n                        \"summary\": related_node.get(\"summary\", \"\"),\n                    })\n            \n            return EntityNode(\n                uuid=getattr(node, 'uuid_', None) or getattr(node, 'uuid', ''),\n                name=node.name or \"\",\n                labels=node.labels or [],\n                summary=node.summary or \"\",\n                attributes=node.attributes or {},\n                related_edges=related_edges,\n                related_nodes=related_nodes,\n            )\n            \n        except Exception as e:\n            logger.error(f\"获取实体 {entity_uuid} 失败: {str(e)}\")\n            return None\n    \n    def get_entities_by_type(\n        self, \n        graph_id: str, \n        entity_type: str,\n        enrich_with_edges: bool = True\n    ) -> List[EntityNode]:\n        \"\"\"\n        获取指定类型的所有实体\n        \n        Args:\n            graph_id: 图谱ID\n            entity_type: 实体类型（如 \"Student\", \"PublicFigure\" 等）\n            enrich_with_edges: 是否获取相关边信息\n            \n        Returns:\n            实体列表\n        \"\"\"\n        result = self.filter_defined_entities(\n            graph_id=graph_id,\n            defined_entity_types=[entity_type],\n            enrich_with_edges=enrich_with_edges\n        )\n        return result.entities\n\n\n"
  },
  {
    "path": "backend/app/services/zep_graph_memory_updater.py",
    "content": "\"\"\"\nZep图谱记忆更新服务\n将模拟中的Agent活动动态更新到Zep图谱中\n\"\"\"\n\nimport os\nimport time\nimport threading\nimport json\nfrom typing import Dict, Any, List, Optional, Callable\nfrom dataclasses import dataclass\nfrom datetime import datetime\nfrom queue import Queue, Empty\n\nfrom zep_cloud.client import Zep\n\nfrom ..config import Config\nfrom ..utils.logger import get_logger\n\nlogger = get_logger('mirofish.zep_graph_memory_updater')\n\n\n@dataclass\nclass AgentActivity:\n    \"\"\"Agent活动记录\"\"\"\n    platform: str           # twitter / reddit\n    agent_id: int\n    agent_name: str\n    action_type: str        # CREATE_POST, LIKE_POST, etc.\n    action_args: Dict[str, Any]\n    round_num: int\n    timestamp: str\n    \n    def to_episode_text(self) -> str:\n        \"\"\"\n        将活动转换为可以发送给Zep的文本描述\n        \n        采用自然语言描述格式，让Zep能够从中提取实体和关系\n        不添加模拟相关的前缀，避免误导图谱更新\n        \"\"\"\n        # 根据不同的动作类型生成不同的描述\n        action_descriptions = {\n            \"CREATE_POST\": self._describe_create_post,\n            \"LIKE_POST\": self._describe_like_post,\n            \"DISLIKE_POST\": self._describe_dislike_post,\n            \"REPOST\": self._describe_repost,\n            \"QUOTE_POST\": self._describe_quote_post,\n            \"FOLLOW\": self._describe_follow,\n            \"CREATE_COMMENT\": self._describe_create_comment,\n            \"LIKE_COMMENT\": self._describe_like_comment,\n            \"DISLIKE_COMMENT\": self._describe_dislike_comment,\n            \"SEARCH_POSTS\": self._describe_search,\n            \"SEARCH_USER\": self._describe_search_user,\n            \"MUTE\": self._describe_mute,\n        }\n        \n        describe_func = action_descriptions.get(self.action_type, self._describe_generic)\n        description = describe_func()\n        \n        # 直接返回 \"agent名称: 活动描述\" 格式，不添加模拟前缀\n        return f\"{self.agent_name}: {description}\"\n    \n    def _describe_create_post(self) -> str:\n        content = self.action_args.get(\"content\", \"\")\n        if content:\n            return f\"发布了一条帖子：「{content}」\"\n        return \"发布了一条帖子\"\n    \n    def _describe_like_post(self) -> str:\n        \"\"\"点赞帖子 - 包含帖子原文和作者信息\"\"\"\n        post_content = self.action_args.get(\"post_content\", \"\")\n        post_author = self.action_args.get(\"post_author_name\", \"\")\n        \n        if post_content and post_author:\n            return f\"点赞了{post_author}的帖子：「{post_content}」\"\n        elif post_content:\n            return f\"点赞了一条帖子：「{post_content}」\"\n        elif post_author:\n            return f\"点赞了{post_author}的一条帖子\"\n        return \"点赞了一条帖子\"\n    \n    def _describe_dislike_post(self) -> str:\n        \"\"\"踩帖子 - 包含帖子原文和作者信息\"\"\"\n        post_content = self.action_args.get(\"post_content\", \"\")\n        post_author = self.action_args.get(\"post_author_name\", \"\")\n        \n        if post_content and post_author:\n            return f\"踩了{post_author}的帖子：「{post_content}」\"\n        elif post_content:\n            return f\"踩了一条帖子：「{post_content}」\"\n        elif post_author:\n            return f\"踩了{post_author}的一条帖子\"\n        return \"踩了一条帖子\"\n    \n    def _describe_repost(self) -> str:\n        \"\"\"转发帖子 - 包含原帖内容和作者信息\"\"\"\n        original_content = self.action_args.get(\"original_content\", \"\")\n        original_author = self.action_args.get(\"original_author_name\", \"\")\n        \n        if original_content and original_author:\n            return f\"转发了{original_author}的帖子：「{original_content}」\"\n        elif original_content:\n            return f\"转发了一条帖子：「{original_content}」\"\n        elif original_author:\n            return f\"转发了{original_author}的一条帖子\"\n        return \"转发了一条帖子\"\n    \n    def _describe_quote_post(self) -> str:\n        \"\"\"引用帖子 - 包含原帖内容、作者信息和引用评论\"\"\"\n        original_content = self.action_args.get(\"original_content\", \"\")\n        original_author = self.action_args.get(\"original_author_name\", \"\")\n        quote_content = self.action_args.get(\"quote_content\", \"\") or self.action_args.get(\"content\", \"\")\n        \n        base = \"\"\n        if original_content and original_author:\n            base = f\"引用了{original_author}的帖子「{original_content}」\"\n        elif original_content:\n            base = f\"引用了一条帖子「{original_content}」\"\n        elif original_author:\n            base = f\"引用了{original_author}的一条帖子\"\n        else:\n            base = \"引用了一条帖子\"\n        \n        if quote_content:\n            base += f\"，并评论道：「{quote_content}」\"\n        return base\n    \n    def _describe_follow(self) -> str:\n        \"\"\"关注用户 - 包含被关注用户的名称\"\"\"\n        target_user_name = self.action_args.get(\"target_user_name\", \"\")\n        \n        if target_user_name:\n            return f\"关注了用户「{target_user_name}」\"\n        return \"关注了一个用户\"\n    \n    def _describe_create_comment(self) -> str:\n        \"\"\"发表评论 - 包含评论内容和所评论的帖子信息\"\"\"\n        content = self.action_args.get(\"content\", \"\")\n        post_content = self.action_args.get(\"post_content\", \"\")\n        post_author = self.action_args.get(\"post_author_name\", \"\")\n        \n        if content:\n            if post_content and post_author:\n                return f\"在{post_author}的帖子「{post_content}」下评论道：「{content}」\"\n            elif post_content:\n                return f\"在帖子「{post_content}」下评论道：「{content}」\"\n            elif post_author:\n                return f\"在{post_author}的帖子下评论道：「{content}」\"\n            return f\"评论道：「{content}」\"\n        return \"发表了评论\"\n    \n    def _describe_like_comment(self) -> str:\n        \"\"\"点赞评论 - 包含评论内容和作者信息\"\"\"\n        comment_content = self.action_args.get(\"comment_content\", \"\")\n        comment_author = self.action_args.get(\"comment_author_name\", \"\")\n        \n        if comment_content and comment_author:\n            return f\"点赞了{comment_author}的评论：「{comment_content}」\"\n        elif comment_content:\n            return f\"点赞了一条评论：「{comment_content}」\"\n        elif comment_author:\n            return f\"点赞了{comment_author}的一条评论\"\n        return \"点赞了一条评论\"\n    \n    def _describe_dislike_comment(self) -> str:\n        \"\"\"踩评论 - 包含评论内容和作者信息\"\"\"\n        comment_content = self.action_args.get(\"comment_content\", \"\")\n        comment_author = self.action_args.get(\"comment_author_name\", \"\")\n        \n        if comment_content and comment_author:\n            return f\"踩了{comment_author}的评论：「{comment_content}」\"\n        elif comment_content:\n            return f\"踩了一条评论：「{comment_content}」\"\n        elif comment_author:\n            return f\"踩了{comment_author}的一条评论\"\n        return \"踩了一条评论\"\n    \n    def _describe_search(self) -> str:\n        \"\"\"搜索帖子 - 包含搜索关键词\"\"\"\n        query = self.action_args.get(\"query\", \"\") or self.action_args.get(\"keyword\", \"\")\n        return f\"搜索了「{query}」\" if query else \"进行了搜索\"\n    \n    def _describe_search_user(self) -> str:\n        \"\"\"搜索用户 - 包含搜索关键词\"\"\"\n        query = self.action_args.get(\"query\", \"\") or self.action_args.get(\"username\", \"\")\n        return f\"搜索了用户「{query}」\" if query else \"搜索了用户\"\n    \n    def _describe_mute(self) -> str:\n        \"\"\"屏蔽用户 - 包含被屏蔽用户的名称\"\"\"\n        target_user_name = self.action_args.get(\"target_user_name\", \"\")\n        \n        if target_user_name:\n            return f\"屏蔽了用户「{target_user_name}」\"\n        return \"屏蔽了一个用户\"\n    \n    def _describe_generic(self) -> str:\n        # 对于未知的动作类型，生成通用描述\n        return f\"执行了{self.action_type}操作\"\n\n\nclass ZepGraphMemoryUpdater:\n    \"\"\"\n    Zep图谱记忆更新器\n    \n    监控模拟的actions日志文件，将新的agent活动实时更新到Zep图谱中。\n    按平台分组，每累积BATCH_SIZE条活动后批量发送到Zep。\n    \n    所有有意义的行为都会被更新到Zep，action_args中会包含完整的上下文信息：\n    - 点赞/踩的帖子原文\n    - 转发/引用的帖子原文\n    - 关注/屏蔽的用户名\n    - 点赞/踩的评论原文\n    \"\"\"\n    \n    # 批量发送大小（每个平台累积多少条后发送）\n    BATCH_SIZE = 5\n    \n    # 平台名称映射（用于控制台显示）\n    PLATFORM_DISPLAY_NAMES = {\n        'twitter': '世界1',\n        'reddit': '世界2',\n    }\n    \n    # 发送间隔（秒），避免请求过快\n    SEND_INTERVAL = 0.5\n    \n    # 重试配置\n    MAX_RETRIES = 3\n    RETRY_DELAY = 2  # 秒\n    \n    def __init__(self, graph_id: str, api_key: Optional[str] = None):\n        \"\"\"\n        初始化更新器\n        \n        Args:\n            graph_id: Zep图谱ID\n            api_key: Zep API Key（可选，默认从配置读取）\n        \"\"\"\n        self.graph_id = graph_id\n        self.api_key = api_key or Config.ZEP_API_KEY\n        \n        if not self.api_key:\n            raise ValueError(\"ZEP_API_KEY未配置\")\n        \n        self.client = Zep(api_key=self.api_key)\n        \n        # 活动队列\n        self._activity_queue: Queue = Queue()\n        \n        # 按平台分组的活动缓冲区（每个平台各自累积到BATCH_SIZE后批量发送）\n        self._platform_buffers: Dict[str, List[AgentActivity]] = {\n            'twitter': [],\n            'reddit': [],\n        }\n        self._buffer_lock = threading.Lock()\n        \n        # 控制标志\n        self._running = False\n        self._worker_thread: Optional[threading.Thread] = None\n        \n        # 统计\n        self._total_activities = 0  # 实际添加到队列的活动数\n        self._total_sent = 0        # 成功发送到Zep的批次数\n        self._total_items_sent = 0  # 成功发送到Zep的活动条数\n        self._failed_count = 0      # 发送失败的批次数\n        self._skipped_count = 0     # 被过滤跳过的活动数（DO_NOTHING）\n        \n        logger.info(f\"ZepGraphMemoryUpdater 初始化完成: graph_id={graph_id}, batch_size={self.BATCH_SIZE}\")\n    \n    def _get_platform_display_name(self, platform: str) -> str:\n        \"\"\"获取平台的显示名称\"\"\"\n        return self.PLATFORM_DISPLAY_NAMES.get(platform.lower(), platform)\n    \n    def start(self):\n        \"\"\"启动后台工作线程\"\"\"\n        if self._running:\n            return\n        \n        self._running = True\n        self._worker_thread = threading.Thread(\n            target=self._worker_loop,\n            daemon=True,\n            name=f\"ZepMemoryUpdater-{self.graph_id[:8]}\"\n        )\n        self._worker_thread.start()\n        logger.info(f\"ZepGraphMemoryUpdater 已启动: graph_id={self.graph_id}\")\n    \n    def stop(self):\n        \"\"\"停止后台工作线程\"\"\"\n        self._running = False\n        \n        # 发送剩余的活动\n        self._flush_remaining()\n        \n        if self._worker_thread and self._worker_thread.is_alive():\n            self._worker_thread.join(timeout=10)\n        \n        logger.info(f\"ZepGraphMemoryUpdater 已停止: graph_id={self.graph_id}, \"\n                   f\"total_activities={self._total_activities}, \"\n                   f\"batches_sent={self._total_sent}, \"\n                   f\"items_sent={self._total_items_sent}, \"\n                   f\"failed={self._failed_count}, \"\n                   f\"skipped={self._skipped_count}\")\n    \n    def add_activity(self, activity: AgentActivity):\n        \"\"\"\n        添加一个agent活动到队列\n        \n        所有有意义的行为都会被添加到队列，包括：\n        - CREATE_POST（发帖）\n        - CREATE_COMMENT（评论）\n        - QUOTE_POST（引用帖子）\n        - SEARCH_POSTS（搜索帖子）\n        - SEARCH_USER（搜索用户）\n        - LIKE_POST/DISLIKE_POST（点赞/踩帖子）\n        - REPOST（转发）\n        - FOLLOW（关注）\n        - MUTE（屏蔽）\n        - LIKE_COMMENT/DISLIKE_COMMENT（点赞/踩评论）\n        \n        action_args中会包含完整的上下文信息（如帖子原文、用户名等）。\n        \n        Args:\n            activity: Agent活动记录\n        \"\"\"\n        # 跳过DO_NOTHING类型的活动\n        if activity.action_type == \"DO_NOTHING\":\n            self._skipped_count += 1\n            return\n        \n        self._activity_queue.put(activity)\n        self._total_activities += 1\n        logger.debug(f\"添加活动到Zep队列: {activity.agent_name} - {activity.action_type}\")\n    \n    def add_activity_from_dict(self, data: Dict[str, Any], platform: str):\n        \"\"\"\n        从字典数据添加活动\n        \n        Args:\n            data: 从actions.jsonl解析的字典数据\n            platform: 平台名称 (twitter/reddit)\n        \"\"\"\n        # 跳过事件类型的条目\n        if \"event_type\" in data:\n            return\n        \n        activity = AgentActivity(\n            platform=platform,\n            agent_id=data.get(\"agent_id\", 0),\n            agent_name=data.get(\"agent_name\", \"\"),\n            action_type=data.get(\"action_type\", \"\"),\n            action_args=data.get(\"action_args\", {}),\n            round_num=data.get(\"round\", 0),\n            timestamp=data.get(\"timestamp\", datetime.now().isoformat()),\n        )\n        \n        self.add_activity(activity)\n    \n    def _worker_loop(self):\n        \"\"\"后台工作循环 - 按平台批量发送活动到Zep\"\"\"\n        while self._running or not self._activity_queue.empty():\n            try:\n                # 尝试从队列获取活动（超时1秒）\n                try:\n                    activity = self._activity_queue.get(timeout=1)\n                    \n                    # 将活动添加到对应平台的缓冲区\n                    platform = activity.platform.lower()\n                    with self._buffer_lock:\n                        if platform not in self._platform_buffers:\n                            self._platform_buffers[platform] = []\n                        self._platform_buffers[platform].append(activity)\n                        \n                        # 检查该平台是否达到批量大小\n                        if len(self._platform_buffers[platform]) >= self.BATCH_SIZE:\n                            batch = self._platform_buffers[platform][:self.BATCH_SIZE]\n                            self._platform_buffers[platform] = self._platform_buffers[platform][self.BATCH_SIZE:]\n                            # 释放锁后再发送\n                            self._send_batch_activities(batch, platform)\n                            # 发送间隔，避免请求过快\n                            time.sleep(self.SEND_INTERVAL)\n                    \n                except Empty:\n                    pass\n                    \n            except Exception as e:\n                logger.error(f\"工作循环异常: {e}\")\n                time.sleep(1)\n    \n    def _send_batch_activities(self, activities: List[AgentActivity], platform: str):\n        \"\"\"\n        批量发送活动到Zep图谱（合并为一条文本）\n        \n        Args:\n            activities: Agent活动列表\n            platform: 平台名称\n        \"\"\"\n        if not activities:\n            return\n        \n        # 将多条活动合并为一条文本，用换行分隔\n        episode_texts = [activity.to_episode_text() for activity in activities]\n        combined_text = \"\\n\".join(episode_texts)\n        \n        # 带重试的发送\n        for attempt in range(self.MAX_RETRIES):\n            try:\n                self.client.graph.add(\n                    graph_id=self.graph_id,\n                    type=\"text\",\n                    data=combined_text\n                )\n                \n                self._total_sent += 1\n                self._total_items_sent += len(activities)\n                display_name = self._get_platform_display_name(platform)\n                logger.info(f\"成功批量发送 {len(activities)} 条{display_name}活动到图谱 {self.graph_id}\")\n                logger.debug(f\"批量内容预览: {combined_text[:200]}...\")\n                return\n                \n            except Exception as e:\n                if attempt < self.MAX_RETRIES - 1:\n                    logger.warning(f\"批量发送到Zep失败 (尝试 {attempt + 1}/{self.MAX_RETRIES}): {e}\")\n                    time.sleep(self.RETRY_DELAY * (attempt + 1))\n                else:\n                    logger.error(f\"批量发送到Zep失败，已重试{self.MAX_RETRIES}次: {e}\")\n                    self._failed_count += 1\n    \n    def _flush_remaining(self):\n        \"\"\"发送队列和缓冲区中剩余的活动\"\"\"\n        # 首先处理队列中剩余的活动，添加到缓冲区\n        while not self._activity_queue.empty():\n            try:\n                activity = self._activity_queue.get_nowait()\n                platform = activity.platform.lower()\n                with self._buffer_lock:\n                    if platform not in self._platform_buffers:\n                        self._platform_buffers[platform] = []\n                    self._platform_buffers[platform].append(activity)\n            except Empty:\n                break\n        \n        # 然后发送各平台缓冲区中剩余的活动（即使不足BATCH_SIZE条）\n        with self._buffer_lock:\n            for platform, buffer in self._platform_buffers.items():\n                if buffer:\n                    display_name = self._get_platform_display_name(platform)\n                    logger.info(f\"发送{display_name}平台剩余的 {len(buffer)} 条活动\")\n                    self._send_batch_activities(buffer, platform)\n            # 清空所有缓冲区\n            for platform in self._platform_buffers:\n                self._platform_buffers[platform] = []\n    \n    def get_stats(self) -> Dict[str, Any]:\n        \"\"\"获取统计信息\"\"\"\n        with self._buffer_lock:\n            buffer_sizes = {p: len(b) for p, b in self._platform_buffers.items()}\n        \n        return {\n            \"graph_id\": self.graph_id,\n            \"batch_size\": self.BATCH_SIZE,\n            \"total_activities\": self._total_activities,  # 添加到队列的活动总数\n            \"batches_sent\": self._total_sent,            # 成功发送的批次数\n            \"items_sent\": self._total_items_sent,        # 成功发送的活动条数\n            \"failed_count\": self._failed_count,          # 发送失败的批次数\n            \"skipped_count\": self._skipped_count,        # 被过滤跳过的活动数（DO_NOTHING）\n            \"queue_size\": self._activity_queue.qsize(),\n            \"buffer_sizes\": buffer_sizes,                # 各平台缓冲区大小\n            \"running\": self._running,\n        }\n\n\nclass ZepGraphMemoryManager:\n    \"\"\"\n    管理多个模拟的Zep图谱记忆更新器\n    \n    每个模拟可以有自己的更新器实例\n    \"\"\"\n    \n    _updaters: Dict[str, ZepGraphMemoryUpdater] = {}\n    _lock = threading.Lock()\n    \n    @classmethod\n    def create_updater(cls, simulation_id: str, graph_id: str) -> ZepGraphMemoryUpdater:\n        \"\"\"\n        为模拟创建图谱记忆更新器\n        \n        Args:\n            simulation_id: 模拟ID\n            graph_id: Zep图谱ID\n            \n        Returns:\n            ZepGraphMemoryUpdater实例\n        \"\"\"\n        with cls._lock:\n            # 如果已存在，先停止旧的\n            if simulation_id in cls._updaters:\n                cls._updaters[simulation_id].stop()\n            \n            updater = ZepGraphMemoryUpdater(graph_id)\n            updater.start()\n            cls._updaters[simulation_id] = updater\n            \n            logger.info(f\"创建图谱记忆更新器: simulation_id={simulation_id}, graph_id={graph_id}\")\n            return updater\n    \n    @classmethod\n    def get_updater(cls, simulation_id: str) -> Optional[ZepGraphMemoryUpdater]:\n        \"\"\"获取模拟的更新器\"\"\"\n        return cls._updaters.get(simulation_id)\n    \n    @classmethod\n    def stop_updater(cls, simulation_id: str):\n        \"\"\"停止并移除模拟的更新器\"\"\"\n        with cls._lock:\n            if simulation_id in cls._updaters:\n                cls._updaters[simulation_id].stop()\n                del cls._updaters[simulation_id]\n                logger.info(f\"已停止图谱记忆更新器: simulation_id={simulation_id}\")\n    \n    # 防止 stop_all 重复调用的标志\n    _stop_all_done = False\n    \n    @classmethod\n    def stop_all(cls):\n        \"\"\"停止所有更新器\"\"\"\n        # 防止重复调用\n        if cls._stop_all_done:\n            return\n        cls._stop_all_done = True\n        \n        with cls._lock:\n            if cls._updaters:\n                for simulation_id, updater in list(cls._updaters.items()):\n                    try:\n                        updater.stop()\n                    except Exception as e:\n                        logger.error(f\"停止更新器失败: simulation_id={simulation_id}, error={e}\")\n                cls._updaters.clear()\n            logger.info(\"已停止所有图谱记忆更新器\")\n    \n    @classmethod\n    def get_all_stats(cls) -> Dict[str, Dict[str, Any]]:\n        \"\"\"获取所有更新器的统计信息\"\"\"\n        return {\n            sim_id: updater.get_stats() \n            for sim_id, updater in cls._updaters.items()\n        }\n"
  },
  {
    "path": "backend/app/services/zep_tools.py",
    "content": "\"\"\"\nZep检索工具服务\n封装图谱搜索、节点读取、边查询等工具，供Report Agent使用\n\n核心检索工具（优化后）：\n1. InsightForge（深度洞察检索）- 最强大的混合检索，自动生成子问题并多维度检索\n2. PanoramaSearch（广度搜索）- 获取全貌，包括过期内容\n3. QuickSearch（简单搜索）- 快速检索\n\"\"\"\n\nimport time\nimport json\nfrom typing import Dict, Any, List, Optional\nfrom dataclasses import dataclass, field\n\nfrom zep_cloud.client import Zep\n\nfrom ..config import Config\nfrom ..utils.logger import get_logger\nfrom ..utils.llm_client import LLMClient\nfrom ..utils.zep_paging import fetch_all_nodes, fetch_all_edges\n\nlogger = get_logger('mirofish.zep_tools')\n\n\n@dataclass\nclass SearchResult:\n    \"\"\"搜索结果\"\"\"\n    facts: List[str]\n    edges: List[Dict[str, Any]]\n    nodes: List[Dict[str, Any]]\n    query: str\n    total_count: int\n    \n    def to_dict(self) -> Dict[str, Any]:\n        return {\n            \"facts\": self.facts,\n            \"edges\": self.edges,\n            \"nodes\": self.nodes,\n            \"query\": self.query,\n            \"total_count\": self.total_count\n        }\n    \n    def to_text(self) -> str:\n        \"\"\"转换为文本格式，供LLM理解\"\"\"\n        text_parts = [f\"搜索查询: {self.query}\", f\"找到 {self.total_count} 条相关信息\"]\n        \n        if self.facts:\n            text_parts.append(\"\\n### 相关事实:\")\n            for i, fact in enumerate(self.facts, 1):\n                text_parts.append(f\"{i}. {fact}\")\n        \n        return \"\\n\".join(text_parts)\n\n\n@dataclass\nclass NodeInfo:\n    \"\"\"节点信息\"\"\"\n    uuid: str\n    name: str\n    labels: List[str]\n    summary: str\n    attributes: Dict[str, Any]\n    \n    def to_dict(self) -> Dict[str, Any]:\n        return {\n            \"uuid\": self.uuid,\n            \"name\": self.name,\n            \"labels\": self.labels,\n            \"summary\": self.summary,\n            \"attributes\": self.attributes\n        }\n    \n    def to_text(self) -> str:\n        \"\"\"转换为文本格式\"\"\"\n        entity_type = next((l for l in self.labels if l not in [\"Entity\", \"Node\"]), \"未知类型\")\n        return f\"实体: {self.name} (类型: {entity_type})\\n摘要: {self.summary}\"\n\n\n@dataclass\nclass EdgeInfo:\n    \"\"\"边信息\"\"\"\n    uuid: str\n    name: str\n    fact: str\n    source_node_uuid: str\n    target_node_uuid: str\n    source_node_name: Optional[str] = None\n    target_node_name: Optional[str] = None\n    # 时间信息\n    created_at: Optional[str] = None\n    valid_at: Optional[str] = None\n    invalid_at: Optional[str] = None\n    expired_at: Optional[str] = None\n    \n    def to_dict(self) -> Dict[str, Any]:\n        return {\n            \"uuid\": self.uuid,\n            \"name\": self.name,\n            \"fact\": self.fact,\n            \"source_node_uuid\": self.source_node_uuid,\n            \"target_node_uuid\": self.target_node_uuid,\n            \"source_node_name\": self.source_node_name,\n            \"target_node_name\": self.target_node_name,\n            \"created_at\": self.created_at,\n            \"valid_at\": self.valid_at,\n            \"invalid_at\": self.invalid_at,\n            \"expired_at\": self.expired_at\n        }\n    \n    def to_text(self, include_temporal: bool = False) -> str:\n        \"\"\"转换为文本格式\"\"\"\n        source = self.source_node_name or self.source_node_uuid[:8]\n        target = self.target_node_name or self.target_node_uuid[:8]\n        base_text = f\"关系: {source} --[{self.name}]--> {target}\\n事实: {self.fact}\"\n        \n        if include_temporal:\n            valid_at = self.valid_at or \"未知\"\n            invalid_at = self.invalid_at or \"至今\"\n            base_text += f\"\\n时效: {valid_at} - {invalid_at}\"\n            if self.expired_at:\n                base_text += f\" (已过期: {self.expired_at})\"\n        \n        return base_text\n    \n    @property\n    def is_expired(self) -> bool:\n        \"\"\"是否已过期\"\"\"\n        return self.expired_at is not None\n    \n    @property\n    def is_invalid(self) -> bool:\n        \"\"\"是否已失效\"\"\"\n        return self.invalid_at is not None\n\n\n@dataclass\nclass InsightForgeResult:\n    \"\"\"\n    深度洞察检索结果 (InsightForge)\n    包含多个子问题的检索结果，以及综合分析\n    \"\"\"\n    query: str\n    simulation_requirement: str\n    sub_queries: List[str]\n    \n    # 各维度检索结果\n    semantic_facts: List[str] = field(default_factory=list)  # 语义搜索结果\n    entity_insights: List[Dict[str, Any]] = field(default_factory=list)  # 实体洞察\n    relationship_chains: List[str] = field(default_factory=list)  # 关系链\n    \n    # 统计信息\n    total_facts: int = 0\n    total_entities: int = 0\n    total_relationships: int = 0\n    \n    def to_dict(self) -> Dict[str, Any]:\n        return {\n            \"query\": self.query,\n            \"simulation_requirement\": self.simulation_requirement,\n            \"sub_queries\": self.sub_queries,\n            \"semantic_facts\": self.semantic_facts,\n            \"entity_insights\": self.entity_insights,\n            \"relationship_chains\": self.relationship_chains,\n            \"total_facts\": self.total_facts,\n            \"total_entities\": self.total_entities,\n            \"total_relationships\": self.total_relationships\n        }\n    \n    def to_text(self) -> str:\n        \"\"\"转换为详细的文本格式，供LLM理解\"\"\"\n        text_parts = [\n            f\"## 未来预测深度分析\",\n            f\"分析问题: {self.query}\",\n            f\"预测场景: {self.simulation_requirement}\",\n            f\"\\n### 预测数据统计\",\n            f\"- 相关预测事实: {self.total_facts}条\",\n            f\"- 涉及实体: {self.total_entities}个\",\n            f\"- 关系链: {self.total_relationships}条\"\n        ]\n        \n        # 子问题\n        if self.sub_queries:\n            text_parts.append(f\"\\n### 分析的子问题\")\n            for i, sq in enumerate(self.sub_queries, 1):\n                text_parts.append(f\"{i}. {sq}\")\n        \n        # 语义搜索结果\n        if self.semantic_facts:\n            text_parts.append(f\"\\n### 【关键事实】(请在报告中引用这些原文)\")\n            for i, fact in enumerate(self.semantic_facts, 1):\n                text_parts.append(f\"{i}. \\\"{fact}\\\"\")\n        \n        # 实体洞察\n        if self.entity_insights:\n            text_parts.append(f\"\\n### 【核心实体】\")\n            for entity in self.entity_insights:\n                text_parts.append(f\"- **{entity.get('name', '未知')}** ({entity.get('type', '实体')})\")\n                if entity.get('summary'):\n                    text_parts.append(f\"  摘要: \\\"{entity.get('summary')}\\\"\")\n                if entity.get('related_facts'):\n                    text_parts.append(f\"  相关事实: {len(entity.get('related_facts', []))}条\")\n        \n        # 关系链\n        if self.relationship_chains:\n            text_parts.append(f\"\\n### 【关系链】\")\n            for chain in self.relationship_chains:\n                text_parts.append(f\"- {chain}\")\n        \n        return \"\\n\".join(text_parts)\n\n\n@dataclass\nclass PanoramaResult:\n    \"\"\"\n    广度搜索结果 (Panorama)\n    包含所有相关信息，包括过期内容\n    \"\"\"\n    query: str\n    \n    # 全部节点\n    all_nodes: List[NodeInfo] = field(default_factory=list)\n    # 全部边（包括过期的）\n    all_edges: List[EdgeInfo] = field(default_factory=list)\n    # 当前有效的事实\n    active_facts: List[str] = field(default_factory=list)\n    # 已过期/失效的事实（历史记录）\n    historical_facts: List[str] = field(default_factory=list)\n    \n    # 统计\n    total_nodes: int = 0\n    total_edges: int = 0\n    active_count: int = 0\n    historical_count: int = 0\n    \n    def to_dict(self) -> Dict[str, Any]:\n        return {\n            \"query\": self.query,\n            \"all_nodes\": [n.to_dict() for n in self.all_nodes],\n            \"all_edges\": [e.to_dict() for e in self.all_edges],\n            \"active_facts\": self.active_facts,\n            \"historical_facts\": self.historical_facts,\n            \"total_nodes\": self.total_nodes,\n            \"total_edges\": self.total_edges,\n            \"active_count\": self.active_count,\n            \"historical_count\": self.historical_count\n        }\n    \n    def to_text(self) -> str:\n        \"\"\"转换为文本格式（完整版本，不截断）\"\"\"\n        text_parts = [\n            f\"## 广度搜索结果（未来全景视图）\",\n            f\"查询: {self.query}\",\n            f\"\\n### 统计信息\",\n            f\"- 总节点数: {self.total_nodes}\",\n            f\"- 总边数: {self.total_edges}\",\n            f\"- 当前有效事实: {self.active_count}条\",\n            f\"- 历史/过期事实: {self.historical_count}条\"\n        ]\n        \n        # 当前有效的事实（完整输出，不截断）\n        if self.active_facts:\n            text_parts.append(f\"\\n### 【当前有效事实】(模拟结果原文)\")\n            for i, fact in enumerate(self.active_facts, 1):\n                text_parts.append(f\"{i}. \\\"{fact}\\\"\")\n        \n        # 历史/过期事实（完整输出，不截断）\n        if self.historical_facts:\n            text_parts.append(f\"\\n### 【历史/过期事实】(演变过程记录)\")\n            for i, fact in enumerate(self.historical_facts, 1):\n                text_parts.append(f\"{i}. \\\"{fact}\\\"\")\n        \n        # 关键实体（完整输出，不截断）\n        if self.all_nodes:\n            text_parts.append(f\"\\n### 【涉及实体】\")\n            for node in self.all_nodes:\n                entity_type = next((l for l in node.labels if l not in [\"Entity\", \"Node\"]), \"实体\")\n                text_parts.append(f\"- **{node.name}** ({entity_type})\")\n        \n        return \"\\n\".join(text_parts)\n\n\n@dataclass\nclass AgentInterview:\n    \"\"\"单个Agent的采访结果\"\"\"\n    agent_name: str\n    agent_role: str  # 角色类型（如：学生、教师、媒体等）\n    agent_bio: str  # 简介\n    question: str  # 采访问题\n    response: str  # 采访回答\n    key_quotes: List[str] = field(default_factory=list)  # 关键引言\n    \n    def to_dict(self) -> Dict[str, Any]:\n        return {\n            \"agent_name\": self.agent_name,\n            \"agent_role\": self.agent_role,\n            \"agent_bio\": self.agent_bio,\n            \"question\": self.question,\n            \"response\": self.response,\n            \"key_quotes\": self.key_quotes\n        }\n    \n    def to_text(self) -> str:\n        text = f\"**{self.agent_name}** ({self.agent_role})\\n\"\n        # 显示完整的agent_bio，不截断\n        text += f\"_简介: {self.agent_bio}_\\n\\n\"\n        text += f\"**Q:** {self.question}\\n\\n\"\n        text += f\"**A:** {self.response}\\n\"\n        if self.key_quotes:\n            text += \"\\n**关键引言:**\\n\"\n            for quote in self.key_quotes:\n                # 清理各种引号\n                clean_quote = quote.replace('\\u201c', '').replace('\\u201d', '').replace('\"', '')\n                clean_quote = clean_quote.replace('\\u300c', '').replace('\\u300d', '')\n                clean_quote = clean_quote.strip()\n                # 去掉开头的标点\n                while clean_quote and clean_quote[0] in '，,；;：:、。！？\\n\\r\\t ':\n                    clean_quote = clean_quote[1:]\n                # 过滤包含问题编号的垃圾内容（问题1-9）\n                skip = False\n                for d in '123456789':\n                    if f'\\u95ee\\u9898{d}' in clean_quote:\n                        skip = True\n                        break\n                if skip:\n                    continue\n                # 截断过长内容（按句号截断，而非硬截断）\n                if len(clean_quote) > 150:\n                    dot_pos = clean_quote.find('\\u3002', 80)\n                    if dot_pos > 0:\n                        clean_quote = clean_quote[:dot_pos + 1]\n                    else:\n                        clean_quote = clean_quote[:147] + \"...\"\n                if clean_quote and len(clean_quote) >= 10:\n                    text += f'> \"{clean_quote}\"\\n'\n        return text\n\n\n@dataclass\nclass InterviewResult:\n    \"\"\"\n    采访结果 (Interview)\n    包含多个模拟Agent的采访回答\n    \"\"\"\n    interview_topic: str  # 采访主题\n    interview_questions: List[str]  # 采访问题列表\n    \n    # 采访选择的Agent\n    selected_agents: List[Dict[str, Any]] = field(default_factory=list)\n    # 各Agent的采访回答\n    interviews: List[AgentInterview] = field(default_factory=list)\n    \n    # 选择Agent的理由\n    selection_reasoning: str = \"\"\n    # 整合后的采访摘要\n    summary: str = \"\"\n    \n    # 统计\n    total_agents: int = 0\n    interviewed_count: int = 0\n    \n    def to_dict(self) -> Dict[str, Any]:\n        return {\n            \"interview_topic\": self.interview_topic,\n            \"interview_questions\": self.interview_questions,\n            \"selected_agents\": self.selected_agents,\n            \"interviews\": [i.to_dict() for i in self.interviews],\n            \"selection_reasoning\": self.selection_reasoning,\n            \"summary\": self.summary,\n            \"total_agents\": self.total_agents,\n            \"interviewed_count\": self.interviewed_count\n        }\n    \n    def to_text(self) -> str:\n        \"\"\"转换为详细的文本格式，供LLM理解和报告引用\"\"\"\n        text_parts = [\n            \"## 深度采访报告\",\n            f\"**采访主题:** {self.interview_topic}\",\n            f\"**采访人数:** {self.interviewed_count} / {self.total_agents} 位模拟Agent\",\n            \"\\n### 采访对象选择理由\",\n            self.selection_reasoning or \"（自动选择）\",\n            \"\\n---\",\n            \"\\n### 采访实录\",\n        ]\n\n        if self.interviews:\n            for i, interview in enumerate(self.interviews, 1):\n                text_parts.append(f\"\\n#### 采访 #{i}: {interview.agent_name}\")\n                text_parts.append(interview.to_text())\n                text_parts.append(\"\\n---\")\n        else:\n            text_parts.append(\"（无采访记录）\\n\\n---\")\n\n        text_parts.append(\"\\n### 采访摘要与核心观点\")\n        text_parts.append(self.summary or \"（无摘要）\")\n\n        return \"\\n\".join(text_parts)\n\n\nclass ZepToolsService:\n    \"\"\"\n    Zep检索工具服务\n    \n    【核心检索工具 - 优化后】\n    1. insight_forge - 深度洞察检索（最强大，自动生成子问题，多维度检索）\n    2. panorama_search - 广度搜索（获取全貌，包括过期内容）\n    3. quick_search - 简单搜索（快速检索）\n    4. interview_agents - 深度采访（采访模拟Agent，获取多视角观点）\n    \n    【基础工具】\n    - search_graph - 图谱语义搜索\n    - get_all_nodes - 获取图谱所有节点\n    - get_all_edges - 获取图谱所有边（含时间信息）\n    - get_node_detail - 获取节点详细信息\n    - get_node_edges - 获取节点相关的边\n    - get_entities_by_type - 按类型获取实体\n    - get_entity_summary - 获取实体的关系摘要\n    \"\"\"\n    \n    # 重试配置\n    MAX_RETRIES = 3\n    RETRY_DELAY = 2.0\n    \n    def __init__(self, api_key: Optional[str] = None, llm_client: Optional[LLMClient] = None):\n        self.api_key = api_key or Config.ZEP_API_KEY\n        if not self.api_key:\n            raise ValueError(\"ZEP_API_KEY 未配置\")\n        \n        self.client = Zep(api_key=self.api_key)\n        # LLM客户端用于InsightForge生成子问题\n        self._llm_client = llm_client\n        logger.info(\"ZepToolsService 初始化完成\")\n    \n    @property\n    def llm(self) -> LLMClient:\n        \"\"\"延迟初始化LLM客户端\"\"\"\n        if self._llm_client is None:\n            self._llm_client = LLMClient()\n        return self._llm_client\n    \n    def _call_with_retry(self, func, operation_name: str, max_retries: int = None):\n        \"\"\"带重试机制的API调用\"\"\"\n        max_retries = max_retries or self.MAX_RETRIES\n        last_exception = None\n        delay = self.RETRY_DELAY\n        \n        for attempt in range(max_retries):\n            try:\n                return func()\n            except Exception as e:\n                last_exception = e\n                if attempt < max_retries - 1:\n                    logger.warning(\n                        f\"Zep {operation_name} 第 {attempt + 1} 次尝试失败: {str(e)[:100]}, \"\n                        f\"{delay:.1f}秒后重试...\"\n                    )\n                    time.sleep(delay)\n                    delay *= 2\n                else:\n                    logger.error(f\"Zep {operation_name} 在 {max_retries} 次尝试后仍失败: {str(e)}\")\n        \n        raise last_exception\n    \n    def search_graph(\n        self, \n        graph_id: str, \n        query: str, \n        limit: int = 10,\n        scope: str = \"edges\"\n    ) -> SearchResult:\n        \"\"\"\n        图谱语义搜索\n        \n        使用混合搜索（语义+BM25）在图谱中搜索相关信息。\n        如果Zep Cloud的search API不可用，则降级为本地关键词匹配。\n        \n        Args:\n            graph_id: 图谱ID (Standalone Graph)\n            query: 搜索查询\n            limit: 返回结果数量\n            scope: 搜索范围，\"edges\" 或 \"nodes\"\n            \n        Returns:\n            SearchResult: 搜索结果\n        \"\"\"\n        logger.info(f\"图谱搜索: graph_id={graph_id}, query={query[:50]}...\")\n        \n        # 尝试使用Zep Cloud Search API\n        try:\n            search_results = self._call_with_retry(\n                func=lambda: self.client.graph.search(\n                    graph_id=graph_id,\n                    query=query,\n                    limit=limit,\n                    scope=scope,\n                    reranker=\"cross_encoder\"\n                ),\n                operation_name=f\"图谱搜索(graph={graph_id})\"\n            )\n            \n            facts = []\n            edges = []\n            nodes = []\n            \n            # 解析边搜索结果\n            if hasattr(search_results, 'edges') and search_results.edges:\n                for edge in search_results.edges:\n                    if hasattr(edge, 'fact') and edge.fact:\n                        facts.append(edge.fact)\n                    edges.append({\n                        \"uuid\": getattr(edge, 'uuid_', None) or getattr(edge, 'uuid', ''),\n                        \"name\": getattr(edge, 'name', ''),\n                        \"fact\": getattr(edge, 'fact', ''),\n                        \"source_node_uuid\": getattr(edge, 'source_node_uuid', ''),\n                        \"target_node_uuid\": getattr(edge, 'target_node_uuid', ''),\n                    })\n            \n            # 解析节点搜索结果\n            if hasattr(search_results, 'nodes') and search_results.nodes:\n                for node in search_results.nodes:\n                    nodes.append({\n                        \"uuid\": getattr(node, 'uuid_', None) or getattr(node, 'uuid', ''),\n                        \"name\": getattr(node, 'name', ''),\n                        \"labels\": getattr(node, 'labels', []),\n                        \"summary\": getattr(node, 'summary', ''),\n                    })\n                    # 节点摘要也算作事实\n                    if hasattr(node, 'summary') and node.summary:\n                        facts.append(f\"[{node.name}]: {node.summary}\")\n            \n            logger.info(f\"搜索完成: 找到 {len(facts)} 条相关事实\")\n            \n            return SearchResult(\n                facts=facts,\n                edges=edges,\n                nodes=nodes,\n                query=query,\n                total_count=len(facts)\n            )\n            \n        except Exception as e:\n            logger.warning(f\"Zep Search API失败，降级为本地搜索: {str(e)}\")\n            # 降级：使用本地关键词匹配搜索\n            return self._local_search(graph_id, query, limit, scope)\n    \n    def _local_search(\n        self, \n        graph_id: str, \n        query: str, \n        limit: int = 10,\n        scope: str = \"edges\"\n    ) -> SearchResult:\n        \"\"\"\n        本地关键词匹配搜索（作为Zep Search API的降级方案）\n        \n        获取所有边/节点，然后在本地进行关键词匹配\n        \n        Args:\n            graph_id: 图谱ID\n            query: 搜索查询\n            limit: 返回结果数量\n            scope: 搜索范围\n            \n        Returns:\n            SearchResult: 搜索结果\n        \"\"\"\n        logger.info(f\"使用本地搜索: query={query[:30]}...\")\n        \n        facts = []\n        edges_result = []\n        nodes_result = []\n        \n        # 提取查询关键词（简单分词）\n        query_lower = query.lower()\n        keywords = [w.strip() for w in query_lower.replace(',', ' ').replace('，', ' ').split() if len(w.strip()) > 1]\n        \n        def match_score(text: str) -> int:\n            \"\"\"计算文本与查询的匹配分数\"\"\"\n            if not text:\n                return 0\n            text_lower = text.lower()\n            # 完全匹配查询\n            if query_lower in text_lower:\n                return 100\n            # 关键词匹配\n            score = 0\n            for keyword in keywords:\n                if keyword in text_lower:\n                    score += 10\n            return score\n        \n        try:\n            if scope in [\"edges\", \"both\"]:\n                # 获取所有边并匹配\n                all_edges = self.get_all_edges(graph_id)\n                scored_edges = []\n                for edge in all_edges:\n                    score = match_score(edge.fact) + match_score(edge.name)\n                    if score > 0:\n                        scored_edges.append((score, edge))\n                \n                # 按分数排序\n                scored_edges.sort(key=lambda x: x[0], reverse=True)\n                \n                for score, edge in scored_edges[:limit]:\n                    if edge.fact:\n                        facts.append(edge.fact)\n                    edges_result.append({\n                        \"uuid\": edge.uuid,\n                        \"name\": edge.name,\n                        \"fact\": edge.fact,\n                        \"source_node_uuid\": edge.source_node_uuid,\n                        \"target_node_uuid\": edge.target_node_uuid,\n                    })\n            \n            if scope in [\"nodes\", \"both\"]:\n                # 获取所有节点并匹配\n                all_nodes = self.get_all_nodes(graph_id)\n                scored_nodes = []\n                for node in all_nodes:\n                    score = match_score(node.name) + match_score(node.summary)\n                    if score > 0:\n                        scored_nodes.append((score, node))\n                \n                scored_nodes.sort(key=lambda x: x[0], reverse=True)\n                \n                for score, node in scored_nodes[:limit]:\n                    nodes_result.append({\n                        \"uuid\": node.uuid,\n                        \"name\": node.name,\n                        \"labels\": node.labels,\n                        \"summary\": node.summary,\n                    })\n                    if node.summary:\n                        facts.append(f\"[{node.name}]: {node.summary}\")\n            \n            logger.info(f\"本地搜索完成: 找到 {len(facts)} 条相关事实\")\n            \n        except Exception as e:\n            logger.error(f\"本地搜索失败: {str(e)}\")\n        \n        return SearchResult(\n            facts=facts,\n            edges=edges_result,\n            nodes=nodes_result,\n            query=query,\n            total_count=len(facts)\n        )\n    \n    def get_all_nodes(self, graph_id: str) -> List[NodeInfo]:\n        \"\"\"\n        获取图谱的所有节点（分页获取）\n\n        Args:\n            graph_id: 图谱ID\n\n        Returns:\n            节点列表\n        \"\"\"\n        logger.info(f\"获取图谱 {graph_id} 的所有节点...\")\n\n        nodes = fetch_all_nodes(self.client, graph_id)\n\n        result = []\n        for node in nodes:\n            node_uuid = getattr(node, 'uuid_', None) or getattr(node, 'uuid', None) or \"\"\n            result.append(NodeInfo(\n                uuid=str(node_uuid) if node_uuid else \"\",\n                name=node.name or \"\",\n                labels=node.labels or [],\n                summary=node.summary or \"\",\n                attributes=node.attributes or {}\n            ))\n\n        logger.info(f\"获取到 {len(result)} 个节点\")\n        return result\n\n    def get_all_edges(self, graph_id: str, include_temporal: bool = True) -> List[EdgeInfo]:\n        \"\"\"\n        获取图谱的所有边（分页获取，包含时间信息）\n\n        Args:\n            graph_id: 图谱ID\n            include_temporal: 是否包含时间信息（默认True）\n\n        Returns:\n            边列表（包含created_at, valid_at, invalid_at, expired_at）\n        \"\"\"\n        logger.info(f\"获取图谱 {graph_id} 的所有边...\")\n\n        edges = fetch_all_edges(self.client, graph_id)\n\n        result = []\n        for edge in edges:\n            edge_uuid = getattr(edge, 'uuid_', None) or getattr(edge, 'uuid', None) or \"\"\n            edge_info = EdgeInfo(\n                uuid=str(edge_uuid) if edge_uuid else \"\",\n                name=edge.name or \"\",\n                fact=edge.fact or \"\",\n                source_node_uuid=edge.source_node_uuid or \"\",\n                target_node_uuid=edge.target_node_uuid or \"\"\n            )\n\n            # 添加时间信息\n            if include_temporal:\n                edge_info.created_at = getattr(edge, 'created_at', None)\n                edge_info.valid_at = getattr(edge, 'valid_at', None)\n                edge_info.invalid_at = getattr(edge, 'invalid_at', None)\n                edge_info.expired_at = getattr(edge, 'expired_at', None)\n\n            result.append(edge_info)\n\n        logger.info(f\"获取到 {len(result)} 条边\")\n        return result\n    \n    def get_node_detail(self, node_uuid: str) -> Optional[NodeInfo]:\n        \"\"\"\n        获取单个节点的详细信息\n        \n        Args:\n            node_uuid: 节点UUID\n            \n        Returns:\n            节点信息或None\n        \"\"\"\n        logger.info(f\"获取节点详情: {node_uuid[:8]}...\")\n        \n        try:\n            node = self._call_with_retry(\n                func=lambda: self.client.graph.node.get(uuid_=node_uuid),\n                operation_name=f\"获取节点详情(uuid={node_uuid[:8]}...)\"\n            )\n            \n            if not node:\n                return None\n            \n            return NodeInfo(\n                uuid=getattr(node, 'uuid_', None) or getattr(node, 'uuid', ''),\n                name=node.name or \"\",\n                labels=node.labels or [],\n                summary=node.summary or \"\",\n                attributes=node.attributes or {}\n            )\n        except Exception as e:\n            logger.error(f\"获取节点详情失败: {str(e)}\")\n            return None\n    \n    def get_node_edges(self, graph_id: str, node_uuid: str) -> List[EdgeInfo]:\n        \"\"\"\n        获取节点相关的所有边\n        \n        通过获取图谱所有边，然后过滤出与指定节点相关的边\n        \n        Args:\n            graph_id: 图谱ID\n            node_uuid: 节点UUID\n            \n        Returns:\n            边列表\n        \"\"\"\n        logger.info(f\"获取节点 {node_uuid[:8]}... 的相关边\")\n        \n        try:\n            # 获取图谱所有边，然后过滤\n            all_edges = self.get_all_edges(graph_id)\n            \n            result = []\n            for edge in all_edges:\n                # 检查边是否与指定节点相关（作为源或目标）\n                if edge.source_node_uuid == node_uuid or edge.target_node_uuid == node_uuid:\n                    result.append(edge)\n            \n            logger.info(f\"找到 {len(result)} 条与节点相关的边\")\n            return result\n            \n        except Exception as e:\n            logger.warning(f\"获取节点边失败: {str(e)}\")\n            return []\n    \n    def get_entities_by_type(\n        self, \n        graph_id: str, \n        entity_type: str\n    ) -> List[NodeInfo]:\n        \"\"\"\n        按类型获取实体\n        \n        Args:\n            graph_id: 图谱ID\n            entity_type: 实体类型（如 Student, PublicFigure 等）\n            \n        Returns:\n            符合类型的实体列表\n        \"\"\"\n        logger.info(f\"获取类型为 {entity_type} 的实体...\")\n        \n        all_nodes = self.get_all_nodes(graph_id)\n        \n        filtered = []\n        for node in all_nodes:\n            # 检查labels是否包含指定类型\n            if entity_type in node.labels:\n                filtered.append(node)\n        \n        logger.info(f\"找到 {len(filtered)} 个 {entity_type} 类型的实体\")\n        return filtered\n    \n    def get_entity_summary(\n        self, \n        graph_id: str, \n        entity_name: str\n    ) -> Dict[str, Any]:\n        \"\"\"\n        获取指定实体的关系摘要\n        \n        搜索与该实体相关的所有信息，并生成摘要\n        \n        Args:\n            graph_id: 图谱ID\n            entity_name: 实体名称\n            \n        Returns:\n            实体摘要信息\n        \"\"\"\n        logger.info(f\"获取实体 {entity_name} 的关系摘要...\")\n        \n        # 先搜索该实体相关的信息\n        search_result = self.search_graph(\n            graph_id=graph_id,\n            query=entity_name,\n            limit=20\n        )\n        \n        # 尝试在所有节点中找到该实体\n        all_nodes = self.get_all_nodes(graph_id)\n        entity_node = None\n        for node in all_nodes:\n            if node.name.lower() == entity_name.lower():\n                entity_node = node\n                break\n        \n        related_edges = []\n        if entity_node:\n            # 传入graph_id参数\n            related_edges = self.get_node_edges(graph_id, entity_node.uuid)\n        \n        return {\n            \"entity_name\": entity_name,\n            \"entity_info\": entity_node.to_dict() if entity_node else None,\n            \"related_facts\": search_result.facts,\n            \"related_edges\": [e.to_dict() for e in related_edges],\n            \"total_relations\": len(related_edges)\n        }\n    \n    def get_graph_statistics(self, graph_id: str) -> Dict[str, Any]:\n        \"\"\"\n        获取图谱的统计信息\n        \n        Args:\n            graph_id: 图谱ID\n            \n        Returns:\n            统计信息\n        \"\"\"\n        logger.info(f\"获取图谱 {graph_id} 的统计信息...\")\n        \n        nodes = self.get_all_nodes(graph_id)\n        edges = self.get_all_edges(graph_id)\n        \n        # 统计实体类型分布\n        entity_types = {}\n        for node in nodes:\n            for label in node.labels:\n                if label not in [\"Entity\", \"Node\"]:\n                    entity_types[label] = entity_types.get(label, 0) + 1\n        \n        # 统计关系类型分布\n        relation_types = {}\n        for edge in edges:\n            relation_types[edge.name] = relation_types.get(edge.name, 0) + 1\n        \n        return {\n            \"graph_id\": graph_id,\n            \"total_nodes\": len(nodes),\n            \"total_edges\": len(edges),\n            \"entity_types\": entity_types,\n            \"relation_types\": relation_types\n        }\n    \n    def get_simulation_context(\n        self, \n        graph_id: str,\n        simulation_requirement: str,\n        limit: int = 30\n    ) -> Dict[str, Any]:\n        \"\"\"\n        获取模拟相关的上下文信息\n        \n        综合搜索与模拟需求相关的所有信息\n        \n        Args:\n            graph_id: 图谱ID\n            simulation_requirement: 模拟需求描述\n            limit: 每类信息的数量限制\n            \n        Returns:\n            模拟上下文信息\n        \"\"\"\n        logger.info(f\"获取模拟上下文: {simulation_requirement[:50]}...\")\n        \n        # 搜索与模拟需求相关的信息\n        search_result = self.search_graph(\n            graph_id=graph_id,\n            query=simulation_requirement,\n            limit=limit\n        )\n        \n        # 获取图谱统计\n        stats = self.get_graph_statistics(graph_id)\n        \n        # 获取所有实体节点\n        all_nodes = self.get_all_nodes(graph_id)\n        \n        # 筛选有实际类型的实体（非纯Entity节点）\n        entities = []\n        for node in all_nodes:\n            custom_labels = [l for l in node.labels if l not in [\"Entity\", \"Node\"]]\n            if custom_labels:\n                entities.append({\n                    \"name\": node.name,\n                    \"type\": custom_labels[0],\n                    \"summary\": node.summary\n                })\n        \n        return {\n            \"simulation_requirement\": simulation_requirement,\n            \"related_facts\": search_result.facts,\n            \"graph_statistics\": stats,\n            \"entities\": entities[:limit],  # 限制数量\n            \"total_entities\": len(entities)\n        }\n    \n    # ========== 核心检索工具（优化后） ==========\n    \n    def insight_forge(\n        self,\n        graph_id: str,\n        query: str,\n        simulation_requirement: str,\n        report_context: str = \"\",\n        max_sub_queries: int = 5\n    ) -> InsightForgeResult:\n        \"\"\"\n        【InsightForge - 深度洞察检索】\n        \n        最强大的混合检索函数，自动分解问题并多维度检索：\n        1. 使用LLM将问题分解为多个子问题\n        2. 对每个子问题进行语义搜索\n        3. 提取相关实体并获取其详细信息\n        4. 追踪关系链\n        5. 整合所有结果，生成深度洞察\n        \n        Args:\n            graph_id: 图谱ID\n            query: 用户问题\n            simulation_requirement: 模拟需求描述\n            report_context: 报告上下文（可选，用于更精准的子问题生成）\n            max_sub_queries: 最大子问题数量\n            \n        Returns:\n            InsightForgeResult: 深度洞察检索结果\n        \"\"\"\n        logger.info(f\"InsightForge 深度洞察检索: {query[:50]}...\")\n        \n        result = InsightForgeResult(\n            query=query,\n            simulation_requirement=simulation_requirement,\n            sub_queries=[]\n        )\n        \n        # Step 1: 使用LLM生成子问题\n        sub_queries = self._generate_sub_queries(\n            query=query,\n            simulation_requirement=simulation_requirement,\n            report_context=report_context,\n            max_queries=max_sub_queries\n        )\n        result.sub_queries = sub_queries\n        logger.info(f\"生成 {len(sub_queries)} 个子问题\")\n        \n        # Step 2: 对每个子问题进行语义搜索\n        all_facts = []\n        all_edges = []\n        seen_facts = set()\n        \n        for sub_query in sub_queries:\n            search_result = self.search_graph(\n                graph_id=graph_id,\n                query=sub_query,\n                limit=15,\n                scope=\"edges\"\n            )\n            \n            for fact in search_result.facts:\n                if fact not in seen_facts:\n                    all_facts.append(fact)\n                    seen_facts.add(fact)\n            \n            all_edges.extend(search_result.edges)\n        \n        # 对原始问题也进行搜索\n        main_search = self.search_graph(\n            graph_id=graph_id,\n            query=query,\n            limit=20,\n            scope=\"edges\"\n        )\n        for fact in main_search.facts:\n            if fact not in seen_facts:\n                all_facts.append(fact)\n                seen_facts.add(fact)\n        \n        result.semantic_facts = all_facts\n        result.total_facts = len(all_facts)\n        \n        # Step 3: 从边中提取相关实体UUID，只获取这些实体的信息（不获取全部节点）\n        entity_uuids = set()\n        for edge_data in all_edges:\n            if isinstance(edge_data, dict):\n                source_uuid = edge_data.get('source_node_uuid', '')\n                target_uuid = edge_data.get('target_node_uuid', '')\n                if source_uuid:\n                    entity_uuids.add(source_uuid)\n                if target_uuid:\n                    entity_uuids.add(target_uuid)\n        \n        # 获取所有相关实体的详情（不限制数量，完整输出）\n        entity_insights = []\n        node_map = {}  # 用于后续关系链构建\n        \n        for uuid in list(entity_uuids):  # 处理所有实体，不截断\n            if not uuid:\n                continue\n            try:\n                # 单独获取每个相关节点的信息\n                node = self.get_node_detail(uuid)\n                if node:\n                    node_map[uuid] = node\n                    entity_type = next((l for l in node.labels if l not in [\"Entity\", \"Node\"]), \"实体\")\n                    \n                    # 获取该实体相关的所有事实（不截断）\n                    related_facts = [\n                        f for f in all_facts \n                        if node.name.lower() in f.lower()\n                    ]\n                    \n                    entity_insights.append({\n                        \"uuid\": node.uuid,\n                        \"name\": node.name,\n                        \"type\": entity_type,\n                        \"summary\": node.summary,\n                        \"related_facts\": related_facts  # 完整输出，不截断\n                    })\n            except Exception as e:\n                logger.debug(f\"获取节点 {uuid} 失败: {e}\")\n                continue\n        \n        result.entity_insights = entity_insights\n        result.total_entities = len(entity_insights)\n        \n        # Step 4: 构建所有关系链（不限制数量）\n        relationship_chains = []\n        for edge_data in all_edges:  # 处理所有边，不截断\n            if isinstance(edge_data, dict):\n                source_uuid = edge_data.get('source_node_uuid', '')\n                target_uuid = edge_data.get('target_node_uuid', '')\n                relation_name = edge_data.get('name', '')\n                \n                source_name = node_map.get(source_uuid, NodeInfo('', '', [], '', {})).name or source_uuid[:8]\n                target_name = node_map.get(target_uuid, NodeInfo('', '', [], '', {})).name or target_uuid[:8]\n                \n                chain = f\"{source_name} --[{relation_name}]--> {target_name}\"\n                if chain not in relationship_chains:\n                    relationship_chains.append(chain)\n        \n        result.relationship_chains = relationship_chains\n        result.total_relationships = len(relationship_chains)\n        \n        logger.info(f\"InsightForge完成: {result.total_facts}条事实, {result.total_entities}个实体, {result.total_relationships}条关系\")\n        return result\n    \n    def _generate_sub_queries(\n        self,\n        query: str,\n        simulation_requirement: str,\n        report_context: str = \"\",\n        max_queries: int = 5\n    ) -> List[str]:\n        \"\"\"\n        使用LLM生成子问题\n        \n        将复杂问题分解为多个可以独立检索的子问题\n        \"\"\"\n        system_prompt = \"\"\"你是一个专业的问题分析专家。你的任务是将一个复杂问题分解为多个可以在模拟世界中独立观察的子问题。\n\n要求：\n1. 每个子问题应该足够具体，可以在模拟世界中找到相关的Agent行为或事件\n2. 子问题应该覆盖原问题的不同维度（如：谁、什么、为什么、怎么样、何时、何地）\n3. 子问题应该与模拟场景相关\n4. 返回JSON格式：{\"sub_queries\": [\"子问题1\", \"子问题2\", ...]}\"\"\"\n\n        user_prompt = f\"\"\"模拟需求背景：\n{simulation_requirement}\n\n{f\"报告上下文：{report_context[:500]}\" if report_context else \"\"}\n\n请将以下问题分解为{max_queries}个子问题：\n{query}\n\n返回JSON格式的子问题列表。\"\"\"\n\n        try:\n            response = self.llm.chat_json(\n                messages=[\n                    {\"role\": \"system\", \"content\": system_prompt},\n                    {\"role\": \"user\", \"content\": user_prompt}\n                ],\n                temperature=0.3\n            )\n            \n            sub_queries = response.get(\"sub_queries\", [])\n            # 确保是字符串列表\n            return [str(sq) for sq in sub_queries[:max_queries]]\n            \n        except Exception as e:\n            logger.warning(f\"生成子问题失败: {str(e)}，使用默认子问题\")\n            # 降级：返回基于原问题的变体\n            return [\n                query,\n                f\"{query} 的主要参与者\",\n                f\"{query} 的原因和影响\",\n                f\"{query} 的发展过程\"\n            ][:max_queries]\n    \n    def panorama_search(\n        self,\n        graph_id: str,\n        query: str,\n        include_expired: bool = True,\n        limit: int = 50\n    ) -> PanoramaResult:\n        \"\"\"\n        【PanoramaSearch - 广度搜索】\n        \n        获取全貌视图，包括所有相关内容和历史/过期信息：\n        1. 获取所有相关节点\n        2. 获取所有边（包括已过期/失效的）\n        3. 分类整理当前有效和历史信息\n        \n        这个工具适用于需要了解事件全貌、追踪演变过程的场景。\n        \n        Args:\n            graph_id: 图谱ID\n            query: 搜索查询（用于相关性排序）\n            include_expired: 是否包含过期内容（默认True）\n            limit: 返回结果数量限制\n            \n        Returns:\n            PanoramaResult: 广度搜索结果\n        \"\"\"\n        logger.info(f\"PanoramaSearch 广度搜索: {query[:50]}...\")\n        \n        result = PanoramaResult(query=query)\n        \n        # 获取所有节点\n        all_nodes = self.get_all_nodes(graph_id)\n        node_map = {n.uuid: n for n in all_nodes}\n        result.all_nodes = all_nodes\n        result.total_nodes = len(all_nodes)\n        \n        # 获取所有边（包含时间信息）\n        all_edges = self.get_all_edges(graph_id, include_temporal=True)\n        result.all_edges = all_edges\n        result.total_edges = len(all_edges)\n        \n        # 分类事实\n        active_facts = []\n        historical_facts = []\n        \n        for edge in all_edges:\n            if not edge.fact:\n                continue\n            \n            # 为事实添加实体名称\n            source_name = node_map.get(edge.source_node_uuid, NodeInfo('', '', [], '', {})).name or edge.source_node_uuid[:8]\n            target_name = node_map.get(edge.target_node_uuid, NodeInfo('', '', [], '', {})).name or edge.target_node_uuid[:8]\n            \n            # 判断是否过期/失效\n            is_historical = edge.is_expired or edge.is_invalid\n            \n            if is_historical:\n                # 历史/过期事实，添加时间标记\n                valid_at = edge.valid_at or \"未知\"\n                invalid_at = edge.invalid_at or edge.expired_at or \"未知\"\n                fact_with_time = f\"[{valid_at} - {invalid_at}] {edge.fact}\"\n                historical_facts.append(fact_with_time)\n            else:\n                # 当前有效事实\n                active_facts.append(edge.fact)\n        \n        # 基于查询进行相关性排序\n        query_lower = query.lower()\n        keywords = [w.strip() for w in query_lower.replace(',', ' ').replace('，', ' ').split() if len(w.strip()) > 1]\n        \n        def relevance_score(fact: str) -> int:\n            fact_lower = fact.lower()\n            score = 0\n            if query_lower in fact_lower:\n                score += 100\n            for kw in keywords:\n                if kw in fact_lower:\n                    score += 10\n            return score\n        \n        # 排序并限制数量\n        active_facts.sort(key=relevance_score, reverse=True)\n        historical_facts.sort(key=relevance_score, reverse=True)\n        \n        result.active_facts = active_facts[:limit]\n        result.historical_facts = historical_facts[:limit] if include_expired else []\n        result.active_count = len(active_facts)\n        result.historical_count = len(historical_facts)\n        \n        logger.info(f\"PanoramaSearch完成: {result.active_count}条有效, {result.historical_count}条历史\")\n        return result\n    \n    def quick_search(\n        self,\n        graph_id: str,\n        query: str,\n        limit: int = 10\n    ) -> SearchResult:\n        \"\"\"\n        【QuickSearch - 简单搜索】\n        \n        快速、轻量级的检索工具：\n        1. 直接调用Zep语义搜索\n        2. 返回最相关的结果\n        3. 适用于简单、直接的检索需求\n        \n        Args:\n            graph_id: 图谱ID\n            query: 搜索查询\n            limit: 返回结果数量\n            \n        Returns:\n            SearchResult: 搜索结果\n        \"\"\"\n        logger.info(f\"QuickSearch 简单搜索: {query[:50]}...\")\n        \n        # 直接调用现有的search_graph方法\n        result = self.search_graph(\n            graph_id=graph_id,\n            query=query,\n            limit=limit,\n            scope=\"edges\"\n        )\n        \n        logger.info(f\"QuickSearch完成: {result.total_count}条结果\")\n        return result\n    \n    def interview_agents(\n        self,\n        simulation_id: str,\n        interview_requirement: str,\n        simulation_requirement: str = \"\",\n        max_agents: int = 5,\n        custom_questions: List[str] = None\n    ) -> InterviewResult:\n        \"\"\"\n        【InterviewAgents - 深度采访】\n        \n        调用真实的OASIS采访API，采访模拟中正在运行的Agent：\n        1. 自动读取人设文件，了解所有模拟Agent\n        2. 使用LLM分析采访需求，智能选择最相关的Agent\n        3. 使用LLM生成采访问题\n        4. 调用 /api/simulation/interview/batch 接口进行真实采访（双平台同时采访）\n        5. 整合所有采访结果，生成采访报告\n        \n        【重要】此功能需要模拟环境处于运行状态（OASIS环境未关闭）\n        \n        【使用场景】\n        - 需要从不同角色视角了解事件看法\n        - 需要收集多方意见和观点\n        - 需要获取模拟Agent的真实回答（非LLM模拟）\n        \n        Args:\n            simulation_id: 模拟ID（用于定位人设文件和调用采访API）\n            interview_requirement: 采访需求描述（非结构化，如\"了解学生对事件的看法\"）\n            simulation_requirement: 模拟需求背景（可选）\n            max_agents: 最多采访的Agent数量\n            custom_questions: 自定义采访问题（可选，若不提供则自动生成）\n            \n        Returns:\n            InterviewResult: 采访结果\n        \"\"\"\n        from .simulation_runner import SimulationRunner\n        \n        logger.info(f\"InterviewAgents 深度采访（真实API）: {interview_requirement[:50]}...\")\n        \n        result = InterviewResult(\n            interview_topic=interview_requirement,\n            interview_questions=custom_questions or []\n        )\n        \n        # Step 1: 读取人设文件\n        profiles = self._load_agent_profiles(simulation_id)\n        \n        if not profiles:\n            logger.warning(f\"未找到模拟 {simulation_id} 的人设文件\")\n            result.summary = \"未找到可采访的Agent人设文件\"\n            return result\n        \n        result.total_agents = len(profiles)\n        logger.info(f\"加载到 {len(profiles)} 个Agent人设\")\n        \n        # Step 2: 使用LLM选择要采访的Agent（返回agent_id列表）\n        selected_agents, selected_indices, selection_reasoning = self._select_agents_for_interview(\n            profiles=profiles,\n            interview_requirement=interview_requirement,\n            simulation_requirement=simulation_requirement,\n            max_agents=max_agents\n        )\n        \n        result.selected_agents = selected_agents\n        result.selection_reasoning = selection_reasoning\n        logger.info(f\"选择了 {len(selected_agents)} 个Agent进行采访: {selected_indices}\")\n        \n        # Step 3: 生成采访问题（如果没有提供）\n        if not result.interview_questions:\n            result.interview_questions = self._generate_interview_questions(\n                interview_requirement=interview_requirement,\n                simulation_requirement=simulation_requirement,\n                selected_agents=selected_agents\n            )\n            logger.info(f\"生成了 {len(result.interview_questions)} 个采访问题\")\n        \n        # 将问题合并为一个采访prompt\n        combined_prompt = \"\\n\".join([f\"{i+1}. {q}\" for i, q in enumerate(result.interview_questions)])\n        \n        # 添加优化前缀，约束Agent回复格式\n        INTERVIEW_PROMPT_PREFIX = (\n            \"你正在接受一次采访。请结合你的人设、所有的过往记忆与行动，\"\n            \"以纯文本方式直接回答以下问题。\\n\"\n            \"回复要求：\\n\"\n            \"1. 直接用自然语言回答，不要调用任何工具\\n\"\n            \"2. 不要返回JSON格式或工具调用格式\\n\"\n            \"3. 不要使用Markdown标题（如#、##、###）\\n\"\n            \"4. 按问题编号逐一回答，每个回答以「问题X：」开头（X为问题编号）\\n\"\n            \"5. 每个问题的回答之间用空行分隔\\n\"\n            \"6. 回答要有实质内容，每个问题至少回答2-3句话\\n\\n\"\n        )\n        optimized_prompt = f\"{INTERVIEW_PROMPT_PREFIX}{combined_prompt}\"\n        \n        # Step 4: 调用真实的采访API（不指定platform，默认双平台同时采访）\n        try:\n            # 构建批量采访列表（不指定platform，双平台采访）\n            interviews_request = []\n            for agent_idx in selected_indices:\n                interviews_request.append({\n                    \"agent_id\": agent_idx,\n                    \"prompt\": optimized_prompt  # 使用优化后的prompt\n                    # 不指定platform，API会在twitter和reddit两个平台都采访\n                })\n            \n            logger.info(f\"调用批量采访API（双平台）: {len(interviews_request)} 个Agent\")\n            \n            # 调用 SimulationRunner 的批量采访方法（不传platform，双平台采访）\n            api_result = SimulationRunner.interview_agents_batch(\n                simulation_id=simulation_id,\n                interviews=interviews_request,\n                platform=None,  # 不指定platform，双平台采访\n                timeout=180.0   # 双平台需要更长超时\n            )\n            \n            logger.info(f\"采访API返回: {api_result.get('interviews_count', 0)} 个结果, success={api_result.get('success')}\")\n            \n            # 检查API调用是否成功\n            if not api_result.get(\"success\", False):\n                error_msg = api_result.get(\"error\", \"未知错误\")\n                logger.warning(f\"采访API返回失败: {error_msg}\")\n                result.summary = f\"采访API调用失败：{error_msg}。请检查OASIS模拟环境状态。\"\n                return result\n            \n            # Step 5: 解析API返回结果，构建AgentInterview对象\n            # 双平台模式返回格式: {\"twitter_0\": {...}, \"reddit_0\": {...}, \"twitter_1\": {...}, ...}\n            api_data = api_result.get(\"result\", {})\n            results_dict = api_data.get(\"results\", {}) if isinstance(api_data, dict) else {}\n            \n            for i, agent_idx in enumerate(selected_indices):\n                agent = selected_agents[i]\n                agent_name = agent.get(\"realname\", agent.get(\"username\", f\"Agent_{agent_idx}\"))\n                agent_role = agent.get(\"profession\", \"未知\")\n                agent_bio = agent.get(\"bio\", \"\")\n                \n                # 获取该Agent在两个平台的采访结果\n                twitter_result = results_dict.get(f\"twitter_{agent_idx}\", {})\n                reddit_result = results_dict.get(f\"reddit_{agent_idx}\", {})\n                \n                twitter_response = twitter_result.get(\"response\", \"\")\n                reddit_response = reddit_result.get(\"response\", \"\")\n\n                # 清理可能的工具调用 JSON 包裹\n                twitter_response = self._clean_tool_call_response(twitter_response)\n                reddit_response = self._clean_tool_call_response(reddit_response)\n\n                # 始终输出双平台标记\n                twitter_text = twitter_response if twitter_response else \"（该平台未获得回复）\"\n                reddit_text = reddit_response if reddit_response else \"（该平台未获得回复）\"\n                response_text = f\"【Twitter平台回答】\\n{twitter_text}\\n\\n【Reddit平台回答】\\n{reddit_text}\"\n\n                # 提取关键引言（从两个平台的回答中）\n                import re\n                combined_responses = f\"{twitter_response} {reddit_response}\"\n\n                # 清理响应文本：去掉标记、编号、Markdown 等干扰\n                clean_text = re.sub(r'#{1,6}\\s+', '', combined_responses)\n                clean_text = re.sub(r'\\{[^}]*tool_name[^}]*\\}', '', clean_text)\n                clean_text = re.sub(r'[*_`|>~\\-]{2,}', '', clean_text)\n                clean_text = re.sub(r'问题\\d+[：:]\\s*', '', clean_text)\n                clean_text = re.sub(r'【[^】]+】', '', clean_text)\n\n                # 策略1（主）: 提取完整的有实质内容的句子\n                sentences = re.split(r'[。！？]', clean_text)\n                meaningful = [\n                    s.strip() for s in sentences\n                    if 20 <= len(s.strip()) <= 150\n                    and not re.match(r'^[\\s\\W，,；;：:、]+', s.strip())\n                    and not s.strip().startswith(('{', '问题'))\n                ]\n                meaningful.sort(key=len, reverse=True)\n                key_quotes = [s + \"。\" for s in meaningful[:3]]\n\n                # 策略2（补充）: 正确配对的中文引号「」内长文本\n                if not key_quotes:\n                    paired = re.findall(r'\\u201c([^\\u201c\\u201d]{15,100})\\u201d', clean_text)\n                    paired += re.findall(r'\\u300c([^\\u300c\\u300d]{15,100})\\u300d', clean_text)\n                    key_quotes = [q for q in paired if not re.match(r'^[，,；;：:、]', q)][:3]\n                \n                interview = AgentInterview(\n                    agent_name=agent_name,\n                    agent_role=agent_role,\n                    agent_bio=agent_bio[:1000],  # 扩大bio长度限制\n                    question=combined_prompt,\n                    response=response_text,\n                    key_quotes=key_quotes[:5]\n                )\n                result.interviews.append(interview)\n            \n            result.interviewed_count = len(result.interviews)\n            \n        except ValueError as e:\n            # 模拟环境未运行\n            logger.warning(f\"采访API调用失败（环境未运行？）: {e}\")\n            result.summary = f\"采访失败：{str(e)}。模拟环境可能已关闭，请确保OASIS环境正在运行。\"\n            return result\n        except Exception as e:\n            logger.error(f\"采访API调用异常: {e}\")\n            import traceback\n            logger.error(traceback.format_exc())\n            result.summary = f\"采访过程发生错误：{str(e)}\"\n            return result\n        \n        # Step 6: 生成采访摘要\n        if result.interviews:\n            result.summary = self._generate_interview_summary(\n                interviews=result.interviews,\n                interview_requirement=interview_requirement\n            )\n        \n        logger.info(f\"InterviewAgents完成: 采访了 {result.interviewed_count} 个Agent（双平台）\")\n        return result\n    \n    @staticmethod\n    def _clean_tool_call_response(response: str) -> str:\n        \"\"\"清理 Agent 回复中的 JSON 工具调用包裹，提取实际内容\"\"\"\n        if not response or not response.strip().startswith('{'):\n            return response\n        text = response.strip()\n        if 'tool_name' not in text[:80]:\n            return response\n        import re as _re\n        try:\n            data = json.loads(text)\n            if isinstance(data, dict) and 'arguments' in data:\n                for key in ('content', 'text', 'body', 'message', 'reply'):\n                    if key in data['arguments']:\n                        return str(data['arguments'][key])\n        except (json.JSONDecodeError, KeyError, TypeError):\n            match = _re.search(r'\"content\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"', text)\n            if match:\n                return match.group(1).replace('\\\\n', '\\n').replace('\\\\\"', '\"')\n        return response\n\n    def _load_agent_profiles(self, simulation_id: str) -> List[Dict[str, Any]]:\n        \"\"\"加载模拟的Agent人设文件\"\"\"\n        import os\n        import csv\n        \n        # 构建人设文件路径\n        sim_dir = os.path.join(\n            os.path.dirname(__file__), \n            f'../../uploads/simulations/{simulation_id}'\n        )\n        \n        profiles = []\n        \n        # 优先尝试读取Reddit JSON格式\n        reddit_profile_path = os.path.join(sim_dir, \"reddit_profiles.json\")\n        if os.path.exists(reddit_profile_path):\n            try:\n                with open(reddit_profile_path, 'r', encoding='utf-8') as f:\n                    profiles = json.load(f)\n                logger.info(f\"从 reddit_profiles.json 加载了 {len(profiles)} 个人设\")\n                return profiles\n            except Exception as e:\n                logger.warning(f\"读取 reddit_profiles.json 失败: {e}\")\n        \n        # 尝试读取Twitter CSV格式\n        twitter_profile_path = os.path.join(sim_dir, \"twitter_profiles.csv\")\n        if os.path.exists(twitter_profile_path):\n            try:\n                with open(twitter_profile_path, 'r', encoding='utf-8') as f:\n                    reader = csv.DictReader(f)\n                    for row in reader:\n                        # CSV格式转换为统一格式\n                        profiles.append({\n                            \"realname\": row.get(\"name\", \"\"),\n                            \"username\": row.get(\"username\", \"\"),\n                            \"bio\": row.get(\"description\", \"\"),\n                            \"persona\": row.get(\"user_char\", \"\"),\n                            \"profession\": \"未知\"\n                        })\n                logger.info(f\"从 twitter_profiles.csv 加载了 {len(profiles)} 个人设\")\n                return profiles\n            except Exception as e:\n                logger.warning(f\"读取 twitter_profiles.csv 失败: {e}\")\n        \n        return profiles\n    \n    def _select_agents_for_interview(\n        self,\n        profiles: List[Dict[str, Any]],\n        interview_requirement: str,\n        simulation_requirement: str,\n        max_agents: int\n    ) -> tuple:\n        \"\"\"\n        使用LLM选择要采访的Agent\n        \n        Returns:\n            tuple: (selected_agents, selected_indices, reasoning)\n                - selected_agents: 选中Agent的完整信息列表\n                - selected_indices: 选中Agent的索引列表（用于API调用）\n                - reasoning: 选择理由\n        \"\"\"\n        \n        # 构建Agent摘要列表\n        agent_summaries = []\n        for i, profile in enumerate(profiles):\n            summary = {\n                \"index\": i,\n                \"name\": profile.get(\"realname\", profile.get(\"username\", f\"Agent_{i}\")),\n                \"profession\": profile.get(\"profession\", \"未知\"),\n                \"bio\": profile.get(\"bio\", \"\")[:200],\n                \"interested_topics\": profile.get(\"interested_topics\", [])\n            }\n            agent_summaries.append(summary)\n        \n        system_prompt = \"\"\"你是一个专业的采访策划专家。你的任务是根据采访需求，从模拟Agent列表中选择最适合采访的对象。\n\n选择标准：\n1. Agent的身份/职业与采访主题相关\n2. Agent可能持有独特或有价值的观点\n3. 选择多样化的视角（如：支持方、反对方、中立方、专业人士等）\n4. 优先选择与事件直接相关的角色\n\n返回JSON格式：\n{\n    \"selected_indices\": [选中Agent的索引列表],\n    \"reasoning\": \"选择理由说明\"\n}\"\"\"\n\n        user_prompt = f\"\"\"采访需求：\n{interview_requirement}\n\n模拟背景：\n{simulation_requirement if simulation_requirement else \"未提供\"}\n\n可选择的Agent列表（共{len(agent_summaries)}个）：\n{json.dumps(agent_summaries, ensure_ascii=False, indent=2)}\n\n请选择最多{max_agents}个最适合采访的Agent，并说明选择理由。\"\"\"\n\n        try:\n            response = self.llm.chat_json(\n                messages=[\n                    {\"role\": \"system\", \"content\": system_prompt},\n                    {\"role\": \"user\", \"content\": user_prompt}\n                ],\n                temperature=0.3\n            )\n            \n            selected_indices = response.get(\"selected_indices\", [])[:max_agents]\n            reasoning = response.get(\"reasoning\", \"基于相关性自动选择\")\n            \n            # 获取选中的Agent完整信息\n            selected_agents = []\n            valid_indices = []\n            for idx in selected_indices:\n                if 0 <= idx < len(profiles):\n                    selected_agents.append(profiles[idx])\n                    valid_indices.append(idx)\n            \n            return selected_agents, valid_indices, reasoning\n            \n        except Exception as e:\n            logger.warning(f\"LLM选择Agent失败，使用默认选择: {e}\")\n            # 降级：选择前N个\n            selected = profiles[:max_agents]\n            indices = list(range(min(max_agents, len(profiles))))\n            return selected, indices, \"使用默认选择策略\"\n    \n    def _generate_interview_questions(\n        self,\n        interview_requirement: str,\n        simulation_requirement: str,\n        selected_agents: List[Dict[str, Any]]\n    ) -> List[str]:\n        \"\"\"使用LLM生成采访问题\"\"\"\n        \n        agent_roles = [a.get(\"profession\", \"未知\") for a in selected_agents]\n        \n        system_prompt = \"\"\"你是一个专业的记者/采访者。根据采访需求，生成3-5个深度采访问题。\n\n问题要求：\n1. 开放性问题，鼓励详细回答\n2. 针对不同角色可能有不同答案\n3. 涵盖事实、观点、感受等多个维度\n4. 语言自然，像真实采访一样\n5. 每个问题控制在50字以内，简洁明了\n6. 直接提问，不要包含背景说明或前缀\n\n返回JSON格式：{\"questions\": [\"问题1\", \"问题2\", ...]}\"\"\"\n\n        user_prompt = f\"\"\"采访需求：{interview_requirement}\n\n模拟背景：{simulation_requirement if simulation_requirement else \"未提供\"}\n\n采访对象角色：{', '.join(agent_roles)}\n\n请生成3-5个采访问题。\"\"\"\n\n        try:\n            response = self.llm.chat_json(\n                messages=[\n                    {\"role\": \"system\", \"content\": system_prompt},\n                    {\"role\": \"user\", \"content\": user_prompt}\n                ],\n                temperature=0.5\n            )\n            \n            return response.get(\"questions\", [f\"关于{interview_requirement}，您有什么看法？\"])\n            \n        except Exception as e:\n            logger.warning(f\"生成采访问题失败: {e}\")\n            return [\n                f\"关于{interview_requirement}，您的观点是什么？\",\n                \"这件事对您或您所代表的群体有什么影响？\",\n                \"您认为应该如何解决或改进这个问题？\"\n            ]\n    \n    def _generate_interview_summary(\n        self,\n        interviews: List[AgentInterview],\n        interview_requirement: str\n    ) -> str:\n        \"\"\"生成采访摘要\"\"\"\n        \n        if not interviews:\n            return \"未完成任何采访\"\n        \n        # 收集所有采访内容\n        interview_texts = []\n        for interview in interviews:\n            interview_texts.append(f\"【{interview.agent_name}（{interview.agent_role}）】\\n{interview.response[:500]}\")\n        \n        system_prompt = \"\"\"你是一个专业的新闻编辑。请根据多位受访者的回答，生成一份采访摘要。\n\n摘要要求：\n1. 提炼各方主要观点\n2. 指出观点的共识和分歧\n3. 突出有价值的引言\n4. 客观中立，不偏袒任何一方\n5. 控制在1000字内\n\n格式约束（必须遵守）：\n- 使用纯文本段落，用空行分隔不同部分\n- 不要使用Markdown标题（如#、##、###）\n- 不要使用分割线（如---、***）\n- 引用受访者原话时使用中文引号「」\n- 可以使用**加粗**标记关键词，但不要使用其他Markdown语法\"\"\"\n\n        user_prompt = f\"\"\"采访主题：{interview_requirement}\n\n采访内容：\n{\"\".join(interview_texts)}\n\n请生成采访摘要。\"\"\"\n\n        try:\n            summary = self.llm.chat(\n                messages=[\n                    {\"role\": \"system\", \"content\": system_prompt},\n                    {\"role\": \"user\", \"content\": user_prompt}\n                ],\n                temperature=0.3,\n                max_tokens=800\n            )\n            return summary\n            \n        except Exception as e:\n            logger.warning(f\"生成采访摘要失败: {e}\")\n            # 降级：简单拼接\n            return f\"共采访了{len(interviews)}位受访者，包括：\" + \"、\".join([i.agent_name for i in interviews])\n"
  },
  {
    "path": "backend/app/utils/__init__.py",
    "content": "\"\"\"\n工具模块\n\"\"\"\n\nfrom .file_parser import FileParser\nfrom .llm_client import LLMClient\n\n__all__ = ['FileParser', 'LLMClient']\n\n"
  },
  {
    "path": "backend/app/utils/file_parser.py",
    "content": "\"\"\"\n文件解析工具\n支持PDF、Markdown、TXT文件的文本提取\n\"\"\"\n\nimport os\nfrom pathlib import Path\nfrom typing import List, Optional\n\n\ndef _read_text_with_fallback(file_path: str) -> str:\n    \"\"\"\n    读取文本文件，UTF-8失败时自动探测编码。\n    \n    采用多级回退策略：\n    1. 首先尝试 UTF-8 解码\n    2. 使用 charset_normalizer 检测编码\n    3. 回退到 chardet 检测编码\n    4. 最终使用 UTF-8 + errors='replace' 兜底\n    \n    Args:\n        file_path: 文件路径\n        \n    Returns:\n        解码后的文本内容\n    \"\"\"\n    data = Path(file_path).read_bytes()\n    \n    # 首先尝试 UTF-8\n    try:\n        return data.decode('utf-8')\n    except UnicodeDecodeError:\n        pass\n    \n    # 尝试使用 charset_normalizer 检测编码\n    encoding = None\n    try:\n        from charset_normalizer import from_bytes\n        best = from_bytes(data).best()\n        if best and best.encoding:\n            encoding = best.encoding\n    except Exception:\n        pass\n    \n    # 回退到 chardet\n    if not encoding:\n        try:\n            import chardet\n            result = chardet.detect(data)\n            encoding = result.get('encoding') if result else None\n        except Exception:\n            pass\n    \n    # 最终兜底：使用 UTF-8 + replace\n    if not encoding:\n        encoding = 'utf-8'\n    \n    return data.decode(encoding, errors='replace')\n\n\nclass FileParser:\n    \"\"\"文件解析器\"\"\"\n    \n    SUPPORTED_EXTENSIONS = {'.pdf', '.md', '.markdown', '.txt'}\n    \n    @classmethod\n    def extract_text(cls, file_path: str) -> str:\n        \"\"\"\n        从文件中提取文本\n        \n        Args:\n            file_path: 文件路径\n            \n        Returns:\n            提取的文本内容\n        \"\"\"\n        path = Path(file_path)\n        \n        if not path.exists():\n            raise FileNotFoundError(f\"文件不存在: {file_path}\")\n        \n        suffix = path.suffix.lower()\n        \n        if suffix not in cls.SUPPORTED_EXTENSIONS:\n            raise ValueError(f\"不支持的文件格式: {suffix}\")\n        \n        if suffix == '.pdf':\n            return cls._extract_from_pdf(file_path)\n        elif suffix in {'.md', '.markdown'}:\n            return cls._extract_from_md(file_path)\n        elif suffix == '.txt':\n            return cls._extract_from_txt(file_path)\n        \n        raise ValueError(f\"无法处理的文件格式: {suffix}\")\n    \n    @staticmethod\n    def _extract_from_pdf(file_path: str) -> str:\n        \"\"\"从PDF提取文本\"\"\"\n        try:\n            import fitz  # PyMuPDF\n        except ImportError:\n            raise ImportError(\"需要安装PyMuPDF: pip install PyMuPDF\")\n        \n        text_parts = []\n        with fitz.open(file_path) as doc:\n            for page in doc:\n                text = page.get_text()\n                if text.strip():\n                    text_parts.append(text)\n        \n        return \"\\n\\n\".join(text_parts)\n    \n    @staticmethod\n    def _extract_from_md(file_path: str) -> str:\n        \"\"\"从Markdown提取文本，支持自动编码检测\"\"\"\n        return _read_text_with_fallback(file_path)\n    \n    @staticmethod\n    def _extract_from_txt(file_path: str) -> str:\n        \"\"\"从TXT提取文本，支持自动编码检测\"\"\"\n        return _read_text_with_fallback(file_path)\n    \n    @classmethod\n    def extract_from_multiple(cls, file_paths: List[str]) -> str:\n        \"\"\"\n        从多个文件提取文本并合并\n        \n        Args:\n            file_paths: 文件路径列表\n            \n        Returns:\n            合并后的文本\n        \"\"\"\n        all_texts = []\n        \n        for i, file_path in enumerate(file_paths, 1):\n            try:\n                text = cls.extract_text(file_path)\n                filename = Path(file_path).name\n                all_texts.append(f\"=== 文档 {i}: {filename} ===\\n{text}\")\n            except Exception as e:\n                all_texts.append(f\"=== 文档 {i}: {file_path} (提取失败: {str(e)}) ===\")\n        \n        return \"\\n\\n\".join(all_texts)\n\n\ndef split_text_into_chunks(\n    text: str, \n    chunk_size: int = 500, \n    overlap: int = 50\n) -> List[str]:\n    \"\"\"\n    将文本分割成小块\n    \n    Args:\n        text: 原始文本\n        chunk_size: 每块的字符数\n        overlap: 重叠字符数\n        \n    Returns:\n        文本块列表\n    \"\"\"\n    if len(text) <= chunk_size:\n        return [text] if text.strip() else []\n    \n    chunks = []\n    start = 0\n    \n    while start < len(text):\n        end = start + chunk_size\n        \n        # 尝试在句子边界处分割\n        if end < len(text):\n            # 查找最近的句子结束符\n            for sep in ['。', '！', '？', '.\\n', '!\\n', '?\\n', '\\n\\n', '. ', '! ', '? ']:\n                last_sep = text[start:end].rfind(sep)\n                if last_sep != -1 and last_sep > chunk_size * 0.3:\n                    end = start + last_sep + len(sep)\n                    break\n        \n        chunk = text[start:end].strip()\n        if chunk:\n            chunks.append(chunk)\n        \n        # 下一个块从重叠位置开始\n        start = end - overlap if end < len(text) else len(text)\n    \n    return chunks\n\n"
  },
  {
    "path": "backend/app/utils/llm_client.py",
    "content": "\"\"\"\nLLM客户端封装\n统一使用OpenAI格式调用\n\"\"\"\n\nimport json\nimport re\nfrom typing import Optional, Dict, Any, List\nfrom openai import OpenAI\n\nfrom ..config import Config\n\n\nclass LLMClient:\n    \"\"\"LLM客户端\"\"\"\n    \n    def __init__(\n        self,\n        api_key: Optional[str] = None,\n        base_url: Optional[str] = None,\n        model: Optional[str] = None\n    ):\n        self.api_key = api_key or Config.LLM_API_KEY\n        self.base_url = base_url or Config.LLM_BASE_URL\n        self.model = model or Config.LLM_MODEL_NAME\n        \n        if not self.api_key:\n            raise ValueError(\"LLM_API_KEY 未配置\")\n        \n        self.client = OpenAI(\n            api_key=self.api_key,\n            base_url=self.base_url\n        )\n    \n    def chat(\n        self,\n        messages: List[Dict[str, str]],\n        temperature: float = 0.7,\n        max_tokens: int = 4096,\n        response_format: Optional[Dict] = None\n    ) -> str:\n        \"\"\"\n        发送聊天请求\n        \n        Args:\n            messages: 消息列表\n            temperature: 温度参数\n            max_tokens: 最大token数\n            response_format: 响应格式（如JSON模式）\n            \n        Returns:\n            模型响应文本\n        \"\"\"\n        kwargs = {\n            \"model\": self.model,\n            \"messages\": messages,\n            \"temperature\": temperature,\n            \"max_tokens\": max_tokens,\n        }\n        \n        if response_format:\n            kwargs[\"response_format\"] = response_format\n        \n        response = self.client.chat.completions.create(**kwargs)\n        content = response.choices[0].message.content\n        # 部分模型（如MiniMax M2.5）会在content中包含<think>思考内容，需要移除\n        content = re.sub(r'<think>[\\s\\S]*?</think>', '', content).strip()\n        return content\n    \n    def chat_json(\n        self,\n        messages: List[Dict[str, str]],\n        temperature: float = 0.3,\n        max_tokens: int = 4096\n    ) -> Dict[str, Any]:\n        \"\"\"\n        发送聊天请求并返回JSON\n        \n        Args:\n            messages: 消息列表\n            temperature: 温度参数\n            max_tokens: 最大token数\n            \n        Returns:\n            解析后的JSON对象\n        \"\"\"\n        response = self.chat(\n            messages=messages,\n            temperature=temperature,\n            max_tokens=max_tokens,\n            response_format={\"type\": \"json_object\"}\n        )\n        # 清理markdown代码块标记\n        cleaned_response = response.strip()\n        cleaned_response = re.sub(r'^```(?:json)?\\s*\\n?', '', cleaned_response, flags=re.IGNORECASE)\n        cleaned_response = re.sub(r'\\n?```\\s*$', '', cleaned_response)\n        cleaned_response = cleaned_response.strip()\n\n        try:\n            return json.loads(cleaned_response)\n        except json.JSONDecodeError:\n            raise ValueError(f\"LLM返回的JSON格式无效: {cleaned_response}\")\n\n"
  },
  {
    "path": "backend/app/utils/logger.py",
    "content": "\"\"\"\n日志配置模块\n提供统一的日志管理，同时输出到控制台和文件\n\"\"\"\n\nimport os\nimport sys\nimport logging\nfrom datetime import datetime\nfrom logging.handlers import RotatingFileHandler\n\n\ndef _ensure_utf8_stdout():\n    \"\"\"\n    确保 stdout/stderr 使用 UTF-8 编码\n    解决 Windows 控制台中文乱码问题\n    \"\"\"\n    if sys.platform == 'win32':\n        # Windows 下重新配置标准输出为 UTF-8\n        if hasattr(sys.stdout, 'reconfigure'):\n            sys.stdout.reconfigure(encoding='utf-8', errors='replace')\n        if hasattr(sys.stderr, 'reconfigure'):\n            sys.stderr.reconfigure(encoding='utf-8', errors='replace')\n\n\n# 日志目录\nLOG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'logs')\n\n\ndef setup_logger(name: str = 'mirofish', level: int = logging.DEBUG) -> logging.Logger:\n    \"\"\"\n    设置日志器\n    \n    Args:\n        name: 日志器名称\n        level: 日志级别\n        \n    Returns:\n        配置好的日志器\n    \"\"\"\n    # 确保日志目录存在\n    os.makedirs(LOG_DIR, exist_ok=True)\n    \n    # 创建日志器\n    logger = logging.getLogger(name)\n    logger.setLevel(level)\n    \n    # 阻止日志向上传播到根 logger，避免重复输出\n    logger.propagate = False\n    \n    # 如果已经有处理器，不重复添加\n    if logger.handlers:\n        return logger\n    \n    # 日志格式\n    detailed_formatter = logging.Formatter(\n        '[%(asctime)s] %(levelname)s [%(name)s.%(funcName)s:%(lineno)d] %(message)s',\n        datefmt='%Y-%m-%d %H:%M:%S'\n    )\n    \n    simple_formatter = logging.Formatter(\n        '[%(asctime)s] %(levelname)s: %(message)s',\n        datefmt='%H:%M:%S'\n    )\n    \n    # 1. 文件处理器 - 详细日志（按日期命名，带轮转）\n    log_filename = datetime.now().strftime('%Y-%m-%d') + '.log'\n    file_handler = RotatingFileHandler(\n        os.path.join(LOG_DIR, log_filename),\n        maxBytes=10 * 1024 * 1024,  # 10MB\n        backupCount=5,\n        encoding='utf-8'\n    )\n    file_handler.setLevel(logging.DEBUG)\n    file_handler.setFormatter(detailed_formatter)\n    \n    # 2. 控制台处理器 - 简洁日志（INFO及以上）\n    # 确保 Windows 下使用 UTF-8 编码，避免中文乱码\n    _ensure_utf8_stdout()\n    console_handler = logging.StreamHandler(sys.stdout)\n    console_handler.setLevel(logging.INFO)\n    console_handler.setFormatter(simple_formatter)\n    \n    # 添加处理器\n    logger.addHandler(file_handler)\n    logger.addHandler(console_handler)\n    \n    return logger\n\n\ndef get_logger(name: str = 'mirofish') -> logging.Logger:\n    \"\"\"\n    获取日志器（如果不存在则创建）\n    \n    Args:\n        name: 日志器名称\n        \n    Returns:\n        日志器实例\n    \"\"\"\n    logger = logging.getLogger(name)\n    if not logger.handlers:\n        return setup_logger(name)\n    return logger\n\n\n# 创建默认日志器\nlogger = setup_logger()\n\n\n# 便捷方法\ndef debug(msg, *args, **kwargs):\n    logger.debug(msg, *args, **kwargs)\n\ndef info(msg, *args, **kwargs):\n    logger.info(msg, *args, **kwargs)\n\ndef warning(msg, *args, **kwargs):\n    logger.warning(msg, *args, **kwargs)\n\ndef error(msg, *args, **kwargs):\n    logger.error(msg, *args, **kwargs)\n\ndef critical(msg, *args, **kwargs):\n    logger.critical(msg, *args, **kwargs)\n\n"
  },
  {
    "path": "backend/app/utils/retry.py",
    "content": "\"\"\"\nAPI调用重试机制\n用于处理LLM等外部API调用的重试逻辑\n\"\"\"\n\nimport time\nimport random\nimport functools\nfrom typing import Callable, Any, Optional, Type, Tuple\nfrom ..utils.logger import get_logger\n\nlogger = get_logger('mirofish.retry')\n\n\ndef retry_with_backoff(\n    max_retries: int = 3,\n    initial_delay: float = 1.0,\n    max_delay: float = 30.0,\n    backoff_factor: float = 2.0,\n    jitter: bool = True,\n    exceptions: Tuple[Type[Exception], ...] = (Exception,),\n    on_retry: Optional[Callable[[Exception, int], None]] = None\n):\n    \"\"\"\n    带指数退避的重试装饰器\n    \n    Args:\n        max_retries: 最大重试次数\n        initial_delay: 初始延迟（秒）\n        max_delay: 最大延迟（秒）\n        backoff_factor: 退避因子\n        jitter: 是否添加随机抖动\n        exceptions: 需要重试的异常类型\n        on_retry: 重试时的回调函数 (exception, retry_count)\n    \n    Usage:\n        @retry_with_backoff(max_retries=3)\n        def call_llm_api():\n            ...\n    \"\"\"\n    def decorator(func: Callable) -> Callable:\n        @functools.wraps(func)\n        def wrapper(*args, **kwargs) -> Any:\n            last_exception = None\n            delay = initial_delay\n            \n            for attempt in range(max_retries + 1):\n                try:\n                    return func(*args, **kwargs)\n                    \n                except exceptions as e:\n                    last_exception = e\n                    \n                    if attempt == max_retries:\n                        logger.error(f\"函数 {func.__name__} 在 {max_retries} 次重试后仍失败: {str(e)}\")\n                        raise\n                    \n                    # 计算延迟\n                    current_delay = min(delay, max_delay)\n                    if jitter:\n                        current_delay = current_delay * (0.5 + random.random())\n                    \n                    logger.warning(\n                        f\"函数 {func.__name__} 第 {attempt + 1} 次尝试失败: {str(e)}, \"\n                        f\"{current_delay:.1f}秒后重试...\"\n                    )\n                    \n                    if on_retry:\n                        on_retry(e, attempt + 1)\n                    \n                    time.sleep(current_delay)\n                    delay *= backoff_factor\n            \n            raise last_exception\n        \n        return wrapper\n    return decorator\n\n\ndef retry_with_backoff_async(\n    max_retries: int = 3,\n    initial_delay: float = 1.0,\n    max_delay: float = 30.0,\n    backoff_factor: float = 2.0,\n    jitter: bool = True,\n    exceptions: Tuple[Type[Exception], ...] = (Exception,),\n    on_retry: Optional[Callable[[Exception, int], None]] = None\n):\n    \"\"\"\n    异步版本的重试装饰器\n    \"\"\"\n    import asyncio\n    \n    def decorator(func: Callable) -> Callable:\n        @functools.wraps(func)\n        async def wrapper(*args, **kwargs) -> Any:\n            last_exception = None\n            delay = initial_delay\n            \n            for attempt in range(max_retries + 1):\n                try:\n                    return await func(*args, **kwargs)\n                    \n                except exceptions as e:\n                    last_exception = e\n                    \n                    if attempt == max_retries:\n                        logger.error(f\"异步函数 {func.__name__} 在 {max_retries} 次重试后仍失败: {str(e)}\")\n                        raise\n                    \n                    current_delay = min(delay, max_delay)\n                    if jitter:\n                        current_delay = current_delay * (0.5 + random.random())\n                    \n                    logger.warning(\n                        f\"异步函数 {func.__name__} 第 {attempt + 1} 次尝试失败: {str(e)}, \"\n                        f\"{current_delay:.1f}秒后重试...\"\n                    )\n                    \n                    if on_retry:\n                        on_retry(e, attempt + 1)\n                    \n                    await asyncio.sleep(current_delay)\n                    delay *= backoff_factor\n            \n            raise last_exception\n        \n        return wrapper\n    return decorator\n\n\nclass RetryableAPIClient:\n    \"\"\"\n    可重试的API客户端封装\n    \"\"\"\n    \n    def __init__(\n        self,\n        max_retries: int = 3,\n        initial_delay: float = 1.0,\n        max_delay: float = 30.0,\n        backoff_factor: float = 2.0\n    ):\n        self.max_retries = max_retries\n        self.initial_delay = initial_delay\n        self.max_delay = max_delay\n        self.backoff_factor = backoff_factor\n    \n    def call_with_retry(\n        self,\n        func: Callable,\n        *args,\n        exceptions: Tuple[Type[Exception], ...] = (Exception,),\n        **kwargs\n    ) -> Any:\n        \"\"\"\n        执行函数调用并在失败时重试\n        \n        Args:\n            func: 要调用的函数\n            *args: 函数参数\n            exceptions: 需要重试的异常类型\n            **kwargs: 函数关键字参数\n            \n        Returns:\n            函数返回值\n        \"\"\"\n        last_exception = None\n        delay = self.initial_delay\n        \n        for attempt in range(self.max_retries + 1):\n            try:\n                return func(*args, **kwargs)\n                \n            except exceptions as e:\n                last_exception = e\n                \n                if attempt == self.max_retries:\n                    logger.error(f\"API调用在 {self.max_retries} 次重试后仍失败: {str(e)}\")\n                    raise\n                \n                current_delay = min(delay, self.max_delay)\n                current_delay = current_delay * (0.5 + random.random())\n                \n                logger.warning(\n                    f\"API调用第 {attempt + 1} 次尝试失败: {str(e)}, \"\n                    f\"{current_delay:.1f}秒后重试...\"\n                )\n                \n                time.sleep(current_delay)\n                delay *= self.backoff_factor\n        \n        raise last_exception\n    \n    def call_batch_with_retry(\n        self,\n        items: list,\n        process_func: Callable,\n        exceptions: Tuple[Type[Exception], ...] = (Exception,),\n        continue_on_failure: bool = True\n    ) -> Tuple[list, list]:\n        \"\"\"\n        批量调用并对每个失败项单独重试\n        \n        Args:\n            items: 要处理的项目列表\n            process_func: 处理函数，接收单个item作为参数\n            exceptions: 需要重试的异常类型\n            continue_on_failure: 单项失败后是否继续处理其他项\n            \n        Returns:\n            (成功结果列表, 失败项列表)\n        \"\"\"\n        results = []\n        failures = []\n        \n        for idx, item in enumerate(items):\n            try:\n                result = self.call_with_retry(\n                    process_func,\n                    item,\n                    exceptions=exceptions\n                )\n                results.append(result)\n                \n            except Exception as e:\n                logger.error(f\"处理第 {idx + 1} 项失败: {str(e)}\")\n                failures.append({\n                    \"index\": idx,\n                    \"item\": item,\n                    \"error\": str(e)\n                })\n                \n                if not continue_on_failure:\n                    raise\n        \n        return results, failures\n\n"
  },
  {
    "path": "backend/app/utils/zep_paging.py",
    "content": "\"\"\"Zep Graph 分页读取工具。\n\nZep 的 node/edge 列表接口使用 UUID cursor 分页，\n本模块封装自动翻页逻辑（含单页重试），对调用方透明地返回完整列表。\n\"\"\"\n\nfrom __future__ import annotations\n\nimport time\nfrom collections.abc import Callable\nfrom typing import Any\n\nfrom zep_cloud import InternalServerError\nfrom zep_cloud.client import Zep\n\nfrom .logger import get_logger\n\nlogger = get_logger('mirofish.zep_paging')\n\n_DEFAULT_PAGE_SIZE = 100\n_MAX_NODES = 2000\n_DEFAULT_MAX_RETRIES = 3\n_DEFAULT_RETRY_DELAY = 2.0  # seconds, doubles each retry\n\n\ndef _fetch_page_with_retry(\n    api_call: Callable[..., list[Any]],\n    *args: Any,\n    max_retries: int = _DEFAULT_MAX_RETRIES,\n    retry_delay: float = _DEFAULT_RETRY_DELAY,\n    page_description: str = \"page\",\n    **kwargs: Any,\n) -> list[Any]:\n    \"\"\"单页请求，失败时指数退避重试。仅重试网络/IO类瞬态错误。\"\"\"\n    if max_retries < 1:\n        raise ValueError(\"max_retries must be >= 1\")\n\n    last_exception: Exception | None = None\n    delay = retry_delay\n\n    for attempt in range(max_retries):\n        try:\n            return api_call(*args, **kwargs)\n        except (ConnectionError, TimeoutError, OSError, InternalServerError) as e:\n            last_exception = e\n            if attempt < max_retries - 1:\n                logger.warning(\n                    f\"Zep {page_description} attempt {attempt + 1} failed: {str(e)[:100]}, retrying in {delay:.1f}s...\"\n                )\n                time.sleep(delay)\n                delay *= 2\n            else:\n                logger.error(f\"Zep {page_description} failed after {max_retries} attempts: {str(e)}\")\n\n    assert last_exception is not None\n    raise last_exception\n\n\ndef fetch_all_nodes(\n    client: Zep,\n    graph_id: str,\n    page_size: int = _DEFAULT_PAGE_SIZE,\n    max_items: int = _MAX_NODES,\n    max_retries: int = _DEFAULT_MAX_RETRIES,\n    retry_delay: float = _DEFAULT_RETRY_DELAY,\n) -> list[Any]:\n    \"\"\"分页获取图谱节点，最多返回 max_items 条（默认 2000）。每页请求自带重试。\"\"\"\n    all_nodes: list[Any] = []\n    cursor: str | None = None\n    page_num = 0\n\n    while True:\n        kwargs: dict[str, Any] = {\"limit\": page_size}\n        if cursor is not None:\n            kwargs[\"uuid_cursor\"] = cursor\n\n        page_num += 1\n        batch = _fetch_page_with_retry(\n            client.graph.node.get_by_graph_id,\n            graph_id,\n            max_retries=max_retries,\n            retry_delay=retry_delay,\n            page_description=f\"fetch nodes page {page_num} (graph={graph_id})\",\n            **kwargs,\n        )\n        if not batch:\n            break\n\n        all_nodes.extend(batch)\n        if len(all_nodes) >= max_items:\n            all_nodes = all_nodes[:max_items]\n            logger.warning(f\"Node count reached limit ({max_items}), stopping pagination for graph {graph_id}\")\n            break\n        if len(batch) < page_size:\n            break\n\n        cursor = getattr(batch[-1], \"uuid_\", None) or getattr(batch[-1], \"uuid\", None)\n        if cursor is None:\n            logger.warning(f\"Node missing uuid field, stopping pagination at {len(all_nodes)} nodes\")\n            break\n\n    return all_nodes\n\n\ndef fetch_all_edges(\n    client: Zep,\n    graph_id: str,\n    page_size: int = _DEFAULT_PAGE_SIZE,\n    max_retries: int = _DEFAULT_MAX_RETRIES,\n    retry_delay: float = _DEFAULT_RETRY_DELAY,\n) -> list[Any]:\n    \"\"\"分页获取图谱所有边，返回完整列表。每页请求自带重试。\"\"\"\n    all_edges: list[Any] = []\n    cursor: str | None = None\n    page_num = 0\n\n    while True:\n        kwargs: dict[str, Any] = {\"limit\": page_size}\n        if cursor is not None:\n            kwargs[\"uuid_cursor\"] = cursor\n\n        page_num += 1\n        batch = _fetch_page_with_retry(\n            client.graph.edge.get_by_graph_id,\n            graph_id,\n            max_retries=max_retries,\n            retry_delay=retry_delay,\n            page_description=f\"fetch edges page {page_num} (graph={graph_id})\",\n            **kwargs,\n        )\n        if not batch:\n            break\n\n        all_edges.extend(batch)\n        if len(batch) < page_size:\n            break\n\n        cursor = getattr(batch[-1], \"uuid_\", None) or getattr(batch[-1], \"uuid\", None)\n        if cursor is None:\n            logger.warning(f\"Edge missing uuid field, stopping pagination at {len(all_edges)} edges\")\n            break\n\n    return all_edges\n"
  },
  {
    "path": "backend/pyproject.toml",
    "content": "[project]\nname = \"mirofish-backend\"\nversion = \"0.1.0\"\ndescription = \"MiroFish - 简洁通用的群体智能引擎，预测万物\"\nrequires-python = \">=3.11\"\nlicense = { text = \"AGPL-3.0\" }\nauthors = [\n    { name = \"MiroFish Team\" }\n]\n\ndependencies = [\n    # 核心框架\n    \"flask>=3.0.0\",\n    \"flask-cors>=6.0.0\",\n    \n    # LLM 相关\n    \"openai>=1.0.0\",\n    \n    # Zep Cloud\n    \"zep-cloud==3.13.0\",\n    \n    # OASIS 社交媒体模拟\n    \"camel-oasis==0.2.5\",\n    \"camel-ai==0.2.78\",\n    \n    # 文件处理\n    \"PyMuPDF>=1.24.0\",\n    # 编码检测（支持非UTF-8编码的文本文件）\n    \"charset-normalizer>=3.0.0\",\n    \"chardet>=5.0.0\",\n    \n    # 工具库\n    \"python-dotenv>=1.0.0\",\n    \"pydantic>=2.0.0\",\n]\n\n[project.optional-dependencies]\ndev = [\n    \"pytest>=8.0.0\",\n    \"pytest-asyncio>=0.23.0\",\n    \"pipreqs>=0.5.0\",\n]\n\n[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n\n[dependency-groups]\ndev = [\n    \"pytest>=8.0.0\",\n    \"pytest-asyncio>=0.23.0\",\n]\n\n[tool.hatch.build.targets.wheel]\npackages = [\"app\"]\n"
  },
  {
    "path": "backend/requirements.txt",
    "content": "# ===========================================\n# MiroFish Backend Dependencies\n# ===========================================\n# Python 3.11+ required\n# Install: pip install -r requirements.txt\n# ===========================================\n\n# ============= 核心框架 =============\nflask>=3.0.0\nflask-cors>=6.0.0\n\n# ============= LLM 相关 =============\n# OpenAI SDK（统一使用 OpenAI 格式调用 LLM）\nopenai>=1.0.0\n\n# ============= Zep Cloud =============\nzep-cloud==3.13.0\n\n# ============= OASIS 社交媒体模拟 =============\n# OASIS 社交模拟框架\ncamel-oasis==0.2.5\ncamel-ai==0.2.78\n\n# ============= 文件处理 =============\nPyMuPDF>=1.24.0\n# 编码检测（支持非UTF-8编码的文本文件）\ncharset-normalizer>=3.0.0\nchardet>=5.0.0\n\n# ============= 工具库 =============\n# 环境变量加载\npython-dotenv>=1.0.0\n\n# 数据验证\npydantic>=2.0.0\n"
  },
  {
    "path": "backend/run.py",
    "content": "\"\"\"\nMiroFish Backend 启动入口\n\"\"\"\n\nimport os\nimport sys\n\n# 解决 Windows 控制台中文乱码问题：在所有导入之前设置 UTF-8 编码\nif sys.platform == 'win32':\n    # 设置环境变量确保 Python 使用 UTF-8\n    os.environ.setdefault('PYTHONIOENCODING', 'utf-8')\n    # 重新配置标准输出流为 UTF-8\n    if hasattr(sys.stdout, 'reconfigure'):\n        sys.stdout.reconfigure(encoding='utf-8', errors='replace')\n    if hasattr(sys.stderr, 'reconfigure'):\n        sys.stderr.reconfigure(encoding='utf-8', errors='replace')\n\n# 添加项目根目录到路径\nsys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))\n\nfrom app import create_app\nfrom app.config import Config\n\n\ndef main():\n    \"\"\"主函数\"\"\"\n    # 验证配置\n    errors = Config.validate()\n    if errors:\n        print(\"配置错误:\")\n        for err in errors:\n            print(f\"  - {err}\")\n        print(\"\\n请检查 .env 文件中的配置\")\n        sys.exit(1)\n    \n    # 创建应用\n    app = create_app()\n    \n    # 获取运行配置\n    host = os.environ.get('FLASK_HOST', '0.0.0.0')\n    port = int(os.environ.get('FLASK_PORT', 5001))\n    debug = Config.DEBUG\n    \n    # 启动服务\n    app.run(host=host, port=port, debug=debug, threaded=True)\n\n\nif __name__ == '__main__':\n    main()\n\n"
  },
  {
    "path": "backend/scripts/action_logger.py",
    "content": "\"\"\"\n动作日志记录器\n用于记录OASIS模拟中每个Agent的动作，供后端监控使用\n\n日志结构:\n    sim_xxx/\n    ├── twitter/\n    │   └── actions.jsonl    # Twitter 平台动作日志\n    ├── reddit/\n    │   └── actions.jsonl    # Reddit 平台动作日志\n    ├── simulation.log       # 主模拟进程日志\n    └── run_state.json       # 运行状态（API 查询用）\n\"\"\"\n\nimport json\nimport os\nimport logging\nfrom datetime import datetime\nfrom typing import Dict, Any, Optional\n\n\nclass PlatformActionLogger:\n    \"\"\"单平台动作日志记录器\"\"\"\n    \n    def __init__(self, platform: str, base_dir: str):\n        \"\"\"\n        初始化日志记录器\n        \n        Args:\n            platform: 平台名称 (twitter/reddit)\n            base_dir: 模拟目录的基础路径\n        \"\"\"\n        self.platform = platform\n        self.base_dir = base_dir\n        self.log_dir = os.path.join(base_dir, platform)\n        self.log_path = os.path.join(self.log_dir, \"actions.jsonl\")\n        self._ensure_dir()\n    \n    def _ensure_dir(self):\n        \"\"\"确保目录存在\"\"\"\n        os.makedirs(self.log_dir, exist_ok=True)\n    \n    def log_action(\n        self,\n        round_num: int,\n        agent_id: int,\n        agent_name: str,\n        action_type: str,\n        action_args: Optional[Dict[str, Any]] = None,\n        result: Optional[str] = None,\n        success: bool = True\n    ):\n        \"\"\"记录一个动作\"\"\"\n        entry = {\n            \"round\": round_num,\n            \"timestamp\": datetime.now().isoformat(),\n            \"agent_id\": agent_id,\n            \"agent_name\": agent_name,\n            \"action_type\": action_type,\n            \"action_args\": action_args or {},\n            \"result\": result,\n            \"success\": success,\n        }\n        \n        with open(self.log_path, 'a', encoding='utf-8') as f:\n            f.write(json.dumps(entry, ensure_ascii=False) + '\\n')\n    \n    def log_round_start(self, round_num: int, simulated_hour: int):\n        \"\"\"记录轮次开始\"\"\"\n        entry = {\n            \"round\": round_num,\n            \"timestamp\": datetime.now().isoformat(),\n            \"event_type\": \"round_start\",\n            \"simulated_hour\": simulated_hour,\n        }\n        \n        with open(self.log_path, 'a', encoding='utf-8') as f:\n            f.write(json.dumps(entry, ensure_ascii=False) + '\\n')\n    \n    def log_round_end(self, round_num: int, actions_count: int):\n        \"\"\"记录轮次结束\"\"\"\n        entry = {\n            \"round\": round_num,\n            \"timestamp\": datetime.now().isoformat(),\n            \"event_type\": \"round_end\",\n            \"actions_count\": actions_count,\n        }\n        \n        with open(self.log_path, 'a', encoding='utf-8') as f:\n            f.write(json.dumps(entry, ensure_ascii=False) + '\\n')\n    \n    def log_simulation_start(self, config: Dict[str, Any]):\n        \"\"\"记录模拟开始\"\"\"\n        entry = {\n            \"timestamp\": datetime.now().isoformat(),\n            \"event_type\": \"simulation_start\",\n            \"platform\": self.platform,\n            \"total_rounds\": config.get(\"time_config\", {}).get(\"total_simulation_hours\", 72) * 2,\n            \"agents_count\": len(config.get(\"agent_configs\", [])),\n        }\n        \n        with open(self.log_path, 'a', encoding='utf-8') as f:\n            f.write(json.dumps(entry, ensure_ascii=False) + '\\n')\n    \n    def log_simulation_end(self, total_rounds: int, total_actions: int):\n        \"\"\"记录模拟结束\"\"\"\n        entry = {\n            \"timestamp\": datetime.now().isoformat(),\n            \"event_type\": \"simulation_end\",\n            \"platform\": self.platform,\n            \"total_rounds\": total_rounds,\n            \"total_actions\": total_actions,\n        }\n        \n        with open(self.log_path, 'a', encoding='utf-8') as f:\n            f.write(json.dumps(entry, ensure_ascii=False) + '\\n')\n\n\nclass SimulationLogManager:\n    \"\"\"\n    模拟日志管理器\n    统一管理所有日志文件，按平台分离\n    \"\"\"\n    \n    def __init__(self, simulation_dir: str):\n        \"\"\"\n        初始化日志管理器\n        \n        Args:\n            simulation_dir: 模拟目录路径\n        \"\"\"\n        self.simulation_dir = simulation_dir\n        self.twitter_logger: Optional[PlatformActionLogger] = None\n        self.reddit_logger: Optional[PlatformActionLogger] = None\n        self._main_logger: Optional[logging.Logger] = None\n        \n        # 设置主日志\n        self._setup_main_logger()\n    \n    def _setup_main_logger(self):\n        \"\"\"设置主模拟日志\"\"\"\n        log_path = os.path.join(self.simulation_dir, \"simulation.log\")\n        \n        # 创建 logger\n        self._main_logger = logging.getLogger(f\"simulation.{os.path.basename(self.simulation_dir)}\")\n        self._main_logger.setLevel(logging.INFO)\n        self._main_logger.handlers.clear()\n        \n        # 文件处理器\n        file_handler = logging.FileHandler(log_path, encoding='utf-8', mode='w')\n        file_handler.setLevel(logging.INFO)\n        file_handler.setFormatter(logging.Formatter(\n            '%(asctime)s - %(levelname)s - %(message)s',\n            datefmt='%Y-%m-%d %H:%M:%S'\n        ))\n        self._main_logger.addHandler(file_handler)\n        \n        # 控制台处理器\n        console_handler = logging.StreamHandler()\n        console_handler.setLevel(logging.INFO)\n        console_handler.setFormatter(logging.Formatter(\n            '[%(asctime)s] %(message)s',\n            datefmt='%H:%M:%S'\n        ))\n        self._main_logger.addHandler(console_handler)\n        \n        self._main_logger.propagate = False\n    \n    def get_twitter_logger(self) -> PlatformActionLogger:\n        \"\"\"获取 Twitter 平台日志记录器\"\"\"\n        if self.twitter_logger is None:\n            self.twitter_logger = PlatformActionLogger(\"twitter\", self.simulation_dir)\n        return self.twitter_logger\n    \n    def get_reddit_logger(self) -> PlatformActionLogger:\n        \"\"\"获取 Reddit 平台日志记录器\"\"\"\n        if self.reddit_logger is None:\n            self.reddit_logger = PlatformActionLogger(\"reddit\", self.simulation_dir)\n        return self.reddit_logger\n    \n    def log(self, message: str, level: str = \"info\"):\n        \"\"\"记录主日志\"\"\"\n        if self._main_logger:\n            getattr(self._main_logger, level.lower(), self._main_logger.info)(message)\n    \n    def info(self, message: str):\n        self.log(message, \"info\")\n    \n    def warning(self, message: str):\n        self.log(message, \"warning\")\n    \n    def error(self, message: str):\n        self.log(message, \"error\")\n    \n    def debug(self, message: str):\n        self.log(message, \"debug\")\n\n\n# ============ 兼容旧接口 ============\n\nclass ActionLogger:\n    \"\"\"\n    动作日志记录器（兼容旧接口）\n    建议使用 SimulationLogManager 代替\n    \"\"\"\n    \n    def __init__(self, log_path: str):\n        self.log_path = log_path\n        self._ensure_dir()\n    \n    def _ensure_dir(self):\n        log_dir = os.path.dirname(self.log_path)\n        if log_dir:\n            os.makedirs(log_dir, exist_ok=True)\n    \n    def log_action(\n        self,\n        round_num: int,\n        platform: str,\n        agent_id: int,\n        agent_name: str,\n        action_type: str,\n        action_args: Optional[Dict[str, Any]] = None,\n        result: Optional[str] = None,\n        success: bool = True\n    ):\n        entry = {\n            \"round\": round_num,\n            \"timestamp\": datetime.now().isoformat(),\n            \"platform\": platform,\n            \"agent_id\": agent_id,\n            \"agent_name\": agent_name,\n            \"action_type\": action_type,\n            \"action_args\": action_args or {},\n            \"result\": result,\n            \"success\": success,\n        }\n        \n        with open(self.log_path, 'a', encoding='utf-8') as f:\n            f.write(json.dumps(entry, ensure_ascii=False) + '\\n')\n    \n    def log_round_start(self, round_num: int, simulated_hour: int, platform: str):\n        entry = {\n            \"round\": round_num,\n            \"timestamp\": datetime.now().isoformat(),\n            \"platform\": platform,\n            \"event_type\": \"round_start\",\n            \"simulated_hour\": simulated_hour,\n        }\n        \n        with open(self.log_path, 'a', encoding='utf-8') as f:\n            f.write(json.dumps(entry, ensure_ascii=False) + '\\n')\n    \n    def log_round_end(self, round_num: int, actions_count: int, platform: str):\n        entry = {\n            \"round\": round_num,\n            \"timestamp\": datetime.now().isoformat(),\n            \"platform\": platform,\n            \"event_type\": \"round_end\",\n            \"actions_count\": actions_count,\n        }\n        \n        with open(self.log_path, 'a', encoding='utf-8') as f:\n            f.write(json.dumps(entry, ensure_ascii=False) + '\\n')\n    \n    def log_simulation_start(self, platform: str, config: Dict[str, Any]):\n        entry = {\n            \"timestamp\": datetime.now().isoformat(),\n            \"platform\": platform,\n            \"event_type\": \"simulation_start\",\n            \"total_rounds\": config.get(\"time_config\", {}).get(\"total_simulation_hours\", 72) * 2,\n            \"agents_count\": len(config.get(\"agent_configs\", [])),\n        }\n        \n        with open(self.log_path, 'a', encoding='utf-8') as f:\n            f.write(json.dumps(entry, ensure_ascii=False) + '\\n')\n    \n    def log_simulation_end(self, platform: str, total_rounds: int, total_actions: int):\n        entry = {\n            \"timestamp\": datetime.now().isoformat(),\n            \"platform\": platform,\n            \"event_type\": \"simulation_end\",\n            \"total_rounds\": total_rounds,\n            \"total_actions\": total_actions,\n        }\n        \n        with open(self.log_path, 'a', encoding='utf-8') as f:\n            f.write(json.dumps(entry, ensure_ascii=False) + '\\n')\n\n\n# 全局日志实例（兼容旧接口）\n_global_logger: Optional[ActionLogger] = None\n\n\ndef get_logger(log_path: Optional[str] = None) -> ActionLogger:\n    \"\"\"获取全局日志实例（兼容旧接口）\"\"\"\n    global _global_logger\n    \n    if log_path:\n        _global_logger = ActionLogger(log_path)\n    \n    if _global_logger is None:\n        _global_logger = ActionLogger(\"actions.jsonl\")\n    \n    return _global_logger\n"
  },
  {
    "path": "backend/scripts/run_parallel_simulation.py",
    "content": "\"\"\"\nOASIS 双平台并行模拟预设脚本\n同时运行Twitter和Reddit模拟，读取相同的配置文件\n\n功能特性:\n- 双平台（Twitter + Reddit）并行模拟\n- 完成模拟后不立即关闭环境，进入等待命令模式\n- 支持通过IPC接收Interview命令\n- 支持单个Agent采访和批量采访\n- 支持远程关闭环境命令\n\n使用方式:\n    python run_parallel_simulation.py --config simulation_config.json\n    python run_parallel_simulation.py --config simulation_config.json --no-wait  # 完成后立即关闭\n    python run_parallel_simulation.py --config simulation_config.json --twitter-only\n    python run_parallel_simulation.py --config simulation_config.json --reddit-only\n\n日志结构:\n    sim_xxx/\n    ├── twitter/\n    │   └── actions.jsonl    # Twitter 平台动作日志\n    ├── reddit/\n    │   └── actions.jsonl    # Reddit 平台动作日志\n    ├── simulation.log       # 主模拟进程日志\n    └── run_state.json       # 运行状态（API 查询用）\n\"\"\"\n\n# ============================================================\n# 解决 Windows 编码问题：在所有 import 之前设置 UTF-8 编码\n# 这是为了修复 OASIS 第三方库读取文件时未指定编码的问题\n# ============================================================\nimport sys\nimport os\n\nif sys.platform == 'win32':\n    # 设置 Python 默认 I/O 编码为 UTF-8\n    # 这会影响所有未指定编码的 open() 调用\n    os.environ.setdefault('PYTHONUTF8', '1')\n    os.environ.setdefault('PYTHONIOENCODING', 'utf-8')\n    \n    # 重新配置标准输出流为 UTF-8（解决控制台中文乱码）\n    if hasattr(sys.stdout, 'reconfigure'):\n        sys.stdout.reconfigure(encoding='utf-8', errors='replace')\n    if hasattr(sys.stderr, 'reconfigure'):\n        sys.stderr.reconfigure(encoding='utf-8', errors='replace')\n    \n    # 强制设置默认编码（影响 open() 函数的默认编码）\n    # 注意：这需要在 Python 启动时就设置，运行时设置可能不生效\n    # 所以我们还需要 monkey-patch 内置的 open 函数\n    import builtins\n    _original_open = builtins.open\n    \n    def _utf8_open(file, mode='r', buffering=-1, encoding=None, errors=None, \n                   newline=None, closefd=True, opener=None):\n        \"\"\"\n        包装 open() 函数，对于文本模式默认使用 UTF-8 编码\n        这可以修复第三方库（如 OASIS）读取文件时未指定编码的问题\n        \"\"\"\n        # 只对文本模式（非二进制）且未指定编码的情况设置默认编码\n        if encoding is None and 'b' not in mode:\n            encoding = 'utf-8'\n        return _original_open(file, mode, buffering, encoding, errors, \n                              newline, closefd, opener)\n    \n    builtins.open = _utf8_open\n\nimport argparse\nimport asyncio\nimport json\nimport logging\nimport multiprocessing\nimport random\nimport signal\nimport sqlite3\nimport warnings\nfrom datetime import datetime\nfrom typing import Dict, Any, List, Optional, Tuple\n\n\n# 全局变量：用于信号处理\n_shutdown_event = None\n_cleanup_done = False\n\n# 添加 backend 目录到路径\n# 脚本固定位于 backend/scripts/ 目录\n_scripts_dir = os.path.dirname(os.path.abspath(__file__))\n_backend_dir = os.path.abspath(os.path.join(_scripts_dir, '..'))\n_project_root = os.path.abspath(os.path.join(_backend_dir, '..'))\nsys.path.insert(0, _scripts_dir)\nsys.path.insert(0, _backend_dir)\n\n# 加载项目根目录的 .env 文件（包含 LLM_API_KEY 等配置）\nfrom dotenv import load_dotenv\n_env_file = os.path.join(_project_root, '.env')\nif os.path.exists(_env_file):\n    load_dotenv(_env_file)\n    print(f\"已加载环境配置: {_env_file}\")\nelse:\n    # 尝试加载 backend/.env\n    _backend_env = os.path.join(_backend_dir, '.env')\n    if os.path.exists(_backend_env):\n        load_dotenv(_backend_env)\n        print(f\"已加载环境配置: {_backend_env}\")\n\n\nclass MaxTokensWarningFilter(logging.Filter):\n    \"\"\"过滤掉 camel-ai 关于 max_tokens 的警告（我们故意不设置 max_tokens，让模型自行决定）\"\"\"\n    \n    def filter(self, record):\n        # 过滤掉包含 max_tokens 警告的日志\n        if \"max_tokens\" in record.getMessage() and \"Invalid or missing\" in record.getMessage():\n            return False\n        return True\n\n\n# 在模块加载时立即添加过滤器，确保在 camel 代码执行前生效\nlogging.getLogger().addFilter(MaxTokensWarningFilter())\n\n\ndef disable_oasis_logging():\n    \"\"\"\n    禁用 OASIS 库的详细日志输出\n    OASIS 的日志太冗余（记录每个 agent 的观察和动作），我们使用自己的 action_logger\n    \"\"\"\n    # 禁用 OASIS 的所有日志器\n    oasis_loggers = [\n        \"social.agent\",\n        \"social.twitter\", \n        \"social.rec\",\n        \"oasis.env\",\n        \"table\",\n    ]\n    \n    for logger_name in oasis_loggers:\n        logger = logging.getLogger(logger_name)\n        logger.setLevel(logging.CRITICAL)  # 只记录严重错误\n        logger.handlers.clear()\n        logger.propagate = False\n\n\ndef init_logging_for_simulation(simulation_dir: str):\n    \"\"\"\n    初始化模拟的日志配置\n    \n    Args:\n        simulation_dir: 模拟目录路径\n    \"\"\"\n    # 禁用 OASIS 的详细日志\n    disable_oasis_logging()\n    \n    # 清理旧的 log 目录（如果存在）\n    old_log_dir = os.path.join(simulation_dir, \"log\")\n    if os.path.exists(old_log_dir):\n        import shutil\n        shutil.rmtree(old_log_dir, ignore_errors=True)\n\n\nfrom action_logger import SimulationLogManager, PlatformActionLogger\n\ntry:\n    from camel.models import ModelFactory\n    from camel.types import ModelPlatformType\n    import oasis\n    from oasis import (\n        ActionType,\n        LLMAction,\n        ManualAction,\n        generate_twitter_agent_graph,\n        generate_reddit_agent_graph\n    )\nexcept ImportError as e:\n    print(f\"错误: 缺少依赖 {e}\")\n    print(\"请先安装: pip install oasis-ai camel-ai\")\n    sys.exit(1)\n\n\n# Twitter可用动作（不包含INTERVIEW，INTERVIEW只能通过ManualAction手动触发）\nTWITTER_ACTIONS = [\n    ActionType.CREATE_POST,\n    ActionType.LIKE_POST,\n    ActionType.REPOST,\n    ActionType.FOLLOW,\n    ActionType.DO_NOTHING,\n    ActionType.QUOTE_POST,\n]\n\n# Reddit可用动作（不包含INTERVIEW，INTERVIEW只能通过ManualAction手动触发）\nREDDIT_ACTIONS = [\n    ActionType.LIKE_POST,\n    ActionType.DISLIKE_POST,\n    ActionType.CREATE_POST,\n    ActionType.CREATE_COMMENT,\n    ActionType.LIKE_COMMENT,\n    ActionType.DISLIKE_COMMENT,\n    ActionType.SEARCH_POSTS,\n    ActionType.SEARCH_USER,\n    ActionType.TREND,\n    ActionType.REFRESH,\n    ActionType.DO_NOTHING,\n    ActionType.FOLLOW,\n    ActionType.MUTE,\n]\n\n\n# IPC相关常量\nIPC_COMMANDS_DIR = \"ipc_commands\"\nIPC_RESPONSES_DIR = \"ipc_responses\"\nENV_STATUS_FILE = \"env_status.json\"\n\nclass CommandType:\n    \"\"\"命令类型常量\"\"\"\n    INTERVIEW = \"interview\"\n    BATCH_INTERVIEW = \"batch_interview\"\n    CLOSE_ENV = \"close_env\"\n\n\nclass ParallelIPCHandler:\n    \"\"\"\n    双平台IPC命令处理器\n    \n    管理两个平台的环境，处理Interview命令\n    \"\"\"\n    \n    def __init__(\n        self,\n        simulation_dir: str,\n        twitter_env=None,\n        twitter_agent_graph=None,\n        reddit_env=None,\n        reddit_agent_graph=None\n    ):\n        self.simulation_dir = simulation_dir\n        self.twitter_env = twitter_env\n        self.twitter_agent_graph = twitter_agent_graph\n        self.reddit_env = reddit_env\n        self.reddit_agent_graph = reddit_agent_graph\n        \n        self.commands_dir = os.path.join(simulation_dir, IPC_COMMANDS_DIR)\n        self.responses_dir = os.path.join(simulation_dir, IPC_RESPONSES_DIR)\n        self.status_file = os.path.join(simulation_dir, ENV_STATUS_FILE)\n        \n        # 确保目录存在\n        os.makedirs(self.commands_dir, exist_ok=True)\n        os.makedirs(self.responses_dir, exist_ok=True)\n    \n    def update_status(self, status: str):\n        \"\"\"更新环境状态\"\"\"\n        with open(self.status_file, 'w', encoding='utf-8') as f:\n            json.dump({\n                \"status\": status,\n                \"twitter_available\": self.twitter_env is not None,\n                \"reddit_available\": self.reddit_env is not None,\n                \"timestamp\": datetime.now().isoformat()\n            }, f, ensure_ascii=False, indent=2)\n    \n    def poll_command(self) -> Optional[Dict[str, Any]]:\n        \"\"\"轮询获取待处理命令\"\"\"\n        if not os.path.exists(self.commands_dir):\n            return None\n        \n        # 获取命令文件（按时间排序）\n        command_files = []\n        for filename in os.listdir(self.commands_dir):\n            if filename.endswith('.json'):\n                filepath = os.path.join(self.commands_dir, filename)\n                command_files.append((filepath, os.path.getmtime(filepath)))\n        \n        command_files.sort(key=lambda x: x[1])\n        \n        for filepath, _ in command_files:\n            try:\n                with open(filepath, 'r', encoding='utf-8') as f:\n                    return json.load(f)\n            except (json.JSONDecodeError, OSError):\n                continue\n        \n        return None\n    \n    def send_response(self, command_id: str, status: str, result: Dict = None, error: str = None):\n        \"\"\"发送响应\"\"\"\n        response = {\n            \"command_id\": command_id,\n            \"status\": status,\n            \"result\": result,\n            \"error\": error,\n            \"timestamp\": datetime.now().isoformat()\n        }\n        \n        response_file = os.path.join(self.responses_dir, f\"{command_id}.json\")\n        with open(response_file, 'w', encoding='utf-8') as f:\n            json.dump(response, f, ensure_ascii=False, indent=2)\n        \n        # 删除命令文件\n        command_file = os.path.join(self.commands_dir, f\"{command_id}.json\")\n        try:\n            os.remove(command_file)\n        except OSError:\n            pass\n    \n    def _get_env_and_graph(self, platform: str):\n        \"\"\"\n        获取指定平台的环境和agent_graph\n        \n        Args:\n            platform: 平台名称 (\"twitter\" 或 \"reddit\")\n            \n        Returns:\n            (env, agent_graph, platform_name) 或 (None, None, None)\n        \"\"\"\n        if platform == \"twitter\" and self.twitter_env:\n            return self.twitter_env, self.twitter_agent_graph, \"twitter\"\n        elif platform == \"reddit\" and self.reddit_env:\n            return self.reddit_env, self.reddit_agent_graph, \"reddit\"\n        else:\n            return None, None, None\n    \n    async def _interview_single_platform(self, agent_id: int, prompt: str, platform: str) -> Dict[str, Any]:\n        \"\"\"\n        在单个平台上执行Interview\n        \n        Returns:\n            包含结果的字典，或包含error的字典\n        \"\"\"\n        env, agent_graph, actual_platform = self._get_env_and_graph(platform)\n        \n        if not env or not agent_graph:\n            return {\"platform\": platform, \"error\": f\"{platform}平台不可用\"}\n        \n        try:\n            agent = agent_graph.get_agent(agent_id)\n            interview_action = ManualAction(\n                action_type=ActionType.INTERVIEW,\n                action_args={\"prompt\": prompt}\n            )\n            actions = {agent: interview_action}\n            await env.step(actions)\n            \n            result = self._get_interview_result(agent_id, actual_platform)\n            result[\"platform\"] = actual_platform\n            return result\n            \n        except Exception as e:\n            return {\"platform\": platform, \"error\": str(e)}\n    \n    async def handle_interview(self, command_id: str, agent_id: int, prompt: str, platform: str = None) -> bool:\n        \"\"\"\n        处理单个Agent采访命令\n        \n        Args:\n            command_id: 命令ID\n            agent_id: Agent ID\n            prompt: 采访问题\n            platform: 指定平台（可选）\n                - \"twitter\": 只采访Twitter平台\n                - \"reddit\": 只采访Reddit平台\n                - None/不指定: 同时采访两个平台，返回整合结果\n            \n        Returns:\n            True 表示成功，False 表示失败\n        \"\"\"\n        # 如果指定了平台，只采访该平台\n        if platform in (\"twitter\", \"reddit\"):\n            result = await self._interview_single_platform(agent_id, prompt, platform)\n            \n            if \"error\" in result:\n                self.send_response(command_id, \"failed\", error=result[\"error\"])\n                print(f\"  Interview失败: agent_id={agent_id}, platform={platform}, error={result['error']}\")\n                return False\n            else:\n                self.send_response(command_id, \"completed\", result=result)\n                print(f\"  Interview完成: agent_id={agent_id}, platform={platform}\")\n                return True\n        \n        # 未指定平台：同时采访两个平台\n        if not self.twitter_env and not self.reddit_env:\n            self.send_response(command_id, \"failed\", error=\"没有可用的模拟环境\")\n            return False\n        \n        results = {\n            \"agent_id\": agent_id,\n            \"prompt\": prompt,\n            \"platforms\": {}\n        }\n        success_count = 0\n        \n        # 并行采访两个平台\n        tasks = []\n        platforms_to_interview = []\n        \n        if self.twitter_env:\n            tasks.append(self._interview_single_platform(agent_id, prompt, \"twitter\"))\n            platforms_to_interview.append(\"twitter\")\n        \n        if self.reddit_env:\n            tasks.append(self._interview_single_platform(agent_id, prompt, \"reddit\"))\n            platforms_to_interview.append(\"reddit\")\n        \n        # 并行执行\n        platform_results = await asyncio.gather(*tasks)\n        \n        for platform_name, platform_result in zip(platforms_to_interview, platform_results):\n            results[\"platforms\"][platform_name] = platform_result\n            if \"error\" not in platform_result:\n                success_count += 1\n        \n        if success_count > 0:\n            self.send_response(command_id, \"completed\", result=results)\n            print(f\"  Interview完成: agent_id={agent_id}, 成功平台数={success_count}/{len(platforms_to_interview)}\")\n            return True\n        else:\n            errors = [f\"{p}: {r.get('error', '未知错误')}\" for p, r in results[\"platforms\"].items()]\n            self.send_response(command_id, \"failed\", error=\"; \".join(errors))\n            print(f\"  Interview失败: agent_id={agent_id}, 所有平台都失败\")\n            return False\n    \n    async def handle_batch_interview(self, command_id: str, interviews: List[Dict], platform: str = None) -> bool:\n        \"\"\"\n        处理批量采访命令\n        \n        Args:\n            command_id: 命令ID\n            interviews: [{\"agent_id\": int, \"prompt\": str, \"platform\": str(optional)}, ...]\n            platform: 默认平台（可被每个interview项覆盖）\n                - \"twitter\": 只采访Twitter平台\n                - \"reddit\": 只采访Reddit平台\n                - None/不指定: 每个Agent同时采访两个平台\n        \"\"\"\n        # 按平台分组\n        twitter_interviews = []\n        reddit_interviews = []\n        both_platforms_interviews = []  # 需要同时采访两个平台的\n        \n        for interview in interviews:\n            item_platform = interview.get(\"platform\", platform)\n            if item_platform == \"twitter\":\n                twitter_interviews.append(interview)\n            elif item_platform == \"reddit\":\n                reddit_interviews.append(interview)\n            else:\n                # 未指定平台：两个平台都采访\n                both_platforms_interviews.append(interview)\n        \n        # 把 both_platforms_interviews 拆分到两个平台\n        if both_platforms_interviews:\n            if self.twitter_env:\n                twitter_interviews.extend(both_platforms_interviews)\n            if self.reddit_env:\n                reddit_interviews.extend(both_platforms_interviews)\n        \n        results = {}\n        \n        # 处理Twitter平台的采访\n        if twitter_interviews and self.twitter_env:\n            try:\n                twitter_actions = {}\n                for interview in twitter_interviews:\n                    agent_id = interview.get(\"agent_id\")\n                    prompt = interview.get(\"prompt\", \"\")\n                    try:\n                        agent = self.twitter_agent_graph.get_agent(agent_id)\n                        twitter_actions[agent] = ManualAction(\n                            action_type=ActionType.INTERVIEW,\n                            action_args={\"prompt\": prompt}\n                        )\n                    except Exception as e:\n                        print(f\"  警告: 无法获取Twitter Agent {agent_id}: {e}\")\n                \n                if twitter_actions:\n                    await self.twitter_env.step(twitter_actions)\n                    \n                    for interview in twitter_interviews:\n                        agent_id = interview.get(\"agent_id\")\n                        result = self._get_interview_result(agent_id, \"twitter\")\n                        result[\"platform\"] = \"twitter\"\n                        results[f\"twitter_{agent_id}\"] = result\n            except Exception as e:\n                print(f\"  Twitter批量Interview失败: {e}\")\n        \n        # 处理Reddit平台的采访\n        if reddit_interviews and self.reddit_env:\n            try:\n                reddit_actions = {}\n                for interview in reddit_interviews:\n                    agent_id = interview.get(\"agent_id\")\n                    prompt = interview.get(\"prompt\", \"\")\n                    try:\n                        agent = self.reddit_agent_graph.get_agent(agent_id)\n                        reddit_actions[agent] = ManualAction(\n                            action_type=ActionType.INTERVIEW,\n                            action_args={\"prompt\": prompt}\n                        )\n                    except Exception as e:\n                        print(f\"  警告: 无法获取Reddit Agent {agent_id}: {e}\")\n                \n                if reddit_actions:\n                    await self.reddit_env.step(reddit_actions)\n                    \n                    for interview in reddit_interviews:\n                        agent_id = interview.get(\"agent_id\")\n                        result = self._get_interview_result(agent_id, \"reddit\")\n                        result[\"platform\"] = \"reddit\"\n                        results[f\"reddit_{agent_id}\"] = result\n            except Exception as e:\n                print(f\"  Reddit批量Interview失败: {e}\")\n        \n        if results:\n            self.send_response(command_id, \"completed\", result={\n                \"interviews_count\": len(results),\n                \"results\": results\n            })\n            print(f\"  批量Interview完成: {len(results)} 个Agent\")\n            return True\n        else:\n            self.send_response(command_id, \"failed\", error=\"没有成功的采访\")\n            return False\n    \n    def _get_interview_result(self, agent_id: int, platform: str) -> Dict[str, Any]:\n        \"\"\"从数据库获取最新的Interview结果\"\"\"\n        db_path = os.path.join(self.simulation_dir, f\"{platform}_simulation.db\")\n        \n        result = {\n            \"agent_id\": agent_id,\n            \"response\": None,\n            \"timestamp\": None\n        }\n        \n        if not os.path.exists(db_path):\n            return result\n        \n        try:\n            conn = sqlite3.connect(db_path)\n            cursor = conn.cursor()\n            \n            # 查询最新的Interview记录\n            cursor.execute(\"\"\"\n                SELECT user_id, info, created_at\n                FROM trace\n                WHERE action = ? AND user_id = ?\n                ORDER BY created_at DESC\n                LIMIT 1\n            \"\"\", (ActionType.INTERVIEW.value, agent_id))\n            \n            row = cursor.fetchone()\n            if row:\n                user_id, info_json, created_at = row\n                try:\n                    info = json.loads(info_json) if info_json else {}\n                    result[\"response\"] = info.get(\"response\", info)\n                    result[\"timestamp\"] = created_at\n                except json.JSONDecodeError:\n                    result[\"response\"] = info_json\n            \n            conn.close()\n            \n        except Exception as e:\n            print(f\"  读取Interview结果失败: {e}\")\n        \n        return result\n    \n    async def process_commands(self) -> bool:\n        \"\"\"\n        处理所有待处理命令\n        \n        Returns:\n            True 表示继续运行，False 表示应该退出\n        \"\"\"\n        command = self.poll_command()\n        if not command:\n            return True\n        \n        command_id = command.get(\"command_id\")\n        command_type = command.get(\"command_type\")\n        args = command.get(\"args\", {})\n        \n        print(f\"\\n收到IPC命令: {command_type}, id={command_id}\")\n        \n        if command_type == CommandType.INTERVIEW:\n            await self.handle_interview(\n                command_id,\n                args.get(\"agent_id\", 0),\n                args.get(\"prompt\", \"\"),\n                args.get(\"platform\")\n            )\n            return True\n            \n        elif command_type == CommandType.BATCH_INTERVIEW:\n            await self.handle_batch_interview(\n                command_id,\n                args.get(\"interviews\", []),\n                args.get(\"platform\")\n            )\n            return True\n            \n        elif command_type == CommandType.CLOSE_ENV:\n            print(\"收到关闭环境命令\")\n            self.send_response(command_id, \"completed\", result={\"message\": \"环境即将关闭\"})\n            return False\n        \n        else:\n            self.send_response(command_id, \"failed\", error=f\"未知命令类型: {command_type}\")\n            return True\n\n\ndef load_config(config_path: str) -> Dict[str, Any]:\n    \"\"\"加载配置文件\"\"\"\n    with open(config_path, 'r', encoding='utf-8') as f:\n        return json.load(f)\n\n\n# 需要过滤掉的非核心动作类型（这些动作对分析价值较低）\nFILTERED_ACTIONS = {'refresh', 'sign_up'}\n\n# 动作类型映射表（数据库中的名称 -> 标准名称）\nACTION_TYPE_MAP = {\n    'create_post': 'CREATE_POST',\n    'like_post': 'LIKE_POST',\n    'dislike_post': 'DISLIKE_POST',\n    'repost': 'REPOST',\n    'quote_post': 'QUOTE_POST',\n    'follow': 'FOLLOW',\n    'mute': 'MUTE',\n    'create_comment': 'CREATE_COMMENT',\n    'like_comment': 'LIKE_COMMENT',\n    'dislike_comment': 'DISLIKE_COMMENT',\n    'search_posts': 'SEARCH_POSTS',\n    'search_user': 'SEARCH_USER',\n    'trend': 'TREND',\n    'do_nothing': 'DO_NOTHING',\n    'interview': 'INTERVIEW',\n}\n\n\ndef get_agent_names_from_config(config: Dict[str, Any]) -> Dict[int, str]:\n    \"\"\"\n    从 simulation_config 中获取 agent_id -> entity_name 的映射\n    \n    这样可以在 actions.jsonl 中显示真实的实体名称，而不是 \"Agent_0\" 这样的代号\n    \n    Args:\n        config: simulation_config.json 的内容\n        \n    Returns:\n        agent_id -> entity_name 的映射字典\n    \"\"\"\n    agent_names = {}\n    agent_configs = config.get(\"agent_configs\", [])\n    \n    for agent_config in agent_configs:\n        agent_id = agent_config.get(\"agent_id\")\n        entity_name = agent_config.get(\"entity_name\", f\"Agent_{agent_id}\")\n        if agent_id is not None:\n            agent_names[agent_id] = entity_name\n    \n    return agent_names\n\n\ndef fetch_new_actions_from_db(\n    db_path: str,\n    last_rowid: int,\n    agent_names: Dict[int, str]\n) -> Tuple[List[Dict[str, Any]], int]:\n    \"\"\"\n    从数据库中获取新的动作记录，并补充完整的上下文信息\n    \n    Args:\n        db_path: 数据库文件路径\n        last_rowid: 上次读取的最大 rowid 值（使用 rowid 而不是 created_at，因为不同平台的 created_at 格式不同）\n        agent_names: agent_id -> agent_name 映射\n        \n    Returns:\n        (actions_list, new_last_rowid)\n        - actions_list: 动作列表，每个元素包含 agent_id, agent_name, action_type, action_args（含上下文信息）\n        - new_last_rowid: 新的最大 rowid 值\n    \"\"\"\n    actions = []\n    new_last_rowid = last_rowid\n    \n    if not os.path.exists(db_path):\n        return actions, new_last_rowid\n    \n    try:\n        conn = sqlite3.connect(db_path)\n        cursor = conn.cursor()\n        \n        # 使用 rowid 来追踪已处理的记录（rowid 是 SQLite 的内置自增字段）\n        # 这样可以避免 created_at 格式差异问题（Twitter 用整数，Reddit 用日期时间字符串）\n        cursor.execute(\"\"\"\n            SELECT rowid, user_id, action, info\n            FROM trace\n            WHERE rowid > ?\n            ORDER BY rowid ASC\n        \"\"\", (last_rowid,))\n        \n        for rowid, user_id, action, info_json in cursor.fetchall():\n            # 更新最大 rowid\n            new_last_rowid = rowid\n            \n            # 过滤非核心动作\n            if action in FILTERED_ACTIONS:\n                continue\n            \n            # 解析动作参数\n            try:\n                action_args = json.loads(info_json) if info_json else {}\n            except json.JSONDecodeError:\n                action_args = {}\n            \n            # 精简 action_args，只保留关键字段（保留完整内容，不截断）\n            simplified_args = {}\n            if 'content' in action_args:\n                simplified_args['content'] = action_args['content']\n            if 'post_id' in action_args:\n                simplified_args['post_id'] = action_args['post_id']\n            if 'comment_id' in action_args:\n                simplified_args['comment_id'] = action_args['comment_id']\n            if 'quoted_id' in action_args:\n                simplified_args['quoted_id'] = action_args['quoted_id']\n            if 'new_post_id' in action_args:\n                simplified_args['new_post_id'] = action_args['new_post_id']\n            if 'follow_id' in action_args:\n                simplified_args['follow_id'] = action_args['follow_id']\n            if 'query' in action_args:\n                simplified_args['query'] = action_args['query']\n            if 'like_id' in action_args:\n                simplified_args['like_id'] = action_args['like_id']\n            if 'dislike_id' in action_args:\n                simplified_args['dislike_id'] = action_args['dislike_id']\n            \n            # 转换动作类型名称\n            action_type = ACTION_TYPE_MAP.get(action, action.upper())\n            \n            # 补充上下文信息（帖子内容、用户名等）\n            _enrich_action_context(cursor, action_type, simplified_args, agent_names)\n            \n            actions.append({\n                'agent_id': user_id,\n                'agent_name': agent_names.get(user_id, f'Agent_{user_id}'),\n                'action_type': action_type,\n                'action_args': simplified_args,\n            })\n        \n        conn.close()\n    except Exception as e:\n        print(f\"读取数据库动作失败: {e}\")\n    \n    return actions, new_last_rowid\n\n\ndef _enrich_action_context(\n    cursor,\n    action_type: str,\n    action_args: Dict[str, Any],\n    agent_names: Dict[int, str]\n) -> None:\n    \"\"\"\n    为动作补充上下文信息（帖子内容、用户名等）\n    \n    Args:\n        cursor: 数据库游标\n        action_type: 动作类型\n        action_args: 动作参数（会被修改）\n        agent_names: agent_id -> agent_name 映射\n    \"\"\"\n    try:\n        # 点赞/踩帖子：补充帖子内容和作者\n        if action_type in ('LIKE_POST', 'DISLIKE_POST'):\n            post_id = action_args.get('post_id')\n            if post_id:\n                post_info = _get_post_info(cursor, post_id, agent_names)\n                if post_info:\n                    action_args['post_content'] = post_info.get('content', '')\n                    action_args['post_author_name'] = post_info.get('author_name', '')\n        \n        # 转发帖子：补充原帖内容和作者\n        elif action_type == 'REPOST':\n            new_post_id = action_args.get('new_post_id')\n            if new_post_id:\n                # 转发帖子的 original_post_id 指向原帖\n                cursor.execute(\"\"\"\n                    SELECT original_post_id FROM post WHERE post_id = ?\n                \"\"\", (new_post_id,))\n                row = cursor.fetchone()\n                if row and row[0]:\n                    original_post_id = row[0]\n                    original_info = _get_post_info(cursor, original_post_id, agent_names)\n                    if original_info:\n                        action_args['original_content'] = original_info.get('content', '')\n                        action_args['original_author_name'] = original_info.get('author_name', '')\n        \n        # 引用帖子：补充原帖内容、作者和引用评论\n        elif action_type == 'QUOTE_POST':\n            quoted_id = action_args.get('quoted_id')\n            new_post_id = action_args.get('new_post_id')\n            \n            if quoted_id:\n                original_info = _get_post_info(cursor, quoted_id, agent_names)\n                if original_info:\n                    action_args['original_content'] = original_info.get('content', '')\n                    action_args['original_author_name'] = original_info.get('author_name', '')\n            \n            # 获取引用帖子的评论内容（quote_content）\n            if new_post_id:\n                cursor.execute(\"\"\"\n                    SELECT quote_content FROM post WHERE post_id = ?\n                \"\"\", (new_post_id,))\n                row = cursor.fetchone()\n                if row and row[0]:\n                    action_args['quote_content'] = row[0]\n        \n        # 关注用户：补充被关注用户的名称\n        elif action_type == 'FOLLOW':\n            follow_id = action_args.get('follow_id')\n            if follow_id:\n                # 从 follow 表获取 followee_id\n                cursor.execute(\"\"\"\n                    SELECT followee_id FROM follow WHERE follow_id = ?\n                \"\"\", (follow_id,))\n                row = cursor.fetchone()\n                if row:\n                    followee_id = row[0]\n                    target_name = _get_user_name(cursor, followee_id, agent_names)\n                    if target_name:\n                        action_args['target_user_name'] = target_name\n        \n        # 屏蔽用户：补充被屏蔽用户的名称\n        elif action_type == 'MUTE':\n            # 从 action_args 中获取 user_id 或 target_id\n            target_id = action_args.get('user_id') or action_args.get('target_id')\n            if target_id:\n                target_name = _get_user_name(cursor, target_id, agent_names)\n                if target_name:\n                    action_args['target_user_name'] = target_name\n        \n        # 点赞/踩评论：补充评论内容和作者\n        elif action_type in ('LIKE_COMMENT', 'DISLIKE_COMMENT'):\n            comment_id = action_args.get('comment_id')\n            if comment_id:\n                comment_info = _get_comment_info(cursor, comment_id, agent_names)\n                if comment_info:\n                    action_args['comment_content'] = comment_info.get('content', '')\n                    action_args['comment_author_name'] = comment_info.get('author_name', '')\n        \n        # 发表评论：补充所评论的帖子信息\n        elif action_type == 'CREATE_COMMENT':\n            post_id = action_args.get('post_id')\n            if post_id:\n                post_info = _get_post_info(cursor, post_id, agent_names)\n                if post_info:\n                    action_args['post_content'] = post_info.get('content', '')\n                    action_args['post_author_name'] = post_info.get('author_name', '')\n    \n    except Exception as e:\n        # 补充上下文失败不影响主流程\n        print(f\"补充动作上下文失败: {e}\")\n\n\ndef _get_post_info(\n    cursor,\n    post_id: int,\n    agent_names: Dict[int, str]\n) -> Optional[Dict[str, str]]:\n    \"\"\"\n    获取帖子信息\n    \n    Args:\n        cursor: 数据库游标\n        post_id: 帖子ID\n        agent_names: agent_id -> agent_name 映射\n        \n    Returns:\n        包含 content 和 author_name 的字典，或 None\n    \"\"\"\n    try:\n        cursor.execute(\"\"\"\n            SELECT p.content, p.user_id, u.agent_id\n            FROM post p\n            LEFT JOIN user u ON p.user_id = u.user_id\n            WHERE p.post_id = ?\n        \"\"\", (post_id,))\n        row = cursor.fetchone()\n        if row:\n            content = row[0] or ''\n            user_id = row[1]\n            agent_id = row[2]\n            \n            # 优先使用 agent_names 中的名称\n            author_name = ''\n            if agent_id is not None and agent_id in agent_names:\n                author_name = agent_names[agent_id]\n            elif user_id:\n                # 从 user 表获取名称\n                cursor.execute(\"SELECT name, user_name FROM user WHERE user_id = ?\", (user_id,))\n                user_row = cursor.fetchone()\n                if user_row:\n                    author_name = user_row[0] or user_row[1] or ''\n            \n            return {'content': content, 'author_name': author_name}\n    except Exception:\n        pass\n    return None\n\n\ndef _get_user_name(\n    cursor,\n    user_id: int,\n    agent_names: Dict[int, str]\n) -> Optional[str]:\n    \"\"\"\n    获取用户名称\n    \n    Args:\n        cursor: 数据库游标\n        user_id: 用户ID\n        agent_names: agent_id -> agent_name 映射\n        \n    Returns:\n        用户名称，或 None\n    \"\"\"\n    try:\n        cursor.execute(\"\"\"\n            SELECT agent_id, name, user_name FROM user WHERE user_id = ?\n        \"\"\", (user_id,))\n        row = cursor.fetchone()\n        if row:\n            agent_id = row[0]\n            name = row[1]\n            user_name = row[2]\n            \n            # 优先使用 agent_names 中的名称\n            if agent_id is not None and agent_id in agent_names:\n                return agent_names[agent_id]\n            return name or user_name or ''\n    except Exception:\n        pass\n    return None\n\n\ndef _get_comment_info(\n    cursor,\n    comment_id: int,\n    agent_names: Dict[int, str]\n) -> Optional[Dict[str, str]]:\n    \"\"\"\n    获取评论信息\n    \n    Args:\n        cursor: 数据库游标\n        comment_id: 评论ID\n        agent_names: agent_id -> agent_name 映射\n        \n    Returns:\n        包含 content 和 author_name 的字典，或 None\n    \"\"\"\n    try:\n        cursor.execute(\"\"\"\n            SELECT c.content, c.user_id, u.agent_id\n            FROM comment c\n            LEFT JOIN user u ON c.user_id = u.user_id\n            WHERE c.comment_id = ?\n        \"\"\", (comment_id,))\n        row = cursor.fetchone()\n        if row:\n            content = row[0] or ''\n            user_id = row[1]\n            agent_id = row[2]\n            \n            # 优先使用 agent_names 中的名称\n            author_name = ''\n            if agent_id is not None and agent_id in agent_names:\n                author_name = agent_names[agent_id]\n            elif user_id:\n                # 从 user 表获取名称\n                cursor.execute(\"SELECT name, user_name FROM user WHERE user_id = ?\", (user_id,))\n                user_row = cursor.fetchone()\n                if user_row:\n                    author_name = user_row[0] or user_row[1] or ''\n            \n            return {'content': content, 'author_name': author_name}\n    except Exception:\n        pass\n    return None\n\n\ndef create_model(config: Dict[str, Any], use_boost: bool = False):\n    \"\"\"\n    创建LLM模型\n    \n    支持双 LLM 配置，用于并行模拟时提速：\n    - 通用配置：LLM_API_KEY, LLM_BASE_URL, LLM_MODEL_NAME\n    - 加速配置（可选）：LLM_BOOST_API_KEY, LLM_BOOST_BASE_URL, LLM_BOOST_MODEL_NAME\n    \n    如果配置了加速 LLM，并行模拟时可以让不同平台使用不同的 API 服务商，提高并发能力。\n    \n    Args:\n        config: 模拟配置字典\n        use_boost: 是否使用加速 LLM 配置（如果可用）\n    \"\"\"\n    # 检查是否有加速配置\n    boost_api_key = os.environ.get(\"LLM_BOOST_API_KEY\", \"\")\n    boost_base_url = os.environ.get(\"LLM_BOOST_BASE_URL\", \"\")\n    boost_model = os.environ.get(\"LLM_BOOST_MODEL_NAME\", \"\")\n    has_boost_config = bool(boost_api_key)\n    \n    # 根据参数和配置情况选择使用哪个 LLM\n    if use_boost and has_boost_config:\n        # 使用加速配置\n        llm_api_key = boost_api_key\n        llm_base_url = boost_base_url\n        llm_model = boost_model or os.environ.get(\"LLM_MODEL_NAME\", \"\")\n        config_label = \"[加速LLM]\"\n    else:\n        # 使用通用配置\n        llm_api_key = os.environ.get(\"LLM_API_KEY\", \"\")\n        llm_base_url = os.environ.get(\"LLM_BASE_URL\", \"\")\n        llm_model = os.environ.get(\"LLM_MODEL_NAME\", \"\")\n        config_label = \"[通用LLM]\"\n    \n    # 如果 .env 中没有模型名，则使用 config 作为备用\n    if not llm_model:\n        llm_model = config.get(\"llm_model\", \"gpt-4o-mini\")\n    \n    # 设置 camel-ai 所需的环境变量\n    if llm_api_key:\n        os.environ[\"OPENAI_API_KEY\"] = llm_api_key\n    \n    if not os.environ.get(\"OPENAI_API_KEY\"):\n        raise ValueError(\"缺少 API Key 配置，请在项目根目录 .env 文件中设置 LLM_API_KEY\")\n    \n    if llm_base_url:\n        os.environ[\"OPENAI_API_BASE_URL\"] = llm_base_url\n    \n    print(f\"{config_label} model={llm_model}, base_url={llm_base_url[:40] if llm_base_url else '默认'}...\")\n    \n    return ModelFactory.create(\n        model_platform=ModelPlatformType.OPENAI,\n        model_type=llm_model,\n    )\n\n\ndef get_active_agents_for_round(\n    env,\n    config: Dict[str, Any],\n    current_hour: int,\n    round_num: int\n) -> List:\n    \"\"\"根据时间和配置决定本轮激活哪些Agent\"\"\"\n    time_config = config.get(\"time_config\", {})\n    agent_configs = config.get(\"agent_configs\", [])\n    \n    base_min = time_config.get(\"agents_per_hour_min\", 5)\n    base_max = time_config.get(\"agents_per_hour_max\", 20)\n    \n    peak_hours = time_config.get(\"peak_hours\", [9, 10, 11, 14, 15, 20, 21, 22])\n    off_peak_hours = time_config.get(\"off_peak_hours\", [0, 1, 2, 3, 4, 5])\n    \n    if current_hour in peak_hours:\n        multiplier = time_config.get(\"peak_activity_multiplier\", 1.5)\n    elif current_hour in off_peak_hours:\n        multiplier = time_config.get(\"off_peak_activity_multiplier\", 0.3)\n    else:\n        multiplier = 1.0\n    \n    target_count = int(random.uniform(base_min, base_max) * multiplier)\n    \n    candidates = []\n    for cfg in agent_configs:\n        agent_id = cfg.get(\"agent_id\", 0)\n        active_hours = cfg.get(\"active_hours\", list(range(8, 23)))\n        activity_level = cfg.get(\"activity_level\", 0.5)\n        \n        if current_hour not in active_hours:\n            continue\n        \n        if random.random() < activity_level:\n            candidates.append(agent_id)\n    \n    selected_ids = random.sample(\n        candidates, \n        min(target_count, len(candidates))\n    ) if candidates else []\n    \n    active_agents = []\n    for agent_id in selected_ids:\n        try:\n            agent = env.agent_graph.get_agent(agent_id)\n            active_agents.append((agent_id, agent))\n        except Exception:\n            pass\n    \n    return active_agents\n\n\nclass PlatformSimulation:\n    \"\"\"平台模拟结果容器\"\"\"\n    def __init__(self):\n        self.env = None\n        self.agent_graph = None\n        self.total_actions = 0\n\n\nasync def run_twitter_simulation(\n    config: Dict[str, Any], \n    simulation_dir: str,\n    action_logger: Optional[PlatformActionLogger] = None,\n    main_logger: Optional[SimulationLogManager] = None,\n    max_rounds: Optional[int] = None\n) -> PlatformSimulation:\n    \"\"\"运行Twitter模拟\n    \n    Args:\n        config: 模拟配置\n        simulation_dir: 模拟目录\n        action_logger: 动作日志记录器\n        main_logger: 主日志管理器\n        max_rounds: 最大模拟轮数（可选，用于截断过长的模拟）\n        \n    Returns:\n        PlatformSimulation: 包含env和agent_graph的结果对象\n    \"\"\"\n    result = PlatformSimulation()\n    \n    def log_info(msg):\n        if main_logger:\n            main_logger.info(f\"[Twitter] {msg}\")\n        print(f\"[Twitter] {msg}\")\n    \n    log_info(\"初始化...\")\n    \n    # Twitter 使用通用 LLM 配置\n    model = create_model(config, use_boost=False)\n    \n    # OASIS Twitter使用CSV格式\n    profile_path = os.path.join(simulation_dir, \"twitter_profiles.csv\")\n    if not os.path.exists(profile_path):\n        log_info(f\"错误: Profile文件不存在: {profile_path}\")\n        return result\n    \n    result.agent_graph = await generate_twitter_agent_graph(\n        profile_path=profile_path,\n        model=model,\n        available_actions=TWITTER_ACTIONS,\n    )\n    \n    # 从配置文件获取 Agent 真实名称映射（使用 entity_name 而非默认的 Agent_X）\n    agent_names = get_agent_names_from_config(config)\n    # 如果配置中没有某个 agent，则使用 OASIS 的默认名称\n    for agent_id, agent in result.agent_graph.get_agents():\n        if agent_id not in agent_names:\n            agent_names[agent_id] = getattr(agent, 'name', f'Agent_{agent_id}')\n    \n    db_path = os.path.join(simulation_dir, \"twitter_simulation.db\")\n    if os.path.exists(db_path):\n        os.remove(db_path)\n    \n    result.env = oasis.make(\n        agent_graph=result.agent_graph,\n        platform=oasis.DefaultPlatformType.TWITTER,\n        database_path=db_path,\n        semaphore=30,  # 限制最大并发 LLM 请求数，防止 API 过载\n    )\n    \n    await result.env.reset()\n    log_info(\"环境已启动\")\n    \n    if action_logger:\n        action_logger.log_simulation_start(config)\n    \n    total_actions = 0\n    last_rowid = 0  # 跟踪数据库中最后处理的行号（使用 rowid 避免 created_at 格式差异）\n    \n    # 执行初始事件\n    event_config = config.get(\"event_config\", {})\n    initial_posts = event_config.get(\"initial_posts\", [])\n    \n    # 记录 round 0 开始（初始事件阶段）\n    if action_logger:\n        action_logger.log_round_start(0, 0)  # round 0, simulated_hour 0\n    \n    initial_action_count = 0\n    if initial_posts:\n        initial_actions = {}\n        for post in initial_posts:\n            agent_id = post.get(\"poster_agent_id\", 0)\n            content = post.get(\"content\", \"\")\n            try:\n                agent = result.env.agent_graph.get_agent(agent_id)\n                initial_actions[agent] = ManualAction(\n                    action_type=ActionType.CREATE_POST,\n                    action_args={\"content\": content}\n                )\n                \n                if action_logger:\n                    action_logger.log_action(\n                        round_num=0,\n                        agent_id=agent_id,\n                        agent_name=agent_names.get(agent_id, f\"Agent_{agent_id}\"),\n                        action_type=\"CREATE_POST\",\n                        action_args={\"content\": content}\n                    )\n                    total_actions += 1\n                    initial_action_count += 1\n            except Exception:\n                pass\n        \n        if initial_actions:\n            await result.env.step(initial_actions)\n            log_info(f\"已发布 {len(initial_actions)} 条初始帖子\")\n    \n    # 记录 round 0 结束\n    if action_logger:\n        action_logger.log_round_end(0, initial_action_count)\n    \n    # 主模拟循环\n    time_config = config.get(\"time_config\", {})\n    total_hours = time_config.get(\"total_simulation_hours\", 72)\n    minutes_per_round = time_config.get(\"minutes_per_round\", 30)\n    total_rounds = (total_hours * 60) // minutes_per_round\n    \n    # 如果指定了最大轮数，则截断\n    if max_rounds is not None and max_rounds > 0:\n        original_rounds = total_rounds\n        total_rounds = min(total_rounds, max_rounds)\n        if total_rounds < original_rounds:\n            log_info(f\"轮数已截断: {original_rounds} -> {total_rounds} (max_rounds={max_rounds})\")\n    \n    start_time = datetime.now()\n    \n    for round_num in range(total_rounds):\n        # 检查是否收到退出信号\n        if _shutdown_event and _shutdown_event.is_set():\n            if main_logger:\n                main_logger.info(f\"收到退出信号，在第 {round_num + 1} 轮停止模拟\")\n            break\n        \n        simulated_minutes = round_num * minutes_per_round\n        simulated_hour = (simulated_minutes // 60) % 24\n        simulated_day = simulated_minutes // (60 * 24) + 1\n        \n        active_agents = get_active_agents_for_round(\n            result.env, config, simulated_hour, round_num\n        )\n        \n        # 无论是否有活跃agent，都记录round开始\n        if action_logger:\n            action_logger.log_round_start(round_num + 1, simulated_hour)\n        \n        if not active_agents:\n            # 没有活跃agent时也记录round结束（actions_count=0）\n            if action_logger:\n                action_logger.log_round_end(round_num + 1, 0)\n            continue\n        \n        actions = {agent: LLMAction() for _, agent in active_agents}\n        await result.env.step(actions)\n        \n        # 从数据库获取实际执行的动作并记录\n        actual_actions, last_rowid = fetch_new_actions_from_db(\n            db_path, last_rowid, agent_names\n        )\n        \n        round_action_count = 0\n        for action_data in actual_actions:\n            if action_logger:\n                action_logger.log_action(\n                    round_num=round_num + 1,\n                    agent_id=action_data['agent_id'],\n                    agent_name=action_data['agent_name'],\n                    action_type=action_data['action_type'],\n                    action_args=action_data['action_args']\n                )\n                total_actions += 1\n                round_action_count += 1\n        \n        if action_logger:\n            action_logger.log_round_end(round_num + 1, round_action_count)\n        \n        if (round_num + 1) % 20 == 0:\n            progress = (round_num + 1) / total_rounds * 100\n            log_info(f\"Day {simulated_day}, {simulated_hour:02d}:00 - Round {round_num + 1}/{total_rounds} ({progress:.1f}%)\")\n    \n    # 注意：不关闭环境，保留给Interview使用\n    \n    if action_logger:\n        action_logger.log_simulation_end(total_rounds, total_actions)\n    \n    result.total_actions = total_actions\n    elapsed = (datetime.now() - start_time).total_seconds()\n    log_info(f\"模拟循环完成! 耗时: {elapsed:.1f}秒, 总动作: {total_actions}\")\n    \n    return result\n\n\nasync def run_reddit_simulation(\n    config: Dict[str, Any], \n    simulation_dir: str,\n    action_logger: Optional[PlatformActionLogger] = None,\n    main_logger: Optional[SimulationLogManager] = None,\n    max_rounds: Optional[int] = None\n) -> PlatformSimulation:\n    \"\"\"运行Reddit模拟\n    \n    Args:\n        config: 模拟配置\n        simulation_dir: 模拟目录\n        action_logger: 动作日志记录器\n        main_logger: 主日志管理器\n        max_rounds: 最大模拟轮数（可选，用于截断过长的模拟）\n        \n    Returns:\n        PlatformSimulation: 包含env和agent_graph的结果对象\n    \"\"\"\n    result = PlatformSimulation()\n    \n    def log_info(msg):\n        if main_logger:\n            main_logger.info(f\"[Reddit] {msg}\")\n        print(f\"[Reddit] {msg}\")\n    \n    log_info(\"初始化...\")\n    \n    # Reddit 使用加速 LLM 配置（如果有的话，否则回退到通用配置）\n    model = create_model(config, use_boost=True)\n    \n    profile_path = os.path.join(simulation_dir, \"reddit_profiles.json\")\n    if not os.path.exists(profile_path):\n        log_info(f\"错误: Profile文件不存在: {profile_path}\")\n        return result\n    \n    result.agent_graph = await generate_reddit_agent_graph(\n        profile_path=profile_path,\n        model=model,\n        available_actions=REDDIT_ACTIONS,\n    )\n    \n    # 从配置文件获取 Agent 真实名称映射（使用 entity_name 而非默认的 Agent_X）\n    agent_names = get_agent_names_from_config(config)\n    # 如果配置中没有某个 agent，则使用 OASIS 的默认名称\n    for agent_id, agent in result.agent_graph.get_agents():\n        if agent_id not in agent_names:\n            agent_names[agent_id] = getattr(agent, 'name', f'Agent_{agent_id}')\n    \n    db_path = os.path.join(simulation_dir, \"reddit_simulation.db\")\n    if os.path.exists(db_path):\n        os.remove(db_path)\n    \n    result.env = oasis.make(\n        agent_graph=result.agent_graph,\n        platform=oasis.DefaultPlatformType.REDDIT,\n        database_path=db_path,\n        semaphore=30,  # 限制最大并发 LLM 请求数，防止 API 过载\n    )\n    \n    await result.env.reset()\n    log_info(\"环境已启动\")\n    \n    if action_logger:\n        action_logger.log_simulation_start(config)\n    \n    total_actions = 0\n    last_rowid = 0  # 跟踪数据库中最后处理的行号（使用 rowid 避免 created_at 格式差异）\n    \n    # 执行初始事件\n    event_config = config.get(\"event_config\", {})\n    initial_posts = event_config.get(\"initial_posts\", [])\n    \n    # 记录 round 0 开始（初始事件阶段）\n    if action_logger:\n        action_logger.log_round_start(0, 0)  # round 0, simulated_hour 0\n    \n    initial_action_count = 0\n    if initial_posts:\n        initial_actions = {}\n        for post in initial_posts:\n            agent_id = post.get(\"poster_agent_id\", 0)\n            content = post.get(\"content\", \"\")\n            try:\n                agent = result.env.agent_graph.get_agent(agent_id)\n                if agent in initial_actions:\n                    if not isinstance(initial_actions[agent], list):\n                        initial_actions[agent] = [initial_actions[agent]]\n                    initial_actions[agent].append(ManualAction(\n                        action_type=ActionType.CREATE_POST,\n                        action_args={\"content\": content}\n                    ))\n                else:\n                    initial_actions[agent] = ManualAction(\n                        action_type=ActionType.CREATE_POST,\n                        action_args={\"content\": content}\n                    )\n                \n                if action_logger:\n                    action_logger.log_action(\n                        round_num=0,\n                        agent_id=agent_id,\n                        agent_name=agent_names.get(agent_id, f\"Agent_{agent_id}\"),\n                        action_type=\"CREATE_POST\",\n                        action_args={\"content\": content}\n                    )\n                    total_actions += 1\n                    initial_action_count += 1\n            except Exception:\n                pass\n        \n        if initial_actions:\n            await result.env.step(initial_actions)\n            log_info(f\"已发布 {len(initial_actions)} 条初始帖子\")\n    \n    # 记录 round 0 结束\n    if action_logger:\n        action_logger.log_round_end(0, initial_action_count)\n    \n    # 主模拟循环\n    time_config = config.get(\"time_config\", {})\n    total_hours = time_config.get(\"total_simulation_hours\", 72)\n    minutes_per_round = time_config.get(\"minutes_per_round\", 30)\n    total_rounds = (total_hours * 60) // minutes_per_round\n    \n    # 如果指定了最大轮数，则截断\n    if max_rounds is not None and max_rounds > 0:\n        original_rounds = total_rounds\n        total_rounds = min(total_rounds, max_rounds)\n        if total_rounds < original_rounds:\n            log_info(f\"轮数已截断: {original_rounds} -> {total_rounds} (max_rounds={max_rounds})\")\n    \n    start_time = datetime.now()\n    \n    for round_num in range(total_rounds):\n        # 检查是否收到退出信号\n        if _shutdown_event and _shutdown_event.is_set():\n            if main_logger:\n                main_logger.info(f\"收到退出信号，在第 {round_num + 1} 轮停止模拟\")\n            break\n        \n        simulated_minutes = round_num * minutes_per_round\n        simulated_hour = (simulated_minutes // 60) % 24\n        simulated_day = simulated_minutes // (60 * 24) + 1\n        \n        active_agents = get_active_agents_for_round(\n            result.env, config, simulated_hour, round_num\n        )\n        \n        # 无论是否有活跃agent，都记录round开始\n        if action_logger:\n            action_logger.log_round_start(round_num + 1, simulated_hour)\n        \n        if not active_agents:\n            # 没有活跃agent时也记录round结束（actions_count=0）\n            if action_logger:\n                action_logger.log_round_end(round_num + 1, 0)\n            continue\n        \n        actions = {agent: LLMAction() for _, agent in active_agents}\n        await result.env.step(actions)\n        \n        # 从数据库获取实际执行的动作并记录\n        actual_actions, last_rowid = fetch_new_actions_from_db(\n            db_path, last_rowid, agent_names\n        )\n        \n        round_action_count = 0\n        for action_data in actual_actions:\n            if action_logger:\n                action_logger.log_action(\n                    round_num=round_num + 1,\n                    agent_id=action_data['agent_id'],\n                    agent_name=action_data['agent_name'],\n                    action_type=action_data['action_type'],\n                    action_args=action_data['action_args']\n                )\n                total_actions += 1\n                round_action_count += 1\n        \n        if action_logger:\n            action_logger.log_round_end(round_num + 1, round_action_count)\n        \n        if (round_num + 1) % 20 == 0:\n            progress = (round_num + 1) / total_rounds * 100\n            log_info(f\"Day {simulated_day}, {simulated_hour:02d}:00 - Round {round_num + 1}/{total_rounds} ({progress:.1f}%)\")\n    \n    # 注意：不关闭环境，保留给Interview使用\n    \n    if action_logger:\n        action_logger.log_simulation_end(total_rounds, total_actions)\n    \n    result.total_actions = total_actions\n    elapsed = (datetime.now() - start_time).total_seconds()\n    log_info(f\"模拟循环完成! 耗时: {elapsed:.1f}秒, 总动作: {total_actions}\")\n    \n    return result\n\n\nasync def main():\n    parser = argparse.ArgumentParser(description='OASIS双平台并行模拟')\n    parser.add_argument(\n        '--config', \n        type=str, \n        required=True,\n        help='配置文件路径 (simulation_config.json)'\n    )\n    parser.add_argument(\n        '--twitter-only',\n        action='store_true',\n        help='只运行Twitter模拟'\n    )\n    parser.add_argument(\n        '--reddit-only',\n        action='store_true',\n        help='只运行Reddit模拟'\n    )\n    parser.add_argument(\n        '--max-rounds',\n        type=int,\n        default=None,\n        help='最大模拟轮数（可选，用于截断过长的模拟）'\n    )\n    parser.add_argument(\n        '--no-wait',\n        action='store_true',\n        default=False,\n        help='模拟完成后立即关闭环境，不进入等待命令模式'\n    )\n    \n    args = parser.parse_args()\n    \n    # 在 main 函数开始时创建 shutdown 事件，确保整个程序都能响应退出信号\n    global _shutdown_event\n    _shutdown_event = asyncio.Event()\n    \n    if not os.path.exists(args.config):\n        print(f\"错误: 配置文件不存在: {args.config}\")\n        sys.exit(1)\n    \n    config = load_config(args.config)\n    simulation_dir = os.path.dirname(args.config) or \".\"\n    wait_for_commands = not args.no_wait\n    \n    # 初始化日志配置（禁用 OASIS 日志，清理旧文件）\n    init_logging_for_simulation(simulation_dir)\n    \n    # 创建日志管理器\n    log_manager = SimulationLogManager(simulation_dir)\n    twitter_logger = log_manager.get_twitter_logger()\n    reddit_logger = log_manager.get_reddit_logger()\n    \n    log_manager.info(\"=\" * 60)\n    log_manager.info(\"OASIS 双平台并行模拟\")\n    log_manager.info(f\"配置文件: {args.config}\")\n    log_manager.info(f\"模拟ID: {config.get('simulation_id', 'unknown')}\")\n    log_manager.info(f\"等待命令模式: {'启用' if wait_for_commands else '禁用'}\")\n    log_manager.info(\"=\" * 60)\n    \n    time_config = config.get(\"time_config\", {})\n    total_hours = time_config.get('total_simulation_hours', 72)\n    minutes_per_round = time_config.get('minutes_per_round', 30)\n    config_total_rounds = (total_hours * 60) // minutes_per_round\n    \n    log_manager.info(f\"模拟参数:\")\n    log_manager.info(f\"  - 总模拟时长: {total_hours}小时\")\n    log_manager.info(f\"  - 每轮时间: {minutes_per_round}分钟\")\n    log_manager.info(f\"  - 配置总轮数: {config_total_rounds}\")\n    if args.max_rounds:\n        log_manager.info(f\"  - 最大轮数限制: {args.max_rounds}\")\n        if args.max_rounds < config_total_rounds:\n            log_manager.info(f\"  - 实际执行轮数: {args.max_rounds} (已截断)\")\n    log_manager.info(f\"  - Agent数量: {len(config.get('agent_configs', []))}\")\n    \n    log_manager.info(\"日志结构:\")\n    log_manager.info(f\"  - 主日志: simulation.log\")\n    log_manager.info(f\"  - Twitter动作: twitter/actions.jsonl\")\n    log_manager.info(f\"  - Reddit动作: reddit/actions.jsonl\")\n    log_manager.info(\"=\" * 60)\n    \n    start_time = datetime.now()\n    \n    # 存储两个平台的模拟结果\n    twitter_result: Optional[PlatformSimulation] = None\n    reddit_result: Optional[PlatformSimulation] = None\n    \n    if args.twitter_only:\n        twitter_result = await run_twitter_simulation(config, simulation_dir, twitter_logger, log_manager, args.max_rounds)\n    elif args.reddit_only:\n        reddit_result = await run_reddit_simulation(config, simulation_dir, reddit_logger, log_manager, args.max_rounds)\n    else:\n        # 并行运行（每个平台使用独立的日志记录器）\n        results = await asyncio.gather(\n            run_twitter_simulation(config, simulation_dir, twitter_logger, log_manager, args.max_rounds),\n            run_reddit_simulation(config, simulation_dir, reddit_logger, log_manager, args.max_rounds),\n        )\n        twitter_result, reddit_result = results\n    \n    total_elapsed = (datetime.now() - start_time).total_seconds()\n    log_manager.info(\"=\" * 60)\n    log_manager.info(f\"模拟循环完成! 总耗时: {total_elapsed:.1f}秒\")\n    \n    # 是否进入等待命令模式\n    if wait_for_commands:\n        log_manager.info(\"\")\n        log_manager.info(\"=\" * 60)\n        log_manager.info(\"进入等待命令模式 - 环境保持运行\")\n        log_manager.info(\"支持的命令: interview, batch_interview, close_env\")\n        log_manager.info(\"=\" * 60)\n        \n        # 创建IPC处理器\n        ipc_handler = ParallelIPCHandler(\n            simulation_dir=simulation_dir,\n            twitter_env=twitter_result.env if twitter_result else None,\n            twitter_agent_graph=twitter_result.agent_graph if twitter_result else None,\n            reddit_env=reddit_result.env if reddit_result else None,\n            reddit_agent_graph=reddit_result.agent_graph if reddit_result else None\n        )\n        ipc_handler.update_status(\"alive\")\n        \n        # 等待命令循环（使用全局 _shutdown_event）\n        try:\n            while not _shutdown_event.is_set():\n                should_continue = await ipc_handler.process_commands()\n                if not should_continue:\n                    break\n                # 使用 wait_for 替代 sleep，这样可以响应 shutdown_event\n                try:\n                    await asyncio.wait_for(_shutdown_event.wait(), timeout=0.5)\n                    break  # 收到退出信号\n                except asyncio.TimeoutError:\n                    pass  # 超时继续循环\n        except KeyboardInterrupt:\n            print(\"\\n收到中断信号\")\n        except asyncio.CancelledError:\n            print(\"\\n任务被取消\")\n        except Exception as e:\n            print(f\"\\n命令处理出错: {e}\")\n        \n        log_manager.info(\"\\n关闭环境...\")\n        ipc_handler.update_status(\"stopped\")\n    \n    # 关闭环境\n    if twitter_result and twitter_result.env:\n        await twitter_result.env.close()\n        log_manager.info(\"[Twitter] 环境已关闭\")\n    \n    if reddit_result and reddit_result.env:\n        await reddit_result.env.close()\n        log_manager.info(\"[Reddit] 环境已关闭\")\n    \n    log_manager.info(\"=\" * 60)\n    log_manager.info(f\"全部完成!\")\n    log_manager.info(f\"日志文件:\")\n    log_manager.info(f\"  - {os.path.join(simulation_dir, 'simulation.log')}\")\n    log_manager.info(f\"  - {os.path.join(simulation_dir, 'twitter', 'actions.jsonl')}\")\n    log_manager.info(f\"  - {os.path.join(simulation_dir, 'reddit', 'actions.jsonl')}\")\n    log_manager.info(\"=\" * 60)\n\n\ndef setup_signal_handlers(loop=None):\n    \"\"\"\n    设置信号处理器，确保收到 SIGTERM/SIGINT 时能够正确退出\n    \n    持久化模拟场景：模拟完成后不退出，等待 interview 命令\n    当收到终止信号时，需要：\n    1. 通知 asyncio 循环退出等待\n    2. 让程序有机会正常清理资源（关闭数据库、环境等）\n    3. 然后才退出\n    \"\"\"\n    def signal_handler(signum, frame):\n        global _cleanup_done\n        sig_name = \"SIGTERM\" if signum == signal.SIGTERM else \"SIGINT\"\n        print(f\"\\n收到 {sig_name} 信号，正在退出...\")\n        \n        if not _cleanup_done:\n            _cleanup_done = True\n            # 设置事件通知 asyncio 循环退出（让循环有机会清理资源）\n            if _shutdown_event:\n                _shutdown_event.set()\n        \n        # 不要直接 sys.exit()，让 asyncio 循环正常退出并清理资源\n        # 如果是重复收到信号，才强制退出\n        else:\n            print(\"强制退出...\")\n            sys.exit(1)\n    \n    signal.signal(signal.SIGTERM, signal_handler)\n    signal.signal(signal.SIGINT, signal_handler)\n\n\nif __name__ == \"__main__\":\n    setup_signal_handlers()\n    try:\n        asyncio.run(main())\n    except KeyboardInterrupt:\n        print(\"\\n程序被中断\")\n    except SystemExit:\n        pass\n    finally:\n        # 清理 multiprocessing 资源跟踪器（防止退出时的警告）\n        try:\n            from multiprocessing import resource_tracker\n            resource_tracker._resource_tracker._stop()\n        except Exception:\n            pass\n        print(\"模拟进程已退出\")\n"
  },
  {
    "path": "backend/scripts/run_reddit_simulation.py",
    "content": "\"\"\"\nOASIS Reddit模拟预设脚本\n此脚本读取配置文件中的参数来执行模拟，实现全程自动化\n\n功能特性:\n- 完成模拟后不立即关闭环境，进入等待命令模式\n- 支持通过IPC接收Interview命令\n- 支持单个Agent采访和批量采访\n- 支持远程关闭环境命令\n\n使用方式:\n    python run_reddit_simulation.py --config /path/to/simulation_config.json\n    python run_reddit_simulation.py --config /path/to/simulation_config.json --no-wait  # 完成后立即关闭\n\"\"\"\n\nimport argparse\nimport asyncio\nimport json\nimport logging\nimport os\nimport random\nimport signal\nimport sys\nimport sqlite3\nfrom datetime import datetime\nfrom typing import Dict, Any, List, Optional\n\n# 全局变量：用于信号处理\n_shutdown_event = None\n_cleanup_done = False\n\n# 添加项目路径\n_scripts_dir = os.path.dirname(os.path.abspath(__file__))\n_backend_dir = os.path.abspath(os.path.join(_scripts_dir, '..'))\n_project_root = os.path.abspath(os.path.join(_backend_dir, '..'))\nsys.path.insert(0, _scripts_dir)\nsys.path.insert(0, _backend_dir)\n\n# 加载项目根目录的 .env 文件（包含 LLM_API_KEY 等配置）\nfrom dotenv import load_dotenv\n_env_file = os.path.join(_project_root, '.env')\nif os.path.exists(_env_file):\n    load_dotenv(_env_file)\nelse:\n    _backend_env = os.path.join(_backend_dir, '.env')\n    if os.path.exists(_backend_env):\n        load_dotenv(_backend_env)\n\n\nimport re\n\n\nclass UnicodeFormatter(logging.Formatter):\n    \"\"\"自定义格式化器，将 Unicode 转义序列转换为可读字符\"\"\"\n    \n    UNICODE_ESCAPE_PATTERN = re.compile(r'\\\\u([0-9a-fA-F]{4})')\n    \n    def format(self, record):\n        result = super().format(record)\n        \n        def replace_unicode(match):\n            try:\n                return chr(int(match.group(1), 16))\n            except (ValueError, OverflowError):\n                return match.group(0)\n        \n        return self.UNICODE_ESCAPE_PATTERN.sub(replace_unicode, result)\n\n\nclass MaxTokensWarningFilter(logging.Filter):\n    \"\"\"过滤掉 camel-ai 关于 max_tokens 的警告（我们故意不设置 max_tokens，让模型自行决定）\"\"\"\n    \n    def filter(self, record):\n        # 过滤掉包含 max_tokens 警告的日志\n        if \"max_tokens\" in record.getMessage() and \"Invalid or missing\" in record.getMessage():\n            return False\n        return True\n\n\n# 在模块加载时立即添加过滤器，确保在 camel 代码执行前生效\nlogging.getLogger().addFilter(MaxTokensWarningFilter())\n\n\ndef setup_oasis_logging(log_dir: str):\n    \"\"\"配置 OASIS 的日志，使用固定名称的日志文件\"\"\"\n    os.makedirs(log_dir, exist_ok=True)\n    \n    # 清理旧的日志文件\n    for f in os.listdir(log_dir):\n        old_log = os.path.join(log_dir, f)\n        if os.path.isfile(old_log) and f.endswith('.log'):\n            try:\n                os.remove(old_log)\n            except OSError:\n                pass\n    \n    formatter = UnicodeFormatter(\"%(levelname)s - %(asctime)s - %(name)s - %(message)s\")\n    \n    loggers_config = {\n        \"social.agent\": os.path.join(log_dir, \"social.agent.log\"),\n        \"social.twitter\": os.path.join(log_dir, \"social.twitter.log\"),\n        \"social.rec\": os.path.join(log_dir, \"social.rec.log\"),\n        \"oasis.env\": os.path.join(log_dir, \"oasis.env.log\"),\n        \"table\": os.path.join(log_dir, \"table.log\"),\n    }\n    \n    for logger_name, log_file in loggers_config.items():\n        logger = logging.getLogger(logger_name)\n        logger.setLevel(logging.DEBUG)\n        logger.handlers.clear()\n        file_handler = logging.FileHandler(log_file, encoding='utf-8', mode='w')\n        file_handler.setLevel(logging.DEBUG)\n        file_handler.setFormatter(formatter)\n        logger.addHandler(file_handler)\n        logger.propagate = False\n\n\ntry:\n    from camel.models import ModelFactory\n    from camel.types import ModelPlatformType\n    import oasis\n    from oasis import (\n        ActionType,\n        LLMAction,\n        ManualAction,\n        generate_reddit_agent_graph\n    )\nexcept ImportError as e:\n    print(f\"错误: 缺少依赖 {e}\")\n    print(\"请先安装: pip install oasis-ai camel-ai\")\n    sys.exit(1)\n\n\n# IPC相关常量\nIPC_COMMANDS_DIR = \"ipc_commands\"\nIPC_RESPONSES_DIR = \"ipc_responses\"\nENV_STATUS_FILE = \"env_status.json\"\n\nclass CommandType:\n    \"\"\"命令类型常量\"\"\"\n    INTERVIEW = \"interview\"\n    BATCH_INTERVIEW = \"batch_interview\"\n    CLOSE_ENV = \"close_env\"\n\n\nclass IPCHandler:\n    \"\"\"IPC命令处理器\"\"\"\n    \n    def __init__(self, simulation_dir: str, env, agent_graph):\n        self.simulation_dir = simulation_dir\n        self.env = env\n        self.agent_graph = agent_graph\n        self.commands_dir = os.path.join(simulation_dir, IPC_COMMANDS_DIR)\n        self.responses_dir = os.path.join(simulation_dir, IPC_RESPONSES_DIR)\n        self.status_file = os.path.join(simulation_dir, ENV_STATUS_FILE)\n        self._running = True\n        \n        # 确保目录存在\n        os.makedirs(self.commands_dir, exist_ok=True)\n        os.makedirs(self.responses_dir, exist_ok=True)\n    \n    def update_status(self, status: str):\n        \"\"\"更新环境状态\"\"\"\n        with open(self.status_file, 'w', encoding='utf-8') as f:\n            json.dump({\n                \"status\": status,\n                \"timestamp\": datetime.now().isoformat()\n            }, f, ensure_ascii=False, indent=2)\n    \n    def poll_command(self) -> Optional[Dict[str, Any]]:\n        \"\"\"轮询获取待处理命令\"\"\"\n        if not os.path.exists(self.commands_dir):\n            return None\n        \n        # 获取命令文件（按时间排序）\n        command_files = []\n        for filename in os.listdir(self.commands_dir):\n            if filename.endswith('.json'):\n                filepath = os.path.join(self.commands_dir, filename)\n                command_files.append((filepath, os.path.getmtime(filepath)))\n        \n        command_files.sort(key=lambda x: x[1])\n        \n        for filepath, _ in command_files:\n            try:\n                with open(filepath, 'r', encoding='utf-8') as f:\n                    return json.load(f)\n            except (json.JSONDecodeError, OSError):\n                continue\n        \n        return None\n    \n    def send_response(self, command_id: str, status: str, result: Dict = None, error: str = None):\n        \"\"\"发送响应\"\"\"\n        response = {\n            \"command_id\": command_id,\n            \"status\": status,\n            \"result\": result,\n            \"error\": error,\n            \"timestamp\": datetime.now().isoformat()\n        }\n        \n        response_file = os.path.join(self.responses_dir, f\"{command_id}.json\")\n        with open(response_file, 'w', encoding='utf-8') as f:\n            json.dump(response, f, ensure_ascii=False, indent=2)\n        \n        # 删除命令文件\n        command_file = os.path.join(self.commands_dir, f\"{command_id}.json\")\n        try:\n            os.remove(command_file)\n        except OSError:\n            pass\n    \n    async def handle_interview(self, command_id: str, agent_id: int, prompt: str) -> bool:\n        \"\"\"\n        处理单个Agent采访命令\n        \n        Returns:\n            True 表示成功，False 表示失败\n        \"\"\"\n        try:\n            # 获取Agent\n            agent = self.agent_graph.get_agent(agent_id)\n            \n            # 创建Interview动作\n            interview_action = ManualAction(\n                action_type=ActionType.INTERVIEW,\n                action_args={\"prompt\": prompt}\n            )\n            \n            # 执行Interview\n            actions = {agent: interview_action}\n            await self.env.step(actions)\n            \n            # 从数据库获取结果\n            result = self._get_interview_result(agent_id)\n            \n            self.send_response(command_id, \"completed\", result=result)\n            print(f\"  Interview完成: agent_id={agent_id}\")\n            return True\n            \n        except Exception as e:\n            error_msg = str(e)\n            print(f\"  Interview失败: agent_id={agent_id}, error={error_msg}\")\n            self.send_response(command_id, \"failed\", error=error_msg)\n            return False\n    \n    async def handle_batch_interview(self, command_id: str, interviews: List[Dict]) -> bool:\n        \"\"\"\n        处理批量采访命令\n        \n        Args:\n            interviews: [{\"agent_id\": int, \"prompt\": str}, ...]\n        \"\"\"\n        try:\n            # 构建动作字典\n            actions = {}\n            agent_prompts = {}  # 记录每个agent的prompt\n            \n            for interview in interviews:\n                agent_id = interview.get(\"agent_id\")\n                prompt = interview.get(\"prompt\", \"\")\n                \n                try:\n                    agent = self.agent_graph.get_agent(agent_id)\n                    actions[agent] = ManualAction(\n                        action_type=ActionType.INTERVIEW,\n                        action_args={\"prompt\": prompt}\n                    )\n                    agent_prompts[agent_id] = prompt\n                except Exception as e:\n                    print(f\"  警告: 无法获取Agent {agent_id}: {e}\")\n            \n            if not actions:\n                self.send_response(command_id, \"failed\", error=\"没有有效的Agent\")\n                return False\n            \n            # 执行批量Interview\n            await self.env.step(actions)\n            \n            # 获取所有结果\n            results = {}\n            for agent_id in agent_prompts.keys():\n                result = self._get_interview_result(agent_id)\n                results[agent_id] = result\n            \n            self.send_response(command_id, \"completed\", result={\n                \"interviews_count\": len(results),\n                \"results\": results\n            })\n            print(f\"  批量Interview完成: {len(results)} 个Agent\")\n            return True\n            \n        except Exception as e:\n            error_msg = str(e)\n            print(f\"  批量Interview失败: {error_msg}\")\n            self.send_response(command_id, \"failed\", error=error_msg)\n            return False\n    \n    def _get_interview_result(self, agent_id: int) -> Dict[str, Any]:\n        \"\"\"从数据库获取最新的Interview结果\"\"\"\n        db_path = os.path.join(self.simulation_dir, \"reddit_simulation.db\")\n        \n        result = {\n            \"agent_id\": agent_id,\n            \"response\": None,\n            \"timestamp\": None\n        }\n        \n        if not os.path.exists(db_path):\n            return result\n        \n        try:\n            conn = sqlite3.connect(db_path)\n            cursor = conn.cursor()\n            \n            # 查询最新的Interview记录\n            cursor.execute(\"\"\"\n                SELECT user_id, info, created_at\n                FROM trace\n                WHERE action = ? AND user_id = ?\n                ORDER BY created_at DESC\n                LIMIT 1\n            \"\"\", (ActionType.INTERVIEW.value, agent_id))\n            \n            row = cursor.fetchone()\n            if row:\n                user_id, info_json, created_at = row\n                try:\n                    info = json.loads(info_json) if info_json else {}\n                    result[\"response\"] = info.get(\"response\", info)\n                    result[\"timestamp\"] = created_at\n                except json.JSONDecodeError:\n                    result[\"response\"] = info_json\n            \n            conn.close()\n            \n        except Exception as e:\n            print(f\"  读取Interview结果失败: {e}\")\n        \n        return result\n    \n    async def process_commands(self) -> bool:\n        \"\"\"\n        处理所有待处理命令\n        \n        Returns:\n            True 表示继续运行，False 表示应该退出\n        \"\"\"\n        command = self.poll_command()\n        if not command:\n            return True\n        \n        command_id = command.get(\"command_id\")\n        command_type = command.get(\"command_type\")\n        args = command.get(\"args\", {})\n        \n        print(f\"\\n收到IPC命令: {command_type}, id={command_id}\")\n        \n        if command_type == CommandType.INTERVIEW:\n            await self.handle_interview(\n                command_id,\n                args.get(\"agent_id\", 0),\n                args.get(\"prompt\", \"\")\n            )\n            return True\n            \n        elif command_type == CommandType.BATCH_INTERVIEW:\n            await self.handle_batch_interview(\n                command_id,\n                args.get(\"interviews\", [])\n            )\n            return True\n            \n        elif command_type == CommandType.CLOSE_ENV:\n            print(\"收到关闭环境命令\")\n            self.send_response(command_id, \"completed\", result={\"message\": \"环境即将关闭\"})\n            return False\n        \n        else:\n            self.send_response(command_id, \"failed\", error=f\"未知命令类型: {command_type}\")\n            return True\n\n\nclass RedditSimulationRunner:\n    \"\"\"Reddit模拟运行器\"\"\"\n    \n    # Reddit可用动作（不包含INTERVIEW，INTERVIEW只能通过ManualAction手动触发）\n    AVAILABLE_ACTIONS = [\n        ActionType.LIKE_POST,\n        ActionType.DISLIKE_POST,\n        ActionType.CREATE_POST,\n        ActionType.CREATE_COMMENT,\n        ActionType.LIKE_COMMENT,\n        ActionType.DISLIKE_COMMENT,\n        ActionType.SEARCH_POSTS,\n        ActionType.SEARCH_USER,\n        ActionType.TREND,\n        ActionType.REFRESH,\n        ActionType.DO_NOTHING,\n        ActionType.FOLLOW,\n        ActionType.MUTE,\n    ]\n    \n    def __init__(self, config_path: str, wait_for_commands: bool = True):\n        \"\"\"\n        初始化模拟运行器\n        \n        Args:\n            config_path: 配置文件路径 (simulation_config.json)\n            wait_for_commands: 模拟完成后是否等待命令（默认True）\n        \"\"\"\n        self.config_path = config_path\n        self.config = self._load_config()\n        self.simulation_dir = os.path.dirname(config_path)\n        self.wait_for_commands = wait_for_commands\n        self.env = None\n        self.agent_graph = None\n        self.ipc_handler = None\n        \n    def _load_config(self) -> Dict[str, Any]:\n        \"\"\"加载配置文件\"\"\"\n        with open(self.config_path, 'r', encoding='utf-8') as f:\n            return json.load(f)\n    \n    def _get_profile_path(self) -> str:\n        \"\"\"获取Profile文件路径\"\"\"\n        return os.path.join(self.simulation_dir, \"reddit_profiles.json\")\n    \n    def _get_db_path(self) -> str:\n        \"\"\"获取数据库路径\"\"\"\n        return os.path.join(self.simulation_dir, \"reddit_simulation.db\")\n    \n    def _create_model(self):\n        \"\"\"\n        创建LLM模型\n        \n        统一使用项目根目录 .env 文件中的配置（优先级最高）：\n        - LLM_API_KEY: API密钥\n        - LLM_BASE_URL: API基础URL\n        - LLM_MODEL_NAME: 模型名称\n        \"\"\"\n        # 优先从 .env 读取配置\n        llm_api_key = os.environ.get(\"LLM_API_KEY\", \"\")\n        llm_base_url = os.environ.get(\"LLM_BASE_URL\", \"\")\n        llm_model = os.environ.get(\"LLM_MODEL_NAME\", \"\")\n        \n        # 如果 .env 中没有，则使用 config 作为备用\n        if not llm_model:\n            llm_model = self.config.get(\"llm_model\", \"gpt-4o-mini\")\n        \n        # 设置 camel-ai 所需的环境变量\n        if llm_api_key:\n            os.environ[\"OPENAI_API_KEY\"] = llm_api_key\n        \n        if not os.environ.get(\"OPENAI_API_KEY\"):\n            raise ValueError(\"缺少 API Key 配置，请在项目根目录 .env 文件中设置 LLM_API_KEY\")\n        \n        if llm_base_url:\n            os.environ[\"OPENAI_API_BASE_URL\"] = llm_base_url\n        \n        print(f\"LLM配置: model={llm_model}, base_url={llm_base_url[:40] if llm_base_url else '默认'}...\")\n        \n        return ModelFactory.create(\n            model_platform=ModelPlatformType.OPENAI,\n            model_type=llm_model,\n        )\n    \n    def _get_active_agents_for_round(\n        self, \n        env, \n        current_hour: int,\n        round_num: int\n    ) -> List:\n        \"\"\"\n        根据时间和配置决定本轮激活哪些Agent\n        \"\"\"\n        time_config = self.config.get(\"time_config\", {})\n        agent_configs = self.config.get(\"agent_configs\", [])\n        \n        base_min = time_config.get(\"agents_per_hour_min\", 5)\n        base_max = time_config.get(\"agents_per_hour_max\", 20)\n        \n        peak_hours = time_config.get(\"peak_hours\", [9, 10, 11, 14, 15, 20, 21, 22])\n        off_peak_hours = time_config.get(\"off_peak_hours\", [0, 1, 2, 3, 4, 5])\n        \n        if current_hour in peak_hours:\n            multiplier = time_config.get(\"peak_activity_multiplier\", 1.5)\n        elif current_hour in off_peak_hours:\n            multiplier = time_config.get(\"off_peak_activity_multiplier\", 0.3)\n        else:\n            multiplier = 1.0\n        \n        target_count = int(random.uniform(base_min, base_max) * multiplier)\n        \n        candidates = []\n        for cfg in agent_configs:\n            agent_id = cfg.get(\"agent_id\", 0)\n            active_hours = cfg.get(\"active_hours\", list(range(8, 23)))\n            activity_level = cfg.get(\"activity_level\", 0.5)\n            \n            if current_hour not in active_hours:\n                continue\n            \n            if random.random() < activity_level:\n                candidates.append(agent_id)\n        \n        selected_ids = random.sample(\n            candidates, \n            min(target_count, len(candidates))\n        ) if candidates else []\n        \n        active_agents = []\n        for agent_id in selected_ids:\n            try:\n                agent = env.agent_graph.get_agent(agent_id)\n                active_agents.append((agent_id, agent))\n            except Exception:\n                pass\n        \n        return active_agents\n    \n    async def run(self, max_rounds: int = None):\n        \"\"\"运行Reddit模拟\n        \n        Args:\n            max_rounds: 最大模拟轮数（可选，用于截断过长的模拟）\n        \"\"\"\n        print(\"=\" * 60)\n        print(\"OASIS Reddit模拟\")\n        print(f\"配置文件: {self.config_path}\")\n        print(f\"模拟ID: {self.config.get('simulation_id', 'unknown')}\")\n        print(f\"等待命令模式: {'启用' if self.wait_for_commands else '禁用'}\")\n        print(\"=\" * 60)\n        \n        time_config = self.config.get(\"time_config\", {})\n        total_hours = time_config.get(\"total_simulation_hours\", 72)\n        minutes_per_round = time_config.get(\"minutes_per_round\", 30)\n        total_rounds = (total_hours * 60) // minutes_per_round\n        \n        # 如果指定了最大轮数，则截断\n        if max_rounds is not None and max_rounds > 0:\n            original_rounds = total_rounds\n            total_rounds = min(total_rounds, max_rounds)\n            if total_rounds < original_rounds:\n                print(f\"\\n轮数已截断: {original_rounds} -> {total_rounds} (max_rounds={max_rounds})\")\n        \n        print(f\"\\n模拟参数:\")\n        print(f\"  - 总模拟时长: {total_hours}小时\")\n        print(f\"  - 每轮时间: {minutes_per_round}分钟\")\n        print(f\"  - 总轮数: {total_rounds}\")\n        if max_rounds:\n            print(f\"  - 最大轮数限制: {max_rounds}\")\n        print(f\"  - Agent数量: {len(self.config.get('agent_configs', []))}\")\n        \n        print(\"\\n初始化LLM模型...\")\n        model = self._create_model()\n        \n        print(\"加载Agent Profile...\")\n        profile_path = self._get_profile_path()\n        if not os.path.exists(profile_path):\n            print(f\"错误: Profile文件不存在: {profile_path}\")\n            return\n        \n        self.agent_graph = await generate_reddit_agent_graph(\n            profile_path=profile_path,\n            model=model,\n            available_actions=self.AVAILABLE_ACTIONS,\n        )\n        \n        db_path = self._get_db_path()\n        if os.path.exists(db_path):\n            os.remove(db_path)\n            print(f\"已删除旧数据库: {db_path}\")\n        \n        print(\"创建OASIS环境...\")\n        self.env = oasis.make(\n            agent_graph=self.agent_graph,\n            platform=oasis.DefaultPlatformType.REDDIT,\n            database_path=db_path,\n            semaphore=30,  # 限制最大并发 LLM 请求数，防止 API 过载\n        )\n        \n        await self.env.reset()\n        print(\"环境初始化完成\\n\")\n        \n        # 初始化IPC处理器\n        self.ipc_handler = IPCHandler(self.simulation_dir, self.env, self.agent_graph)\n        self.ipc_handler.update_status(\"running\")\n        \n        # 执行初始事件\n        event_config = self.config.get(\"event_config\", {})\n        initial_posts = event_config.get(\"initial_posts\", [])\n        \n        if initial_posts:\n            print(f\"执行初始事件 ({len(initial_posts)}条初始帖子)...\")\n            initial_actions = {}\n            for post in initial_posts:\n                agent_id = post.get(\"poster_agent_id\", 0)\n                content = post.get(\"content\", \"\")\n                try:\n                    agent = self.env.agent_graph.get_agent(agent_id)\n                    if agent in initial_actions:\n                        if not isinstance(initial_actions[agent], list):\n                            initial_actions[agent] = [initial_actions[agent]]\n                        initial_actions[agent].append(ManualAction(\n                            action_type=ActionType.CREATE_POST,\n                            action_args={\"content\": content}\n                        ))\n                    else:\n                        initial_actions[agent] = ManualAction(\n                            action_type=ActionType.CREATE_POST,\n                            action_args={\"content\": content}\n                        )\n                except Exception as e:\n                    print(f\"  警告: 无法为Agent {agent_id}创建初始帖子: {e}\")\n            \n            if initial_actions:\n                await self.env.step(initial_actions)\n                print(f\"  已发布 {len(initial_actions)} 条初始帖子\")\n        \n        # 主模拟循环\n        print(\"\\n开始模拟循环...\")\n        start_time = datetime.now()\n        \n        for round_num in range(total_rounds):\n            simulated_minutes = round_num * minutes_per_round\n            simulated_hour = (simulated_minutes // 60) % 24\n            simulated_day = simulated_minutes // (60 * 24) + 1\n            \n            active_agents = self._get_active_agents_for_round(\n                self.env, simulated_hour, round_num\n            )\n            \n            if not active_agents:\n                continue\n            \n            actions = {\n                agent: LLMAction()\n                for _, agent in active_agents\n            }\n            \n            await self.env.step(actions)\n            \n            if (round_num + 1) % 10 == 0 or round_num == 0:\n                elapsed = (datetime.now() - start_time).total_seconds()\n                progress = (round_num + 1) / total_rounds * 100\n                print(f\"  [Day {simulated_day}, {simulated_hour:02d}:00] \"\n                      f\"Round {round_num + 1}/{total_rounds} ({progress:.1f}%) \"\n                      f\"- {len(active_agents)} agents active \"\n                      f\"- elapsed: {elapsed:.1f}s\")\n        \n        total_elapsed = (datetime.now() - start_time).total_seconds()\n        print(f\"\\n模拟循环完成!\")\n        print(f\"  - 总耗时: {total_elapsed:.1f}秒\")\n        print(f\"  - 数据库: {db_path}\")\n        \n        # 是否进入等待命令模式\n        if self.wait_for_commands:\n            print(\"\\n\" + \"=\" * 60)\n            print(\"进入等待命令模式 - 环境保持运行\")\n            print(\"支持的命令: interview, batch_interview, close_env\")\n            print(\"=\" * 60)\n            \n            self.ipc_handler.update_status(\"alive\")\n            \n            # 等待命令循环（使用全局 _shutdown_event）\n            try:\n                while not _shutdown_event.is_set():\n                    should_continue = await self.ipc_handler.process_commands()\n                    if not should_continue:\n                        break\n                    try:\n                        await asyncio.wait_for(_shutdown_event.wait(), timeout=0.5)\n                        break  # 收到退出信号\n                    except asyncio.TimeoutError:\n                        pass\n            except KeyboardInterrupt:\n                print(\"\\n收到中断信号\")\n            except asyncio.CancelledError:\n                print(\"\\n任务被取消\")\n            except Exception as e:\n                print(f\"\\n命令处理出错: {e}\")\n            \n            print(\"\\n关闭环境...\")\n        \n        # 关闭环境\n        self.ipc_handler.update_status(\"stopped\")\n        await self.env.close()\n        \n        print(\"环境已关闭\")\n        print(\"=\" * 60)\n\n\nasync def main():\n    parser = argparse.ArgumentParser(description='OASIS Reddit模拟')\n    parser.add_argument(\n        '--config', \n        type=str, \n        required=True,\n        help='配置文件路径 (simulation_config.json)'\n    )\n    parser.add_argument(\n        '--max-rounds',\n        type=int,\n        default=None,\n        help='最大模拟轮数（可选，用于截断过长的模拟）'\n    )\n    parser.add_argument(\n        '--no-wait',\n        action='store_true',\n        default=False,\n        help='模拟完成后立即关闭环境，不进入等待命令模式'\n    )\n    \n    args = parser.parse_args()\n    \n    # 在 main 函数开始时创建 shutdown 事件\n    global _shutdown_event\n    _shutdown_event = asyncio.Event()\n    \n    if not os.path.exists(args.config):\n        print(f\"错误: 配置文件不存在: {args.config}\")\n        sys.exit(1)\n    \n    # 初始化日志配置（使用固定文件名，清理旧日志）\n    simulation_dir = os.path.dirname(args.config) or \".\"\n    setup_oasis_logging(os.path.join(simulation_dir, \"log\"))\n    \n    runner = RedditSimulationRunner(\n        config_path=args.config,\n        wait_for_commands=not args.no_wait\n    )\n    await runner.run(max_rounds=args.max_rounds)\n\n\ndef setup_signal_handlers():\n    \"\"\"\n    设置信号处理器，确保收到 SIGTERM/SIGINT 时能够正确退出\n    让程序有机会正常清理资源（关闭数据库、环境等）\n    \"\"\"\n    def signal_handler(signum, frame):\n        global _cleanup_done\n        sig_name = \"SIGTERM\" if signum == signal.SIGTERM else \"SIGINT\"\n        print(f\"\\n收到 {sig_name} 信号，正在退出...\")\n        if not _cleanup_done:\n            _cleanup_done = True\n            if _shutdown_event:\n                _shutdown_event.set()\n        else:\n            # 重复收到信号才强制退出\n            print(\"强制退出...\")\n            sys.exit(1)\n    \n    signal.signal(signal.SIGTERM, signal_handler)\n    signal.signal(signal.SIGINT, signal_handler)\n\n\nif __name__ == \"__main__\":\n    setup_signal_handlers()\n    try:\n        asyncio.run(main())\n    except KeyboardInterrupt:\n        print(\"\\n程序被中断\")\n    except SystemExit:\n        pass\n    finally:\n        print(\"模拟进程已退出\")\n\n"
  },
  {
    "path": "backend/scripts/run_twitter_simulation.py",
    "content": "\"\"\"\nOASIS Twitter模拟预设脚本\n此脚本读取配置文件中的参数来执行模拟，实现全程自动化\n\n功能特性:\n- 完成模拟后不立即关闭环境，进入等待命令模式\n- 支持通过IPC接收Interview命令\n- 支持单个Agent采访和批量采访\n- 支持远程关闭环境命令\n\n使用方式:\n    python run_twitter_simulation.py --config /path/to/simulation_config.json\n    python run_twitter_simulation.py --config /path/to/simulation_config.json --no-wait  # 完成后立即关闭\n\"\"\"\n\nimport argparse\nimport asyncio\nimport json\nimport logging\nimport os\nimport random\nimport signal\nimport sys\nimport sqlite3\nfrom datetime import datetime\nfrom typing import Dict, Any, List, Optional\n\n# 全局变量：用于信号处理\n_shutdown_event = None\n_cleanup_done = False\n\n# 添加项目路径\n_scripts_dir = os.path.dirname(os.path.abspath(__file__))\n_backend_dir = os.path.abspath(os.path.join(_scripts_dir, '..'))\n_project_root = os.path.abspath(os.path.join(_backend_dir, '..'))\nsys.path.insert(0, _scripts_dir)\nsys.path.insert(0, _backend_dir)\n\n# 加载项目根目录的 .env 文件（包含 LLM_API_KEY 等配置）\nfrom dotenv import load_dotenv\n_env_file = os.path.join(_project_root, '.env')\nif os.path.exists(_env_file):\n    load_dotenv(_env_file)\nelse:\n    _backend_env = os.path.join(_backend_dir, '.env')\n    if os.path.exists(_backend_env):\n        load_dotenv(_backend_env)\n\n\nimport re\n\n\nclass UnicodeFormatter(logging.Formatter):\n    \"\"\"自定义格式化器，将 Unicode 转义序列转换为可读字符\"\"\"\n    \n    UNICODE_ESCAPE_PATTERN = re.compile(r'\\\\u([0-9a-fA-F]{4})')\n    \n    def format(self, record):\n        result = super().format(record)\n        \n        def replace_unicode(match):\n            try:\n                return chr(int(match.group(1), 16))\n            except (ValueError, OverflowError):\n                return match.group(0)\n        \n        return self.UNICODE_ESCAPE_PATTERN.sub(replace_unicode, result)\n\n\nclass MaxTokensWarningFilter(logging.Filter):\n    \"\"\"过滤掉 camel-ai 关于 max_tokens 的警告（我们故意不设置 max_tokens，让模型自行决定）\"\"\"\n    \n    def filter(self, record):\n        # 过滤掉包含 max_tokens 警告的日志\n        if \"max_tokens\" in record.getMessage() and \"Invalid or missing\" in record.getMessage():\n            return False\n        return True\n\n\n# 在模块加载时立即添加过滤器，确保在 camel 代码执行前生效\nlogging.getLogger().addFilter(MaxTokensWarningFilter())\n\n\ndef setup_oasis_logging(log_dir: str):\n    \"\"\"配置 OASIS 的日志，使用固定名称的日志文件\"\"\"\n    os.makedirs(log_dir, exist_ok=True)\n    \n    # 清理旧的日志文件\n    for f in os.listdir(log_dir):\n        old_log = os.path.join(log_dir, f)\n        if os.path.isfile(old_log) and f.endswith('.log'):\n            try:\n                os.remove(old_log)\n            except OSError:\n                pass\n    \n    formatter = UnicodeFormatter(\"%(levelname)s - %(asctime)s - %(name)s - %(message)s\")\n    \n    loggers_config = {\n        \"social.agent\": os.path.join(log_dir, \"social.agent.log\"),\n        \"social.twitter\": os.path.join(log_dir, \"social.twitter.log\"),\n        \"social.rec\": os.path.join(log_dir, \"social.rec.log\"),\n        \"oasis.env\": os.path.join(log_dir, \"oasis.env.log\"),\n        \"table\": os.path.join(log_dir, \"table.log\"),\n    }\n    \n    for logger_name, log_file in loggers_config.items():\n        logger = logging.getLogger(logger_name)\n        logger.setLevel(logging.DEBUG)\n        logger.handlers.clear()\n        file_handler = logging.FileHandler(log_file, encoding='utf-8', mode='w')\n        file_handler.setLevel(logging.DEBUG)\n        file_handler.setFormatter(formatter)\n        logger.addHandler(file_handler)\n        logger.propagate = False\n\n\ntry:\n    from camel.models import ModelFactory\n    from camel.types import ModelPlatformType\n    import oasis\n    from oasis import (\n        ActionType,\n        LLMAction,\n        ManualAction,\n        generate_twitter_agent_graph\n    )\nexcept ImportError as e:\n    print(f\"错误: 缺少依赖 {e}\")\n    print(\"请先安装: pip install oasis-ai camel-ai\")\n    sys.exit(1)\n\n\n# IPC相关常量\nIPC_COMMANDS_DIR = \"ipc_commands\"\nIPC_RESPONSES_DIR = \"ipc_responses\"\nENV_STATUS_FILE = \"env_status.json\"\n\nclass CommandType:\n    \"\"\"命令类型常量\"\"\"\n    INTERVIEW = \"interview\"\n    BATCH_INTERVIEW = \"batch_interview\"\n    CLOSE_ENV = \"close_env\"\n\n\nclass IPCHandler:\n    \"\"\"IPC命令处理器\"\"\"\n    \n    def __init__(self, simulation_dir: str, env, agent_graph):\n        self.simulation_dir = simulation_dir\n        self.env = env\n        self.agent_graph = agent_graph\n        self.commands_dir = os.path.join(simulation_dir, IPC_COMMANDS_DIR)\n        self.responses_dir = os.path.join(simulation_dir, IPC_RESPONSES_DIR)\n        self.status_file = os.path.join(simulation_dir, ENV_STATUS_FILE)\n        self._running = True\n        \n        # 确保目录存在\n        os.makedirs(self.commands_dir, exist_ok=True)\n        os.makedirs(self.responses_dir, exist_ok=True)\n    \n    def update_status(self, status: str):\n        \"\"\"更新环境状态\"\"\"\n        with open(self.status_file, 'w', encoding='utf-8') as f:\n            json.dump({\n                \"status\": status,\n                \"timestamp\": datetime.now().isoformat()\n            }, f, ensure_ascii=False, indent=2)\n    \n    def poll_command(self) -> Optional[Dict[str, Any]]:\n        \"\"\"轮询获取待处理命令\"\"\"\n        if not os.path.exists(self.commands_dir):\n            return None\n        \n        # 获取命令文件（按时间排序）\n        command_files = []\n        for filename in os.listdir(self.commands_dir):\n            if filename.endswith('.json'):\n                filepath = os.path.join(self.commands_dir, filename)\n                command_files.append((filepath, os.path.getmtime(filepath)))\n        \n        command_files.sort(key=lambda x: x[1])\n        \n        for filepath, _ in command_files:\n            try:\n                with open(filepath, 'r', encoding='utf-8') as f:\n                    return json.load(f)\n            except (json.JSONDecodeError, OSError):\n                continue\n        \n        return None\n    \n    def send_response(self, command_id: str, status: str, result: Dict = None, error: str = None):\n        \"\"\"发送响应\"\"\"\n        response = {\n            \"command_id\": command_id,\n            \"status\": status,\n            \"result\": result,\n            \"error\": error,\n            \"timestamp\": datetime.now().isoformat()\n        }\n        \n        response_file = os.path.join(self.responses_dir, f\"{command_id}.json\")\n        with open(response_file, 'w', encoding='utf-8') as f:\n            json.dump(response, f, ensure_ascii=False, indent=2)\n        \n        # 删除命令文件\n        command_file = os.path.join(self.commands_dir, f\"{command_id}.json\")\n        try:\n            os.remove(command_file)\n        except OSError:\n            pass\n    \n    async def handle_interview(self, command_id: str, agent_id: int, prompt: str) -> bool:\n        \"\"\"\n        处理单个Agent采访命令\n        \n        Returns:\n            True 表示成功，False 表示失败\n        \"\"\"\n        try:\n            # 获取Agent\n            agent = self.agent_graph.get_agent(agent_id)\n            \n            # 创建Interview动作\n            interview_action = ManualAction(\n                action_type=ActionType.INTERVIEW,\n                action_args={\"prompt\": prompt}\n            )\n            \n            # 执行Interview\n            actions = {agent: interview_action}\n            await self.env.step(actions)\n            \n            # 从数据库获取结果\n            result = self._get_interview_result(agent_id)\n            \n            self.send_response(command_id, \"completed\", result=result)\n            print(f\"  Interview完成: agent_id={agent_id}\")\n            return True\n            \n        except Exception as e:\n            error_msg = str(e)\n            print(f\"  Interview失败: agent_id={agent_id}, error={error_msg}\")\n            self.send_response(command_id, \"failed\", error=error_msg)\n            return False\n    \n    async def handle_batch_interview(self, command_id: str, interviews: List[Dict]) -> bool:\n        \"\"\"\n        处理批量采访命令\n        \n        Args:\n            interviews: [{\"agent_id\": int, \"prompt\": str}, ...]\n        \"\"\"\n        try:\n            # 构建动作字典\n            actions = {}\n            agent_prompts = {}  # 记录每个agent的prompt\n            \n            for interview in interviews:\n                agent_id = interview.get(\"agent_id\")\n                prompt = interview.get(\"prompt\", \"\")\n                \n                try:\n                    agent = self.agent_graph.get_agent(agent_id)\n                    actions[agent] = ManualAction(\n                        action_type=ActionType.INTERVIEW,\n                        action_args={\"prompt\": prompt}\n                    )\n                    agent_prompts[agent_id] = prompt\n                except Exception as e:\n                    print(f\"  警告: 无法获取Agent {agent_id}: {e}\")\n            \n            if not actions:\n                self.send_response(command_id, \"failed\", error=\"没有有效的Agent\")\n                return False\n            \n            # 执行批量Interview\n            await self.env.step(actions)\n            \n            # 获取所有结果\n            results = {}\n            for agent_id in agent_prompts.keys():\n                result = self._get_interview_result(agent_id)\n                results[agent_id] = result\n            \n            self.send_response(command_id, \"completed\", result={\n                \"interviews_count\": len(results),\n                \"results\": results\n            })\n            print(f\"  批量Interview完成: {len(results)} 个Agent\")\n            return True\n            \n        except Exception as e:\n            error_msg = str(e)\n            print(f\"  批量Interview失败: {error_msg}\")\n            self.send_response(command_id, \"failed\", error=error_msg)\n            return False\n    \n    def _get_interview_result(self, agent_id: int) -> Dict[str, Any]:\n        \"\"\"从数据库获取最新的Interview结果\"\"\"\n        db_path = os.path.join(self.simulation_dir, \"twitter_simulation.db\")\n        \n        result = {\n            \"agent_id\": agent_id,\n            \"response\": None,\n            \"timestamp\": None\n        }\n        \n        if not os.path.exists(db_path):\n            return result\n        \n        try:\n            conn = sqlite3.connect(db_path)\n            cursor = conn.cursor()\n            \n            # 查询最新的Interview记录\n            cursor.execute(\"\"\"\n                SELECT user_id, info, created_at\n                FROM trace\n                WHERE action = ? AND user_id = ?\n                ORDER BY created_at DESC\n                LIMIT 1\n            \"\"\", (ActionType.INTERVIEW.value, agent_id))\n            \n            row = cursor.fetchone()\n            if row:\n                user_id, info_json, created_at = row\n                try:\n                    info = json.loads(info_json) if info_json else {}\n                    result[\"response\"] = info.get(\"response\", info)\n                    result[\"timestamp\"] = created_at\n                except json.JSONDecodeError:\n                    result[\"response\"] = info_json\n            \n            conn.close()\n            \n        except Exception as e:\n            print(f\"  读取Interview结果失败: {e}\")\n        \n        return result\n    \n    async def process_commands(self) -> bool:\n        \"\"\"\n        处理所有待处理命令\n        \n        Returns:\n            True 表示继续运行，False 表示应该退出\n        \"\"\"\n        command = self.poll_command()\n        if not command:\n            return True\n        \n        command_id = command.get(\"command_id\")\n        command_type = command.get(\"command_type\")\n        args = command.get(\"args\", {})\n        \n        print(f\"\\n收到IPC命令: {command_type}, id={command_id}\")\n        \n        if command_type == CommandType.INTERVIEW:\n            await self.handle_interview(\n                command_id,\n                args.get(\"agent_id\", 0),\n                args.get(\"prompt\", \"\")\n            )\n            return True\n            \n        elif command_type == CommandType.BATCH_INTERVIEW:\n            await self.handle_batch_interview(\n                command_id,\n                args.get(\"interviews\", [])\n            )\n            return True\n            \n        elif command_type == CommandType.CLOSE_ENV:\n            print(\"收到关闭环境命令\")\n            self.send_response(command_id, \"completed\", result={\"message\": \"环境即将关闭\"})\n            return False\n        \n        else:\n            self.send_response(command_id, \"failed\", error=f\"未知命令类型: {command_type}\")\n            return True\n\n\nclass TwitterSimulationRunner:\n    \"\"\"Twitter模拟运行器\"\"\"\n    \n    # Twitter可用动作（不包含INTERVIEW，INTERVIEW只能通过ManualAction手动触发）\n    AVAILABLE_ACTIONS = [\n        ActionType.CREATE_POST,\n        ActionType.LIKE_POST,\n        ActionType.REPOST,\n        ActionType.FOLLOW,\n        ActionType.DO_NOTHING,\n        ActionType.QUOTE_POST,\n    ]\n    \n    def __init__(self, config_path: str, wait_for_commands: bool = True):\n        \"\"\"\n        初始化模拟运行器\n        \n        Args:\n            config_path: 配置文件路径 (simulation_config.json)\n            wait_for_commands: 模拟完成后是否等待命令（默认True）\n        \"\"\"\n        self.config_path = config_path\n        self.config = self._load_config()\n        self.simulation_dir = os.path.dirname(config_path)\n        self.wait_for_commands = wait_for_commands\n        self.env = None\n        self.agent_graph = None\n        self.ipc_handler = None\n        \n    def _load_config(self) -> Dict[str, Any]:\n        \"\"\"加载配置文件\"\"\"\n        with open(self.config_path, 'r', encoding='utf-8') as f:\n            return json.load(f)\n    \n    def _get_profile_path(self) -> str:\n        \"\"\"获取Profile文件路径（OASIS Twitter使用CSV格式）\"\"\"\n        return os.path.join(self.simulation_dir, \"twitter_profiles.csv\")\n    \n    def _get_db_path(self) -> str:\n        \"\"\"获取数据库路径\"\"\"\n        return os.path.join(self.simulation_dir, \"twitter_simulation.db\")\n    \n    def _create_model(self):\n        \"\"\"\n        创建LLM模型\n        \n        统一使用项目根目录 .env 文件中的配置（优先级最高）：\n        - LLM_API_KEY: API密钥\n        - LLM_BASE_URL: API基础URL\n        - LLM_MODEL_NAME: 模型名称\n        \"\"\"\n        # 优先从 .env 读取配置\n        llm_api_key = os.environ.get(\"LLM_API_KEY\", \"\")\n        llm_base_url = os.environ.get(\"LLM_BASE_URL\", \"\")\n        llm_model = os.environ.get(\"LLM_MODEL_NAME\", \"\")\n        \n        # 如果 .env 中没有，则使用 config 作为备用\n        if not llm_model:\n            llm_model = self.config.get(\"llm_model\", \"gpt-4o-mini\")\n        \n        # 设置 camel-ai 所需的环境变量\n        if llm_api_key:\n            os.environ[\"OPENAI_API_KEY\"] = llm_api_key\n        \n        if not os.environ.get(\"OPENAI_API_KEY\"):\n            raise ValueError(\"缺少 API Key 配置，请在项目根目录 .env 文件中设置 LLM_API_KEY\")\n        \n        if llm_base_url:\n            os.environ[\"OPENAI_API_BASE_URL\"] = llm_base_url\n        \n        print(f\"LLM配置: model={llm_model}, base_url={llm_base_url[:40] if llm_base_url else '默认'}...\")\n        \n        return ModelFactory.create(\n            model_platform=ModelPlatformType.OPENAI,\n            model_type=llm_model,\n        )\n    \n    def _get_active_agents_for_round(\n        self, \n        env, \n        current_hour: int,\n        round_num: int\n    ) -> List:\n        \"\"\"\n        根据时间和配置决定本轮激活哪些Agent\n        \n        Args:\n            env: OASIS环境\n            current_hour: 当前模拟小时（0-23）\n            round_num: 当前轮数\n            \n        Returns:\n            激活的Agent列表\n        \"\"\"\n        time_config = self.config.get(\"time_config\", {})\n        agent_configs = self.config.get(\"agent_configs\", [])\n        \n        # 基础激活数量\n        base_min = time_config.get(\"agents_per_hour_min\", 5)\n        base_max = time_config.get(\"agents_per_hour_max\", 20)\n        \n        # 根据时段调整\n        peak_hours = time_config.get(\"peak_hours\", [9, 10, 11, 14, 15, 20, 21, 22])\n        off_peak_hours = time_config.get(\"off_peak_hours\", [0, 1, 2, 3, 4, 5])\n        \n        if current_hour in peak_hours:\n            multiplier = time_config.get(\"peak_activity_multiplier\", 1.5)\n        elif current_hour in off_peak_hours:\n            multiplier = time_config.get(\"off_peak_activity_multiplier\", 0.3)\n        else:\n            multiplier = 1.0\n        \n        target_count = int(random.uniform(base_min, base_max) * multiplier)\n        \n        # 根据每个Agent的配置计算激活概率\n        candidates = []\n        for cfg in agent_configs:\n            agent_id = cfg.get(\"agent_id\", 0)\n            active_hours = cfg.get(\"active_hours\", list(range(8, 23)))\n            activity_level = cfg.get(\"activity_level\", 0.5)\n            \n            # 检查是否在活跃时间\n            if current_hour not in active_hours:\n                continue\n            \n            # 根据活跃度计算概率\n            if random.random() < activity_level:\n                candidates.append(agent_id)\n        \n        # 随机选择\n        selected_ids = random.sample(\n            candidates, \n            min(target_count, len(candidates))\n        ) if candidates else []\n        \n        # 转换为Agent对象\n        active_agents = []\n        for agent_id in selected_ids:\n            try:\n                agent = env.agent_graph.get_agent(agent_id)\n                active_agents.append((agent_id, agent))\n            except Exception:\n                pass\n        \n        return active_agents\n    \n    async def run(self, max_rounds: int = None):\n        \"\"\"运行Twitter模拟\n        \n        Args:\n            max_rounds: 最大模拟轮数（可选，用于截断过长的模拟）\n        \"\"\"\n        print(\"=\" * 60)\n        print(\"OASIS Twitter模拟\")\n        print(f\"配置文件: {self.config_path}\")\n        print(f\"模拟ID: {self.config.get('simulation_id', 'unknown')}\")\n        print(f\"等待命令模式: {'启用' if self.wait_for_commands else '禁用'}\")\n        print(\"=\" * 60)\n        \n        # 加载时间配置\n        time_config = self.config.get(\"time_config\", {})\n        total_hours = time_config.get(\"total_simulation_hours\", 72)\n        minutes_per_round = time_config.get(\"minutes_per_round\", 30)\n        \n        # 计算总轮数\n        total_rounds = (total_hours * 60) // minutes_per_round\n        \n        # 如果指定了最大轮数，则截断\n        if max_rounds is not None and max_rounds > 0:\n            original_rounds = total_rounds\n            total_rounds = min(total_rounds, max_rounds)\n            if total_rounds < original_rounds:\n                print(f\"\\n轮数已截断: {original_rounds} -> {total_rounds} (max_rounds={max_rounds})\")\n        \n        print(f\"\\n模拟参数:\")\n        print(f\"  - 总模拟时长: {total_hours}小时\")\n        print(f\"  - 每轮时间: {minutes_per_round}分钟\")\n        print(f\"  - 总轮数: {total_rounds}\")\n        if max_rounds:\n            print(f\"  - 最大轮数限制: {max_rounds}\")\n        print(f\"  - Agent数量: {len(self.config.get('agent_configs', []))}\")\n        \n        # 创建模型\n        print(\"\\n初始化LLM模型...\")\n        model = self._create_model()\n        \n        # 加载Agent图\n        print(\"加载Agent Profile...\")\n        profile_path = self._get_profile_path()\n        if not os.path.exists(profile_path):\n            print(f\"错误: Profile文件不存在: {profile_path}\")\n            return\n        \n        self.agent_graph = await generate_twitter_agent_graph(\n            profile_path=profile_path,\n            model=model,\n            available_actions=self.AVAILABLE_ACTIONS,\n        )\n        \n        # 数据库路径\n        db_path = self._get_db_path()\n        if os.path.exists(db_path):\n            os.remove(db_path)\n            print(f\"已删除旧数据库: {db_path}\")\n        \n        # 创建环境\n        print(\"创建OASIS环境...\")\n        self.env = oasis.make(\n            agent_graph=self.agent_graph,\n            platform=oasis.DefaultPlatformType.TWITTER,\n            database_path=db_path,\n            semaphore=30,  # 限制最大并发 LLM 请求数，防止 API 过载\n        )\n        \n        await self.env.reset()\n        print(\"环境初始化完成\\n\")\n        \n        # 初始化IPC处理器\n        self.ipc_handler = IPCHandler(self.simulation_dir, self.env, self.agent_graph)\n        self.ipc_handler.update_status(\"running\")\n        \n        # 执行初始事件\n        event_config = self.config.get(\"event_config\", {})\n        initial_posts = event_config.get(\"initial_posts\", [])\n        \n        if initial_posts:\n            print(f\"执行初始事件 ({len(initial_posts)}条初始帖子)...\")\n            initial_actions = {}\n            for post in initial_posts:\n                agent_id = post.get(\"poster_agent_id\", 0)\n                content = post.get(\"content\", \"\")\n                try:\n                    agent = self.env.agent_graph.get_agent(agent_id)\n                    initial_actions[agent] = ManualAction(\n                        action_type=ActionType.CREATE_POST,\n                        action_args={\"content\": content}\n                    )\n                except Exception as e:\n                    print(f\"  警告: 无法为Agent {agent_id}创建初始帖子: {e}\")\n            \n            if initial_actions:\n                await self.env.step(initial_actions)\n                print(f\"  已发布 {len(initial_actions)} 条初始帖子\")\n        \n        # 主模拟循环\n        print(\"\\n开始模拟循环...\")\n        start_time = datetime.now()\n        \n        for round_num in range(total_rounds):\n            # 计算当前模拟时间\n            simulated_minutes = round_num * minutes_per_round\n            simulated_hour = (simulated_minutes // 60) % 24\n            simulated_day = simulated_minutes // (60 * 24) + 1\n            \n            # 获取本轮激活的Agent\n            active_agents = self._get_active_agents_for_round(\n                self.env, simulated_hour, round_num\n            )\n            \n            if not active_agents:\n                continue\n            \n            # 构建动作\n            actions = {\n                agent: LLMAction()\n                for _, agent in active_agents\n            }\n            \n            # 执行动作\n            await self.env.step(actions)\n            \n            # 打印进度\n            if (round_num + 1) % 10 == 0 or round_num == 0:\n                elapsed = (datetime.now() - start_time).total_seconds()\n                progress = (round_num + 1) / total_rounds * 100\n                print(f\"  [Day {simulated_day}, {simulated_hour:02d}:00] \"\n                      f\"Round {round_num + 1}/{total_rounds} ({progress:.1f}%) \"\n                      f\"- {len(active_agents)} agents active \"\n                      f\"- elapsed: {elapsed:.1f}s\")\n        \n        total_elapsed = (datetime.now() - start_time).total_seconds()\n        print(f\"\\n模拟循环完成!\")\n        print(f\"  - 总耗时: {total_elapsed:.1f}秒\")\n        print(f\"  - 数据库: {db_path}\")\n        \n        # 是否进入等待命令模式\n        if self.wait_for_commands:\n            print(\"\\n\" + \"=\" * 60)\n            print(\"进入等待命令模式 - 环境保持运行\")\n            print(\"支持的命令: interview, batch_interview, close_env\")\n            print(\"=\" * 60)\n            \n            self.ipc_handler.update_status(\"alive\")\n            \n            # 等待命令循环（使用全局 _shutdown_event）\n            try:\n                while not _shutdown_event.is_set():\n                    should_continue = await self.ipc_handler.process_commands()\n                    if not should_continue:\n                        break\n                    try:\n                        await asyncio.wait_for(_shutdown_event.wait(), timeout=0.5)\n                        break  # 收到退出信号\n                    except asyncio.TimeoutError:\n                        pass\n            except KeyboardInterrupt:\n                print(\"\\n收到中断信号\")\n            except asyncio.CancelledError:\n                print(\"\\n任务被取消\")\n            except Exception as e:\n                print(f\"\\n命令处理出错: {e}\")\n            \n            print(\"\\n关闭环境...\")\n        \n        # 关闭环境\n        self.ipc_handler.update_status(\"stopped\")\n        await self.env.close()\n        \n        print(\"环境已关闭\")\n        print(\"=\" * 60)\n\n\nasync def main():\n    parser = argparse.ArgumentParser(description='OASIS Twitter模拟')\n    parser.add_argument(\n        '--config', \n        type=str, \n        required=True,\n        help='配置文件路径 (simulation_config.json)'\n    )\n    parser.add_argument(\n        '--max-rounds',\n        type=int,\n        default=None,\n        help='最大模拟轮数（可选，用于截断过长的模拟）'\n    )\n    parser.add_argument(\n        '--no-wait',\n        action='store_true',\n        default=False,\n        help='模拟完成后立即关闭环境，不进入等待命令模式'\n    )\n    \n    args = parser.parse_args()\n    \n    # 在 main 函数开始时创建 shutdown 事件\n    global _shutdown_event\n    _shutdown_event = asyncio.Event()\n    \n    if not os.path.exists(args.config):\n        print(f\"错误: 配置文件不存在: {args.config}\")\n        sys.exit(1)\n    \n    # 初始化日志配置（使用固定文件名，清理旧日志）\n    simulation_dir = os.path.dirname(args.config) or \".\"\n    setup_oasis_logging(os.path.join(simulation_dir, \"log\"))\n    \n    runner = TwitterSimulationRunner(\n        config_path=args.config,\n        wait_for_commands=not args.no_wait\n    )\n    await runner.run(max_rounds=args.max_rounds)\n\n\ndef setup_signal_handlers():\n    \"\"\"\n    设置信号处理器，确保收到 SIGTERM/SIGINT 时能够正确退出\n    让程序有机会正常清理资源（关闭数据库、环境等）\n    \"\"\"\n    def signal_handler(signum, frame):\n        global _cleanup_done\n        sig_name = \"SIGTERM\" if signum == signal.SIGTERM else \"SIGINT\"\n        print(f\"\\n收到 {sig_name} 信号，正在退出...\")\n        if not _cleanup_done:\n            _cleanup_done = True\n            if _shutdown_event:\n                _shutdown_event.set()\n        else:\n            # 重复收到信号才强制退出\n            print(\"强制退出...\")\n            sys.exit(1)\n    \n    signal.signal(signal.SIGTERM, signal_handler)\n    signal.signal(signal.SIGINT, signal_handler)\n\n\nif __name__ == \"__main__\":\n    setup_signal_handlers()\n    try:\n        asyncio.run(main())\n    except KeyboardInterrupt:\n        print(\"\\n程序被中断\")\n    except SystemExit:\n        pass\n    finally:\n        print(\"模拟进程已退出\")\n"
  },
  {
    "path": "backend/scripts/test_profile_format.py",
    "content": "\"\"\"\n测试Profile格式生成是否符合OASIS要求\n验证：\n1. Twitter Profile生成CSV格式\n2. Reddit Profile生成JSON详细格式\n\"\"\"\n\nimport os\nimport sys\nimport json\nimport csv\nimport tempfile\n\n# 添加项目路径\nsys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))\n\nfrom app.services.oasis_profile_generator import OasisProfileGenerator, OasisAgentProfile\n\n\ndef test_profile_formats():\n    \"\"\"测试Profile格式\"\"\"\n    print(\"=\" * 60)\n    print(\"OASIS Profile格式测试\")\n    print(\"=\" * 60)\n    \n    # 创建测试Profile数据\n    test_profiles = [\n        OasisAgentProfile(\n            user_id=0,\n            user_name=\"test_user_123\",\n            name=\"Test User\",\n            bio=\"A test user for validation\",\n            persona=\"Test User is an enthusiastic participant in social discussions.\",\n            karma=1500,\n            friend_count=100,\n            follower_count=200,\n            statuses_count=500,\n            age=25,\n            gender=\"male\",\n            mbti=\"INTJ\",\n            country=\"China\",\n            profession=\"Student\",\n            interested_topics=[\"Technology\", \"Education\"],\n            source_entity_uuid=\"test-uuid-123\",\n            source_entity_type=\"Student\",\n        ),\n        OasisAgentProfile(\n            user_id=1,\n            user_name=\"org_official_456\",\n            name=\"Official Organization\",\n            bio=\"Official account for Organization\",\n            persona=\"This is an official institutional account that communicates official positions.\",\n            karma=5000,\n            friend_count=50,\n            follower_count=10000,\n            statuses_count=200,\n            profession=\"Organization\",\n            interested_topics=[\"Public Policy\", \"Announcements\"],\n            source_entity_uuid=\"test-uuid-456\",\n            source_entity_type=\"University\",\n        ),\n    ]\n    \n    generator = OasisProfileGenerator.__new__(OasisProfileGenerator)\n    \n    # 使用临时目录\n    with tempfile.TemporaryDirectory() as temp_dir:\n        twitter_path = os.path.join(temp_dir, \"twitter_profiles.csv\")\n        reddit_path = os.path.join(temp_dir, \"reddit_profiles.json\")\n        \n        # 测试Twitter CSV格式\n        print(\"\\n1. 测试Twitter Profile (CSV格式)\")\n        print(\"-\" * 40)\n        generator._save_twitter_csv(test_profiles, twitter_path)\n        \n        # 读取并验证CSV\n        with open(twitter_path, 'r', encoding='utf-8') as f:\n            reader = csv.DictReader(f)\n            rows = list(reader)\n            \n        print(f\"   文件: {twitter_path}\")\n        print(f\"   行数: {len(rows)}\")\n        print(f\"   表头: {list(rows[0].keys())}\")\n        print(f\"\\n   示例数据 (第1行):\")\n        for key, value in rows[0].items():\n            print(f\"     {key}: {value}\")\n        \n        # 验证必需字段\n        required_twitter_fields = ['user_id', 'user_name', 'name', 'bio', \n                                   'friend_count', 'follower_count', 'statuses_count', 'created_at']\n        missing = set(required_twitter_fields) - set(rows[0].keys())\n        if missing:\n            print(f\"\\n   [错误] 缺少字段: {missing}\")\n        else:\n            print(f\"\\n   [通过] 所有必需字段都存在\")\n        \n        # 测试Reddit JSON格式\n        print(\"\\n2. 测试Reddit Profile (JSON详细格式)\")\n        print(\"-\" * 40)\n        generator._save_reddit_json(test_profiles, reddit_path)\n        \n        # 读取并验证JSON\n        with open(reddit_path, 'r', encoding='utf-8') as f:\n            reddit_data = json.load(f)\n        \n        print(f\"   文件: {reddit_path}\")\n        print(f\"   条目数: {len(reddit_data)}\")\n        print(f\"   字段: {list(reddit_data[0].keys())}\")\n        print(f\"\\n   示例数据 (第1条):\")\n        print(json.dumps(reddit_data[0], ensure_ascii=False, indent=4))\n        \n        # 验证详细格式字段\n        required_reddit_fields = ['realname', 'username', 'bio', 'persona']\n        optional_reddit_fields = ['age', 'gender', 'mbti', 'country', 'profession', 'interested_topics']\n        \n        missing = set(required_reddit_fields) - set(reddit_data[0].keys())\n        if missing:\n            print(f\"\\n   [错误] 缺少必需字段: {missing}\")\n        else:\n            print(f\"\\n   [通过] 所有必需字段都存在\")\n        \n        present_optional = set(optional_reddit_fields) & set(reddit_data[0].keys())\n        print(f\"   [信息] 可选字段: {present_optional}\")\n    \n    print(\"\\n\" + \"=\" * 60)\n    print(\"测试完成!\")\n    print(\"=\" * 60)\n\n\ndef show_expected_formats():\n    \"\"\"显示OASIS期望的格式\"\"\"\n    print(\"\\n\" + \"=\" * 60)\n    print(\"OASIS 期望的Profile格式参考\")\n    print(\"=\" * 60)\n    \n    print(\"\\n1. Twitter Profile (CSV格式)\")\n    print(\"-\" * 40)\n    twitter_example = \"\"\"user_id,user_name,name,bio,friend_count,follower_count,statuses_count,created_at\n0,user0,User Zero,I am user zero with interests in technology.,100,150,500,2023-01-01\n1,user1,User One,Tech enthusiast and coffee lover.,200,250,1000,2023-01-02\"\"\"\n    print(twitter_example)\n    \n    print(\"\\n2. Reddit Profile (JSON详细格式)\")\n    print(\"-\" * 40)\n    reddit_example = [\n        {\n            \"realname\": \"James Miller\",\n            \"username\": \"millerhospitality\",\n            \"bio\": \"Passionate about hospitality & tourism.\",\n            \"persona\": \"James is a seasoned professional in the Hospitality & Tourism industry...\",\n            \"age\": 40,\n            \"gender\": \"male\",\n            \"mbti\": \"ESTJ\",\n            \"country\": \"UK\",\n            \"profession\": \"Hospitality & Tourism\",\n            \"interested_topics\": [\"Economics\", \"Business\"]\n        }\n    ]\n    print(json.dumps(reddit_example, ensure_ascii=False, indent=2))\n\n\nif __name__ == \"__main__\":\n    test_profile_formats()\n    show_expected_formats()\n\n\n"
  },
  {
    "path": "docker-compose.yml",
    "content": "services:\n  mirofish:\n    image: ghcr.io/666ghj/mirofish:latest\n    # 加速镜像（如拉取缓慢可替换上方地址）\n    # image: ghcr.nju.edu.cn/666ghj/mirofish:latest\n    container_name: mirofish\n    env_file:\n      - .env\n    ports:\n      - \"3000:3000\"\n      - \"5001:5001\"\n    restart: unless-stopped\n    volumes:\n      - ./backend/uploads:/app/backend/uploads"
  },
  {
    "path": "frontend/.gitignore",
    "content": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\nnode_modules\ndist\ndist-ssr\n*.local\n\n# Editor directories and files\n.vscode/*\n!.vscode/extensions.json\n.idea\n.DS_Store\n*.suo\n*.ntvs*\n*.njsproj\n*.sln\n*.sw?\n"
  },
  {
    "path": "frontend/index.html",
    "content": "<!doctype html>\n<html lang=\"zh-CN\">\n  <head>\n    <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n    <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n    <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\">\n    <meta charset=\"UTF-8\" />\n    <link rel=\"icon\" type=\"image/png\" href=\"/icon.png\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <meta name=\"description\" content=\"MiroFish - 社交媒体舆论模拟系统\" />\n    <title>MiroFish - 预测万物</title>\n  </head>\n  <body>\n    <div id=\"app\"></div>\n    <script type=\"module\" src=\"/src/main.js\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "frontend/package.json",
    "content": "{\n  \"name\": \"frontend\",\n  \"private\": true,\n  \"version\": \"0.1.0\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"vite --host\",\n    \"build\": \"vite build\",\n    \"preview\": \"vite preview\"\n  },\n  \"dependencies\": {\n    \"axios\": \"^1.13.2\",\n    \"d3\": \"^7.9.0\",\n    \"vue\": \"^3.5.24\",\n    \"vue-router\": \"^4.6.3\"\n  },\n  \"devDependencies\": {\n    \"@vitejs/plugin-vue\": \"^6.0.1\",\n    \"vite\": \"^7.2.4\"\n  }\n}\n"
  },
  {
    "path": "frontend/src/App.vue",
    "content": "<template>\n  <router-view />\n</template>\n\n<script setup>\n// 使用 Vue Router 来管理页面\n</script>\n\n<style>\n/* 全局样式重置 */\n* {\n  margin: 0;\n  padding: 0;\n  box-sizing: border-box;\n}\n\n#app {\n  font-family: 'JetBrains Mono', 'Space Grotesk', 'Noto Sans SC', monospace;\n  -webkit-font-smoothing: antialiased;\n  -moz-osx-font-smoothing: grayscale;\n  color: #000000;\n  background-color: #ffffff;\n}\n\n/* 滚动条样式 */\n::-webkit-scrollbar {\n  width: 8px;\n  height: 8px;\n}\n\n::-webkit-scrollbar-track {\n  background: #f1f1f1;\n}\n\n::-webkit-scrollbar-thumb {\n  background: #000000;\n}\n\n::-webkit-scrollbar-thumb:hover {\n  background: #333333;\n}\n\n/* 全局按钮样式 */\nbutton {\n  font-family: inherit;\n}\n</style>\n"
  },
  {
    "path": "frontend/src/api/graph.js",
    "content": "import service, { requestWithRetry } from './index'\n\n/**\n * 生成本体（上传文档和模拟需求）\n * @param {Object} data - 包含files, simulation_requirement, project_name等\n * @returns {Promise}\n */\nexport function generateOntology(formData) {\n  return requestWithRetry(() => \n    service({\n      url: '/api/graph/ontology/generate',\n      method: 'post',\n      data: formData,\n      headers: {\n        'Content-Type': 'multipart/form-data'\n      }\n    })\n  )\n}\n\n/**\n * 构建图谱\n * @param {Object} data - 包含project_id, graph_name等\n * @returns {Promise}\n */\nexport function buildGraph(data) {\n  return requestWithRetry(() =>\n    service({\n      url: '/api/graph/build',\n      method: 'post',\n      data\n    })\n  )\n}\n\n/**\n * 查询任务状态\n * @param {String} taskId - 任务ID\n * @returns {Promise}\n */\nexport function getTaskStatus(taskId) {\n  return service({\n    url: `/api/graph/task/${taskId}`,\n    method: 'get'\n  })\n}\n\n/**\n * 获取图谱数据\n * @param {String} graphId - 图谱ID\n * @returns {Promise}\n */\nexport function getGraphData(graphId) {\n  return service({\n    url: `/api/graph/data/${graphId}`,\n    method: 'get'\n  })\n}\n\n/**\n * 获取项目信息\n * @param {String} projectId - 项目ID\n * @returns {Promise}\n */\nexport function getProject(projectId) {\n  return service({\n    url: `/api/graph/project/${projectId}`,\n    method: 'get'\n  })\n}\n"
  },
  {
    "path": "frontend/src/api/index.js",
    "content": "import axios from 'axios'\n\n// 创建axios实例\nconst service = axios.create({\n  baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:5001',\n  timeout: 300000, // 5分钟超时（本体生成可能需要较长时间）\n  headers: {\n    'Content-Type': 'application/json'\n  }\n})\n\n// 请求拦截器\nservice.interceptors.request.use(\n  config => {\n    return config\n  },\n  error => {\n    console.error('Request error:', error)\n    return Promise.reject(error)\n  }\n)\n\n// 响应拦截器（容错重试机制）\nservice.interceptors.response.use(\n  response => {\n    const res = response.data\n    \n    // 如果返回的状态码不是success，则抛出错误\n    if (!res.success && res.success !== undefined) {\n      console.error('API Error:', res.error || res.message || 'Unknown error')\n      return Promise.reject(new Error(res.error || res.message || 'Error'))\n    }\n    \n    return res\n  },\n  error => {\n    console.error('Response error:', error)\n    \n    // 处理超时\n    if (error.code === 'ECONNABORTED' && error.message.includes('timeout')) {\n      console.error('Request timeout')\n    }\n    \n    // 处理网络错误\n    if (error.message === 'Network Error') {\n      console.error('Network error - please check your connection')\n    }\n    \n    return Promise.reject(error)\n  }\n)\n\n// 带重试的请求函数\nexport const requestWithRetry = async (requestFn, maxRetries = 3, delay = 1000) => {\n  for (let i = 0; i < maxRetries; i++) {\n    try {\n      return await requestFn()\n    } catch (error) {\n      if (i === maxRetries - 1) throw error\n      \n      console.warn(`Request failed, retrying (${i + 1}/${maxRetries})...`)\n      await new Promise(resolve => setTimeout(resolve, delay * Math.pow(2, i)))\n    }\n  }\n}\n\nexport default service\n"
  },
  {
    "path": "frontend/src/api/report.js",
    "content": "import service, { requestWithRetry } from './index'\n\n/**\n * 开始报告生成\n * @param {Object} data - { simulation_id, force_regenerate? }\n */\nexport const generateReport = (data) => {\n  return requestWithRetry(() => service.post('/api/report/generate', data), 3, 1000)\n}\n\n/**\n * 获取报告生成状态\n * @param {string} reportId\n */\nexport const getReportStatus = (reportId) => {\n  return service.get(`/api/report/generate/status`, { params: { report_id: reportId } })\n}\n\n/**\n * 获取 Agent 日志（增量）\n * @param {string} reportId\n * @param {number} fromLine - 从第几行开始获取\n */\nexport const getAgentLog = (reportId, fromLine = 0) => {\n  return service.get(`/api/report/${reportId}/agent-log`, { params: { from_line: fromLine } })\n}\n\n/**\n * 获取控制台日志（增量）\n * @param {string} reportId\n * @param {number} fromLine - 从第几行开始获取\n */\nexport const getConsoleLog = (reportId, fromLine = 0) => {\n  return service.get(`/api/report/${reportId}/console-log`, { params: { from_line: fromLine } })\n}\n\n/**\n * 获取报告详情\n * @param {string} reportId\n */\nexport const getReport = (reportId) => {\n  return service.get(`/api/report/${reportId}`)\n}\n\n/**\n * 与 Report Agent 对话\n * @param {Object} data - { simulation_id, message, chat_history? }\n */\nexport const chatWithReport = (data) => {\n  return requestWithRetry(() => service.post('/api/report/chat', data), 3, 1000)\n}\n"
  },
  {
    "path": "frontend/src/api/simulation.js",
    "content": "import service, { requestWithRetry } from './index'\n\n/**\n * 创建模拟\n * @param {Object} data - { project_id, graph_id?, enable_twitter?, enable_reddit? }\n */\nexport const createSimulation = (data) => {\n  return requestWithRetry(() => service.post('/api/simulation/create', data), 3, 1000)\n}\n\n/**\n * 准备模拟环境（异步任务）\n * @param {Object} data - { simulation_id, entity_types?, use_llm_for_profiles?, parallel_profile_count?, force_regenerate? }\n */\nexport const prepareSimulation = (data) => {\n  return requestWithRetry(() => service.post('/api/simulation/prepare', data), 3, 1000)\n}\n\n/**\n * 查询准备任务进度\n * @param {Object} data - { task_id?, simulation_id? }\n */\nexport const getPrepareStatus = (data) => {\n  return service.post('/api/simulation/prepare/status', data)\n}\n\n/**\n * 获取模拟状态\n * @param {string} simulationId\n */\nexport const getSimulation = (simulationId) => {\n  return service.get(`/api/simulation/${simulationId}`)\n}\n\n/**\n * 获取模拟的 Agent Profiles\n * @param {string} simulationId\n * @param {string} platform - 'reddit' | 'twitter'\n */\nexport const getSimulationProfiles = (simulationId, platform = 'reddit') => {\n  return service.get(`/api/simulation/${simulationId}/profiles`, { params: { platform } })\n}\n\n/**\n * 实时获取生成中的 Agent Profiles\n * @param {string} simulationId\n * @param {string} platform - 'reddit' | 'twitter'\n */\nexport const getSimulationProfilesRealtime = (simulationId, platform = 'reddit') => {\n  return service.get(`/api/simulation/${simulationId}/profiles/realtime`, { params: { platform } })\n}\n\n/**\n * 获取模拟配置\n * @param {string} simulationId\n */\nexport const getSimulationConfig = (simulationId) => {\n  return service.get(`/api/simulation/${simulationId}/config`)\n}\n\n/**\n * 实时获取生成中的模拟配置\n * @param {string} simulationId\n * @returns {Promise} 返回配置信息，包含元数据和配置内容\n */\nexport const getSimulationConfigRealtime = (simulationId) => {\n  return service.get(`/api/simulation/${simulationId}/config/realtime`)\n}\n\n/**\n * 列出所有模拟\n * @param {string} projectId - 可选，按项目ID过滤\n */\nexport const listSimulations = (projectId) => {\n  const params = projectId ? { project_id: projectId } : {}\n  return service.get('/api/simulation/list', { params })\n}\n\n/**\n * 启动模拟\n * @param {Object} data - { simulation_id, platform?, max_rounds?, enable_graph_memory_update? }\n */\nexport const startSimulation = (data) => {\n  return requestWithRetry(() => service.post('/api/simulation/start', data), 3, 1000)\n}\n\n/**\n * 停止模拟\n * @param {Object} data - { simulation_id }\n */\nexport const stopSimulation = (data) => {\n  return service.post('/api/simulation/stop', data)\n}\n\n/**\n * 获取模拟运行实时状态\n * @param {string} simulationId\n */\nexport const getRunStatus = (simulationId) => {\n  return service.get(`/api/simulation/${simulationId}/run-status`)\n}\n\n/**\n * 获取模拟运行详细状态（包含最近动作）\n * @param {string} simulationId\n */\nexport const getRunStatusDetail = (simulationId) => {\n  return service.get(`/api/simulation/${simulationId}/run-status/detail`)\n}\n\n/**\n * 获取模拟中的帖子\n * @param {string} simulationId\n * @param {string} platform - 'reddit' | 'twitter'\n * @param {number} limit - 返回数量\n * @param {number} offset - 偏移量\n */\nexport const getSimulationPosts = (simulationId, platform = 'reddit', limit = 50, offset = 0) => {\n  return service.get(`/api/simulation/${simulationId}/posts`, {\n    params: { platform, limit, offset }\n  })\n}\n\n/**\n * 获取模拟时间线（按轮次汇总）\n * @param {string} simulationId\n * @param {number} startRound - 起始轮次\n * @param {number} endRound - 结束轮次\n */\nexport const getSimulationTimeline = (simulationId, startRound = 0, endRound = null) => {\n  const params = { start_round: startRound }\n  if (endRound !== null) {\n    params.end_round = endRound\n  }\n  return service.get(`/api/simulation/${simulationId}/timeline`, { params })\n}\n\n/**\n * 获取Agent统计信息\n * @param {string} simulationId\n */\nexport const getAgentStats = (simulationId) => {\n  return service.get(`/api/simulation/${simulationId}/agent-stats`)\n}\n\n/**\n * 获取模拟动作历史\n * @param {string} simulationId\n * @param {Object} params - { limit, offset, platform, agent_id, round_num }\n */\nexport const getSimulationActions = (simulationId, params = {}) => {\n  return service.get(`/api/simulation/${simulationId}/actions`, { params })\n}\n\n/**\n * 关闭模拟环境（优雅退出）\n * @param {Object} data - { simulation_id, timeout? }\n */\nexport const closeSimulationEnv = (data) => {\n  return service.post('/api/simulation/close-env', data)\n}\n\n/**\n * 获取模拟环境状态\n * @param {Object} data - { simulation_id }\n */\nexport const getEnvStatus = (data) => {\n  return service.post('/api/simulation/env-status', data)\n}\n\n/**\n * 批量采访 Agent\n * @param {Object} data - { simulation_id, interviews: [{ agent_id, prompt }] }\n */\nexport const interviewAgents = (data) => {\n  return requestWithRetry(() => service.post('/api/simulation/interview/batch', data), 3, 1000)\n}\n\n/**\n * 获取历史模拟列表（带项目详情）\n * 用于首页历史项目展示\n * @param {number} limit - 返回数量限制\n */\nexport const getSimulationHistory = (limit = 20) => {\n  return service.get('/api/simulation/history', { params: { limit } })\n}\n\n"
  },
  {
    "path": "frontend/src/components/GraphPanel.vue",
    "content": "<template>\n  <div class=\"graph-panel\">\n    <div class=\"panel-header\">\n      <span class=\"panel-title\">Graph Relationship Visualization</span>\n      <!-- 顶部工具栏 (Internal Top Right) -->\n      <div class=\"header-tools\">\n        <button class=\"tool-btn\" @click=\"$emit('refresh')\" :disabled=\"loading\" title=\"刷新图谱\">\n          <span class=\"icon-refresh\" :class=\"{ 'spinning': loading }\">↻</span>\n          <span class=\"btn-text\">Refresh</span>\n        </button>\n        <button class=\"tool-btn\" @click=\"$emit('toggle-maximize')\" title=\"最大化/还原\">\n          <span class=\"icon-maximize\">⛶</span>\n        </button>\n      </div>\n    </div>\n    \n    <div class=\"graph-container\" ref=\"graphContainer\">\n      <!-- 图谱可视化 -->\n      <div v-if=\"graphData\" class=\"graph-view\">\n        <svg ref=\"graphSvg\" class=\"graph-svg\"></svg>\n        \n        <!-- 构建中/模拟中提示 -->\n        <div v-if=\"currentPhase === 1 || isSimulating\" class=\"graph-building-hint\">\n          <div class=\"memory-icon-wrapper\">\n            <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" class=\"memory-icon\">\n              <path d=\"M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96.44 2.5 2.5 0 0 1-2.96-3.08 3 3 0 0 1-.34-5.58 2.5 2.5 0 0 1 1.32-4.24 2.5 2.5 0 0 1 4.44-4.04z\" />\n              <path d=\"M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96.44 2.5 2.5 0 0 0 2.96-3.08 3 3 0 0 0 .34-5.58 2.5 2.5 0 0 0-1.32-4.24 2.5 2.5 0 0 0-4.44-4.04z\" />\n            </svg>\n          </div>\n          {{ isSimulating ? 'GraphRAG长短期记忆实时更新中' : '实时更新中...' }}\n        </div>\n        \n        <!-- 模拟结束后的提示 -->\n        <div v-if=\"showSimulationFinishedHint\" class=\"graph-building-hint finished-hint\">\n          <div class=\"hint-icon-wrapper\">\n            <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" class=\"hint-icon\">\n              <circle cx=\"12\" cy=\"12\" r=\"10\"></circle>\n              <line x1=\"12\" y1=\"16\" x2=\"12\" y2=\"12\"></line>\n              <line x1=\"12\" y1=\"8\" x2=\"12.01\" y2=\"8\"></line>\n            </svg>\n          </div>\n          <span class=\"hint-text\">还有少量内容处理中，建议稍后手动刷新图谱</span>\n          <button class=\"hint-close-btn\" @click=\"dismissFinishedHint\" title=\"关闭提示\">\n            <svg viewBox=\"0 0 24 24\" width=\"14\" height=\"14\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n              <line x1=\"18\" y1=\"6\" x2=\"6\" y2=\"18\"></line>\n              <line x1=\"6\" y1=\"6\" x2=\"18\" y2=\"18\"></line>\n            </svg>\n          </button>\n        </div>\n        \n        <!-- 节点/边详情面板 -->\n        <div v-if=\"selectedItem\" class=\"detail-panel\">\n          <div class=\"detail-panel-header\">\n            <span class=\"detail-title\">{{ selectedItem.type === 'node' ? 'Node Details' : 'Relationship' }}</span>\n            <span v-if=\"selectedItem.type === 'node'\" class=\"detail-type-badge\" :style=\"{ background: selectedItem.color, color: '#fff' }\">\n              {{ selectedItem.entityType }}\n            </span>\n            <button class=\"detail-close\" @click=\"closeDetailPanel\">×</button>\n          </div>\n          \n          <!-- 节点详情 -->\n          <div v-if=\"selectedItem.type === 'node'\" class=\"detail-content\">\n            <div class=\"detail-row\">\n              <span class=\"detail-label\">Name:</span>\n              <span class=\"detail-value\">{{ selectedItem.data.name }}</span>\n            </div>\n            <div class=\"detail-row\">\n              <span class=\"detail-label\">UUID:</span>\n              <span class=\"detail-value uuid-text\">{{ selectedItem.data.uuid }}</span>\n            </div>\n            <div class=\"detail-row\" v-if=\"selectedItem.data.created_at\">\n              <span class=\"detail-label\">Created:</span>\n              <span class=\"detail-value\">{{ formatDateTime(selectedItem.data.created_at) }}</span>\n            </div>\n            \n            <!-- Properties -->\n            <div class=\"detail-section\" v-if=\"selectedItem.data.attributes && Object.keys(selectedItem.data.attributes).length > 0\">\n              <div class=\"section-title\">Properties:</div>\n              <div class=\"properties-list\">\n                <div v-for=\"(value, key) in selectedItem.data.attributes\" :key=\"key\" class=\"property-item\">\n                  <span class=\"property-key\">{{ key }}:</span>\n                  <span class=\"property-value\">{{ value || 'None' }}</span>\n                </div>\n              </div>\n            </div>\n            \n            <!-- Summary -->\n            <div class=\"detail-section\" v-if=\"selectedItem.data.summary\">\n              <div class=\"section-title\">Summary:</div>\n              <div class=\"summary-text\">{{ selectedItem.data.summary }}</div>\n            </div>\n            \n            <!-- Labels -->\n            <div class=\"detail-section\" v-if=\"selectedItem.data.labels && selectedItem.data.labels.length > 0\">\n              <div class=\"section-title\">Labels:</div>\n              <div class=\"labels-list\">\n                <span v-for=\"label in selectedItem.data.labels\" :key=\"label\" class=\"label-tag\">\n                  {{ label }}\n                </span>\n              </div>\n            </div>\n          </div>\n          \n          <!-- 边详情 -->\n          <div v-else class=\"detail-content\">\n            <!-- 自环组详情 -->\n            <template v-if=\"selectedItem.data.isSelfLoopGroup\">\n              <div class=\"edge-relation-header self-loop-header\">\n                {{ selectedItem.data.source_name }} - Self Relations\n                <span class=\"self-loop-count\">{{ selectedItem.data.selfLoopCount }} items</span>\n              </div>\n              \n              <div class=\"self-loop-list\">\n                <div \n                  v-for=\"(loop, idx) in selectedItem.data.selfLoopEdges\" \n                  :key=\"loop.uuid || idx\" \n                  class=\"self-loop-item\"\n                  :class=\"{ expanded: expandedSelfLoops.has(loop.uuid || idx) }\"\n                >\n                  <div \n                    class=\"self-loop-item-header\"\n                    @click=\"toggleSelfLoop(loop.uuid || idx)\"\n                  >\n                    <span class=\"self-loop-index\">#{{ idx + 1 }}</span>\n                    <span class=\"self-loop-name\">{{ loop.name || loop.fact_type || 'RELATED' }}</span>\n                    <span class=\"self-loop-toggle\">{{ expandedSelfLoops.has(loop.uuid || idx) ? '−' : '+' }}</span>\n                  </div>\n                  \n                  <div class=\"self-loop-item-content\" v-show=\"expandedSelfLoops.has(loop.uuid || idx)\">\n                    <div class=\"detail-row\" v-if=\"loop.uuid\">\n                      <span class=\"detail-label\">UUID:</span>\n                      <span class=\"detail-value uuid-text\">{{ loop.uuid }}</span>\n                    </div>\n                    <div class=\"detail-row\" v-if=\"loop.fact\">\n                      <span class=\"detail-label\">Fact:</span>\n                      <span class=\"detail-value fact-text\">{{ loop.fact }}</span>\n                    </div>\n                    <div class=\"detail-row\" v-if=\"loop.fact_type\">\n                      <span class=\"detail-label\">Type:</span>\n                      <span class=\"detail-value\">{{ loop.fact_type }}</span>\n                    </div>\n                    <div class=\"detail-row\" v-if=\"loop.created_at\">\n                      <span class=\"detail-label\">Created:</span>\n                      <span class=\"detail-value\">{{ formatDateTime(loop.created_at) }}</span>\n                    </div>\n                    <div v-if=\"loop.episodes && loop.episodes.length > 0\" class=\"self-loop-episodes\">\n                      <span class=\"detail-label\">Episodes:</span>\n                      <div class=\"episodes-list compact\">\n                        <span v-for=\"ep in loop.episodes\" :key=\"ep\" class=\"episode-tag small\">{{ ep }}</span>\n                      </div>\n                    </div>\n                  </div>\n                </div>\n              </div>\n            </template>\n            \n            <!-- 普通边详情 -->\n            <template v-else>\n              <div class=\"edge-relation-header\">\n                {{ selectedItem.data.source_name }} → {{ selectedItem.data.name || 'RELATED_TO' }} → {{ selectedItem.data.target_name }}\n              </div>\n              \n              <div class=\"detail-row\">\n                <span class=\"detail-label\">UUID:</span>\n                <span class=\"detail-value uuid-text\">{{ selectedItem.data.uuid }}</span>\n              </div>\n              <div class=\"detail-row\">\n                <span class=\"detail-label\">Label:</span>\n                <span class=\"detail-value\">{{ selectedItem.data.name || 'RELATED_TO' }}</span>\n              </div>\n              <div class=\"detail-row\">\n                <span class=\"detail-label\">Type:</span>\n                <span class=\"detail-value\">{{ selectedItem.data.fact_type || 'Unknown' }}</span>\n              </div>\n              <div class=\"detail-row\" v-if=\"selectedItem.data.fact\">\n                <span class=\"detail-label\">Fact:</span>\n                <span class=\"detail-value fact-text\">{{ selectedItem.data.fact }}</span>\n              </div>\n              \n              <!-- Episodes -->\n              <div class=\"detail-section\" v-if=\"selectedItem.data.episodes && selectedItem.data.episodes.length > 0\">\n                <div class=\"section-title\">Episodes:</div>\n                <div class=\"episodes-list\">\n                  <span v-for=\"ep in selectedItem.data.episodes\" :key=\"ep\" class=\"episode-tag\">\n                    {{ ep }}\n                  </span>\n                </div>\n              </div>\n              \n              <div class=\"detail-row\" v-if=\"selectedItem.data.created_at\">\n                <span class=\"detail-label\">Created:</span>\n                <span class=\"detail-value\">{{ formatDateTime(selectedItem.data.created_at) }}</span>\n              </div>\n              <div class=\"detail-row\" v-if=\"selectedItem.data.valid_at\">\n                <span class=\"detail-label\">Valid From:</span>\n                <span class=\"detail-value\">{{ formatDateTime(selectedItem.data.valid_at) }}</span>\n              </div>\n            </template>\n          </div>\n        </div>\n      </div>\n      \n      <!-- 加载状态 -->\n      <div v-else-if=\"loading\" class=\"graph-state\">\n        <div class=\"loading-spinner\"></div>\n        <p>图谱数据加载中...</p>\n      </div>\n      \n      <!-- 等待/空状态 -->\n      <div v-else class=\"graph-state\">\n        <div class=\"empty-icon\">❖</div>\n        <p class=\"empty-text\">等待本体生成...</p>\n      </div>\n    </div>\n\n    <!-- 底部图例 (Bottom Left) -->\n    <div v-if=\"graphData && entityTypes.length\" class=\"graph-legend\">\n      <span class=\"legend-title\">Entity Types</span>\n      <div class=\"legend-items\">\n        <div class=\"legend-item\" v-for=\"type in entityTypes\" :key=\"type.name\">\n          <span class=\"legend-dot\" :style=\"{ background: type.color }\"></span>\n          <span class=\"legend-label\">{{ type.name }}</span>\n        </div>\n      </div>\n    </div>\n    \n    <!-- 显示边标签开关 -->\n    <div v-if=\"graphData\" class=\"edge-labels-toggle\">\n      <label class=\"toggle-switch\">\n        <input type=\"checkbox\" v-model=\"showEdgeLabels\" />\n        <span class=\"slider\"></span>\n      </label>\n      <span class=\"toggle-label\">Show Edge Labels</span>\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue'\nimport * as d3 from 'd3'\n\nconst props = defineProps({\n  graphData: Object,\n  loading: Boolean,\n  currentPhase: Number,\n  isSimulating: Boolean\n})\n\nconst emit = defineEmits(['refresh', 'toggle-maximize'])\n\nconst graphContainer = ref(null)\nconst graphSvg = ref(null)\nconst selectedItem = ref(null)\nconst showEdgeLabels = ref(true) // 默认显示边标签\nconst expandedSelfLoops = ref(new Set()) // 展开的自环项\nconst showSimulationFinishedHint = ref(false) // 模拟结束后的提示\nconst wasSimulating = ref(false) // 追踪之前是否在模拟中\n\n// 关闭模拟结束提示\nconst dismissFinishedHint = () => {\n  showSimulationFinishedHint.value = false\n}\n\n// 监听 isSimulating 变化，检测模拟结束\nwatch(() => props.isSimulating, (newValue, oldValue) => {\n  if (wasSimulating.value && !newValue) {\n    // 从模拟中变为非模拟状态，显示结束提示\n    showSimulationFinishedHint.value = true\n  }\n  wasSimulating.value = newValue\n}, { immediate: true })\n\n// 切换自环项展开/折叠状态\nconst toggleSelfLoop = (id) => {\n  const newSet = new Set(expandedSelfLoops.value)\n  if (newSet.has(id)) {\n    newSet.delete(id)\n  } else {\n    newSet.add(id)\n  }\n  expandedSelfLoops.value = newSet\n}\n\n// 计算实体类型用于图例\nconst entityTypes = computed(() => {\n  if (!props.graphData?.nodes) return []\n  const typeMap = {}\n  // 美观的颜色调色板\n  const colors = ['#FF6B35', '#004E89', '#7B2D8E', '#1A936F', '#C5283D', '#E9724C', '#3498db', '#9b59b6', '#27ae60', '#f39c12']\n  \n  props.graphData.nodes.forEach(node => {\n    const type = node.labels?.find(l => l !== 'Entity') || 'Entity'\n    if (!typeMap[type]) {\n      typeMap[type] = { name: type, count: 0, color: colors[Object.keys(typeMap).length % colors.length] }\n    }\n    typeMap[type].count++\n  })\n  return Object.values(typeMap)\n})\n\n// 格式化时间\nconst formatDateTime = (dateStr) => {\n  if (!dateStr) return ''\n  try {\n    const date = new Date(dateStr)\n    return date.toLocaleString('en-US', { \n      month: 'short', \n      day: 'numeric', \n      year: 'numeric',\n      hour: 'numeric',\n      minute: '2-digit',\n      hour12: true \n    })\n  } catch {\n    return dateStr\n  }\n}\n\nconst closeDetailPanel = () => {\n  selectedItem.value = null\n  expandedSelfLoops.value = new Set() // 重置展开状态\n}\n\nlet currentSimulation = null\nlet linkLabelsRef = null\nlet linkLabelBgRef = null\n\nconst renderGraph = () => {\n  if (!graphSvg.value || !props.graphData) return\n  \n  // 停止之前的仿真\n  if (currentSimulation) {\n    currentSimulation.stop()\n  }\n  \n  const container = graphContainer.value\n  const width = container.clientWidth\n  const height = container.clientHeight\n  \n  const svg = d3.select(graphSvg.value)\n    .attr('width', width)\n    .attr('height', height)\n    .attr('viewBox', `0 0 ${width} ${height}`)\n    \n  svg.selectAll('*').remove()\n  \n  const nodesData = props.graphData.nodes || []\n  const edgesData = props.graphData.edges || []\n  \n  if (nodesData.length === 0) return\n\n  // Prep data\n  const nodeMap = {}\n  nodesData.forEach(n => nodeMap[n.uuid] = n)\n  \n  const nodes = nodesData.map(n => ({\n    id: n.uuid,\n    name: n.name || 'Unnamed',\n    type: n.labels?.find(l => l !== 'Entity') || 'Entity',\n    rawData: n\n  }))\n  \n  const nodeIds = new Set(nodes.map(n => n.id))\n  \n  // 处理边数据，计算同一对节点间的边数量和索引\n  const edgePairCount = {}\n  const selfLoopEdges = {} // 按节点分组的自环边\n  const tempEdges = edgesData\n    .filter(e => nodeIds.has(e.source_node_uuid) && nodeIds.has(e.target_node_uuid))\n  \n  // 统计每对节点之间的边数量，收集自环边\n  tempEdges.forEach(e => {\n    if (e.source_node_uuid === e.target_node_uuid) {\n      // 自环 - 收集到数组中\n      if (!selfLoopEdges[e.source_node_uuid]) {\n        selfLoopEdges[e.source_node_uuid] = []\n      }\n      selfLoopEdges[e.source_node_uuid].push({\n        ...e,\n        source_name: nodeMap[e.source_node_uuid]?.name,\n        target_name: nodeMap[e.target_node_uuid]?.name\n      })\n    } else {\n      const pairKey = [e.source_node_uuid, e.target_node_uuid].sort().join('_')\n      edgePairCount[pairKey] = (edgePairCount[pairKey] || 0) + 1\n    }\n  })\n  \n  // 记录当前处理到每对节点的第几条边\n  const edgePairIndex = {}\n  const processedSelfLoopNodes = new Set() // 已处理的自环节点\n  \n  const edges = []\n  \n  tempEdges.forEach(e => {\n    const isSelfLoop = e.source_node_uuid === e.target_node_uuid\n    \n    if (isSelfLoop) {\n      // 自环边 - 每个节点只添加一条合并的自环\n      if (processedSelfLoopNodes.has(e.source_node_uuid)) {\n        return // 已处理过，跳过\n      }\n      processedSelfLoopNodes.add(e.source_node_uuid)\n      \n      const allSelfLoops = selfLoopEdges[e.source_node_uuid]\n      const nodeName = nodeMap[e.source_node_uuid]?.name || 'Unknown'\n      \n      edges.push({\n        source: e.source_node_uuid,\n        target: e.target_node_uuid,\n        type: 'SELF_LOOP',\n        name: `Self Relations (${allSelfLoops.length})`,\n        curvature: 0,\n        isSelfLoop: true,\n        rawData: {\n          isSelfLoopGroup: true,\n          source_name: nodeName,\n          target_name: nodeName,\n          selfLoopCount: allSelfLoops.length,\n          selfLoopEdges: allSelfLoops // 存储所有自环边的详细信息\n        }\n      })\n      return\n    }\n    \n    const pairKey = [e.source_node_uuid, e.target_node_uuid].sort().join('_')\n    const totalCount = edgePairCount[pairKey]\n    const currentIndex = edgePairIndex[pairKey] || 0\n    edgePairIndex[pairKey] = currentIndex + 1\n    \n    // 判断边的方向是否与标准化方向一致（源UUID < 目标UUID）\n    const isReversed = e.source_node_uuid > e.target_node_uuid\n    \n    // 计算曲率：多条边时分散开，单条边为直线\n    let curvature = 0\n    if (totalCount > 1) {\n      // 均匀分布曲率，确保明显区分\n      // 曲率范围根据边数量增加，边越多曲率范围越大\n      const curvatureRange = Math.min(1.2, 0.6 + totalCount * 0.15)\n      curvature = ((currentIndex / (totalCount - 1)) - 0.5) * curvatureRange * 2\n      \n      // 如果边的方向与标准化方向相反，翻转曲率\n      // 这样确保所有边在同一参考系下分布，不会因方向不同而重叠\n      if (isReversed) {\n        curvature = -curvature\n      }\n    }\n    \n    edges.push({\n      source: e.source_node_uuid,\n      target: e.target_node_uuid,\n      type: e.fact_type || e.name || 'RELATED',\n      name: e.name || e.fact_type || 'RELATED',\n      curvature,\n      isSelfLoop: false,\n      pairIndex: currentIndex,\n      pairTotal: totalCount,\n      rawData: {\n        ...e,\n        source_name: nodeMap[e.source_node_uuid]?.name,\n        target_name: nodeMap[e.target_node_uuid]?.name\n      }\n    })\n  })\n    \n  // Color scale\n  const colorMap = {}\n  entityTypes.value.forEach(t => colorMap[t.name] = t.color)\n  const getColor = (type) => colorMap[type] || '#999'\n\n  // Simulation - 根据边数量动态调整节点间距\n  const simulation = d3.forceSimulation(nodes)\n    .force('link', d3.forceLink(edges).id(d => d.id).distance(d => {\n      // 根据这对节点之间的边数量动态调整距离\n      // 基础距离 150，每多一条边增加 40\n      const baseDistance = 150\n      const edgeCount = d.pairTotal || 1\n      return baseDistance + (edgeCount - 1) * 50\n    }))\n    .force('charge', d3.forceManyBody().strength(-400))\n    .force('center', d3.forceCenter(width / 2, height / 2))\n    .force('collide', d3.forceCollide(50))\n    // 添加向中心的引力，让独立的节点群聚集到中心区域\n    .force('x', d3.forceX(width / 2).strength(0.04))\n    .force('y', d3.forceY(height / 2).strength(0.04))\n  \n  currentSimulation = simulation\n\n  const g = svg.append('g')\n  \n  // Zoom\n  svg.call(d3.zoom().extent([[0, 0], [width, height]]).scaleExtent([0.1, 4]).on('zoom', (event) => {\n    g.attr('transform', event.transform)\n  }))\n\n  // Links - 使用 path 支持曲线\n  const linkGroup = g.append('g').attr('class', 'links')\n  \n  // 计算曲线路径\n  const getLinkPath = (d) => {\n    const sx = d.source.x, sy = d.source.y\n    const tx = d.target.x, ty = d.target.y\n    \n    // 检测自环\n    if (d.isSelfLoop) {\n      // 自环：绘制一个圆弧从节点出发再返回\n      const loopRadius = 30\n      // 从节点右侧出发，绕一圈回来\n      const x1 = sx + 8  // 起点偏移\n      const y1 = sy - 4\n      const x2 = sx + 8  // 终点偏移\n      const y2 = sy + 4\n      // 使用圆弧绘制自环（sweep-flag=1 顺时针）\n      return `M${x1},${y1} A${loopRadius},${loopRadius} 0 1,1 ${x2},${y2}`\n    }\n    \n    if (d.curvature === 0) {\n      // 直线\n      return `M${sx},${sy} L${tx},${ty}`\n    }\n    \n    // 计算曲线控制点 - 根据边数量和距离动态调整\n    const dx = tx - sx, dy = ty - sy\n    const dist = Math.sqrt(dx * dx + dy * dy)\n    // 垂直于连线方向的偏移，根据距离比例计算，保证曲线明显可见\n    // 边越多，偏移量占距离的比例越大\n    const pairTotal = d.pairTotal || 1\n    const offsetRatio = 0.25 + pairTotal * 0.05 // 基础25%，每多一条边增加5%\n    const baseOffset = Math.max(35, dist * offsetRatio)\n    const offsetX = -dy / dist * d.curvature * baseOffset\n    const offsetY = dx / dist * d.curvature * baseOffset\n    const cx = (sx + tx) / 2 + offsetX\n    const cy = (sy + ty) / 2 + offsetY\n    \n    return `M${sx},${sy} Q${cx},${cy} ${tx},${ty}`\n  }\n  \n  // 计算曲线中点（用于标签定位）\n  const getLinkMidpoint = (d) => {\n    const sx = d.source.x, sy = d.source.y\n    const tx = d.target.x, ty = d.target.y\n    \n    // 检测自环\n    if (d.isSelfLoop) {\n      // 自环标签位置：节点右侧\n      return { x: sx + 70, y: sy }\n    }\n    \n    if (d.curvature === 0) {\n      return { x: (sx + tx) / 2, y: (sy + ty) / 2 }\n    }\n    \n    // 二次贝塞尔曲线的中点 t=0.5\n    const dx = tx - sx, dy = ty - sy\n    const dist = Math.sqrt(dx * dx + dy * dy)\n    const pairTotal = d.pairTotal || 1\n    const offsetRatio = 0.25 + pairTotal * 0.05\n    const baseOffset = Math.max(35, dist * offsetRatio)\n    const offsetX = -dy / dist * d.curvature * baseOffset\n    const offsetY = dx / dist * d.curvature * baseOffset\n    const cx = (sx + tx) / 2 + offsetX\n    const cy = (sy + ty) / 2 + offsetY\n    \n    // 二次贝塞尔曲线公式 B(t) = (1-t)²P0 + 2(1-t)tP1 + t²P2, t=0.5\n    const midX = 0.25 * sx + 0.5 * cx + 0.25 * tx\n    const midY = 0.25 * sy + 0.5 * cy + 0.25 * ty\n    \n    return { x: midX, y: midY }\n  }\n  \n  const link = linkGroup.selectAll('path')\n    .data(edges)\n    .enter().append('path')\n    .attr('stroke', '#C0C0C0')\n    .attr('stroke-width', 1.5)\n    .attr('fill', 'none')\n    .style('cursor', 'pointer')\n    .on('click', (event, d) => {\n      event.stopPropagation()\n      // 重置之前选中边的样式\n      linkGroup.selectAll('path').attr('stroke', '#C0C0C0').attr('stroke-width', 1.5)\n      linkLabelBg.attr('fill', 'rgba(255,255,255,0.95)')\n      linkLabels.attr('fill', '#666')\n      // 高亮当前选中的边\n      d3.select(event.target).attr('stroke', '#3498db').attr('stroke-width', 3)\n      \n      selectedItem.value = {\n        type: 'edge',\n        data: d.rawData\n      }\n    })\n\n  // Link labels background (白色背景使文字更清晰)\n  const linkLabelBg = linkGroup.selectAll('rect')\n    .data(edges)\n    .enter().append('rect')\n    .attr('fill', 'rgba(255,255,255,0.95)')\n    .attr('rx', 3)\n    .attr('ry', 3)\n    .style('cursor', 'pointer')\n    .style('pointer-events', 'all')\n    .style('display', showEdgeLabels.value ? 'block' : 'none')\n    .on('click', (event, d) => {\n      event.stopPropagation()\n      linkGroup.selectAll('path').attr('stroke', '#C0C0C0').attr('stroke-width', 1.5)\n      linkLabelBg.attr('fill', 'rgba(255,255,255,0.95)')\n      linkLabels.attr('fill', '#666')\n      // 高亮对应的边\n      link.filter(l => l === d).attr('stroke', '#3498db').attr('stroke-width', 3)\n      d3.select(event.target).attr('fill', 'rgba(52, 152, 219, 0.1)')\n      \n      selectedItem.value = {\n        type: 'edge',\n        data: d.rawData\n      }\n    })\n\n  // Link labels\n  const linkLabels = linkGroup.selectAll('text')\n    .data(edges)\n    .enter().append('text')\n    .text(d => d.name)\n    .attr('font-size', '9px')\n    .attr('fill', '#666')\n    .attr('text-anchor', 'middle')\n    .attr('dominant-baseline', 'middle')\n    .style('cursor', 'pointer')\n    .style('pointer-events', 'all')\n    .style('font-family', 'system-ui, sans-serif')\n    .style('display', showEdgeLabels.value ? 'block' : 'none')\n    .on('click', (event, d) => {\n      event.stopPropagation()\n      linkGroup.selectAll('path').attr('stroke', '#C0C0C0').attr('stroke-width', 1.5)\n      linkLabelBg.attr('fill', 'rgba(255,255,255,0.95)')\n      linkLabels.attr('fill', '#666')\n      // 高亮对应的边\n      link.filter(l => l === d).attr('stroke', '#3498db').attr('stroke-width', 3)\n      d3.select(event.target).attr('fill', '#3498db')\n      \n      selectedItem.value = {\n        type: 'edge',\n        data: d.rawData\n      }\n    })\n  \n  // 保存引用供外部控制显隐\n  linkLabelsRef = linkLabels\n  linkLabelBgRef = linkLabelBg\n\n  // Nodes group\n  const nodeGroup = g.append('g').attr('class', 'nodes')\n  \n  // Node circles\n  const node = nodeGroup.selectAll('circle')\n    .data(nodes)\n    .enter().append('circle')\n    .attr('r', 10)\n    .attr('fill', d => getColor(d.type))\n    .attr('stroke', '#fff')\n    .attr('stroke-width', 2.5)\n    .style('cursor', 'pointer')\n    .call(d3.drag()\n      .on('start', (event, d) => {\n        // 只记录位置，不重启仿真（区分点击和拖拽）\n        d.fx = d.x\n        d.fy = d.y\n        d._dragStartX = event.x\n        d._dragStartY = event.y\n        d._isDragging = false\n      })\n      .on('drag', (event, d) => {\n        // 检测是否真正开始拖拽（移动超过阈值）\n        const dx = event.x - d._dragStartX\n        const dy = event.y - d._dragStartY\n        const distance = Math.sqrt(dx * dx + dy * dy)\n        \n        if (!d._isDragging && distance > 3) {\n          // 首次检测到真正拖拽，才重启仿真\n          d._isDragging = true\n          simulation.alphaTarget(0.3).restart()\n        }\n        \n        if (d._isDragging) {\n          d.fx = event.x\n          d.fy = event.y\n        }\n      })\n      .on('end', (event, d) => {\n        // 只有真正拖拽过才让仿真逐渐停止\n        if (d._isDragging) {\n          simulation.alphaTarget(0)\n        }\n        d.fx = null\n        d.fy = null\n        d._isDragging = false\n      })\n    )\n    .on('click', (event, d) => {\n      event.stopPropagation()\n      // 重置所有节点样式\n      node.attr('stroke', '#fff').attr('stroke-width', 2.5)\n      linkGroup.selectAll('path').attr('stroke', '#C0C0C0').attr('stroke-width', 1.5)\n      // 高亮选中节点\n      d3.select(event.target).attr('stroke', '#E91E63').attr('stroke-width', 4)\n      // 高亮与此节点相连的边\n      link.filter(l => l.source.id === d.id || l.target.id === d.id)\n        .attr('stroke', '#E91E63')\n        .attr('stroke-width', 2.5)\n      \n      selectedItem.value = {\n        type: 'node',\n        data: d.rawData,\n        entityType: d.type,\n        color: getColor(d.type)\n      }\n    })\n    .on('mouseenter', (event, d) => {\n      if (!selectedItem.value || selectedItem.value.data?.uuid !== d.rawData.uuid) {\n        d3.select(event.target).attr('stroke', '#333').attr('stroke-width', 3)\n      }\n    })\n    .on('mouseleave', (event, d) => {\n      if (!selectedItem.value || selectedItem.value.data?.uuid !== d.rawData.uuid) {\n        d3.select(event.target).attr('stroke', '#fff').attr('stroke-width', 2.5)\n      }\n    })\n\n  // Node Labels\n  const nodeLabels = nodeGroup.selectAll('text')\n    .data(nodes)\n    .enter().append('text')\n    .text(d => d.name.length > 8 ? d.name.substring(0, 8) + '…' : d.name)\n    .attr('font-size', '11px')\n    .attr('fill', '#333')\n    .attr('font-weight', '500')\n    .attr('dx', 14)\n    .attr('dy', 4)\n    .style('pointer-events', 'none')\n    .style('font-family', 'system-ui, sans-serif')\n\n  simulation.on('tick', () => {\n    // 更新曲线路径\n    link.attr('d', d => getLinkPath(d))\n    \n    // 更新边标签位置（无旋转，水平显示更清晰）\n    linkLabels.each(function(d) {\n      const mid = getLinkMidpoint(d)\n      d3.select(this)\n        .attr('x', mid.x)\n        .attr('y', mid.y)\n        .attr('transform', '') // 移除旋转，保持水平\n    })\n    \n    // 更新边标签背景\n    linkLabelBg.each(function(d, i) {\n      const mid = getLinkMidpoint(d)\n      const textEl = linkLabels.nodes()[i]\n      const bbox = textEl.getBBox()\n      d3.select(this)\n        .attr('x', mid.x - bbox.width / 2 - 4)\n        .attr('y', mid.y - bbox.height / 2 - 2)\n        .attr('width', bbox.width + 8)\n        .attr('height', bbox.height + 4)\n        .attr('transform', '') // 移除旋转\n    })\n\n    node\n      .attr('cx', d => d.x)\n      .attr('cy', d => d.y)\n\n    nodeLabels\n      .attr('x', d => d.x)\n      .attr('y', d => d.y)\n  })\n  \n  // 点击空白处关闭详情面板\n  svg.on('click', () => {\n    selectedItem.value = null\n    node.attr('stroke', '#fff').attr('stroke-width', 2.5)\n    linkGroup.selectAll('path').attr('stroke', '#C0C0C0').attr('stroke-width', 1.5)\n    linkLabelBg.attr('fill', 'rgba(255,255,255,0.95)')\n    linkLabels.attr('fill', '#666')\n  })\n}\n\nwatch(() => props.graphData, () => {\n  nextTick(renderGraph)\n}, { deep: true })\n\n// 监听边标签显示开关\nwatch(showEdgeLabels, (newVal) => {\n  if (linkLabelsRef) {\n    linkLabelsRef.style('display', newVal ? 'block' : 'none')\n  }\n  if (linkLabelBgRef) {\n    linkLabelBgRef.style('display', newVal ? 'block' : 'none')\n  }\n})\n\nconst handleResize = () => {\n  nextTick(renderGraph)\n}\n\nonMounted(() => {\n  window.addEventListener('resize', handleResize)\n})\n\nonUnmounted(() => {\n  window.removeEventListener('resize', handleResize)\n  if (currentSimulation) {\n    currentSimulation.stop()\n  }\n})\n</script>\n\n<style scoped>\n.graph-panel {\n  position: relative;\n  width: 100%;\n  height: 100%;\n  background-color: #FAFAFA;\n  background-image: radial-gradient(#D0D0D0 1.5px, transparent 1.5px);\n  background-size: 24px 24px;\n  overflow: hidden;\n}\n\n.panel-header {\n  position: absolute;\n  top: 0;\n  left: 0;\n  right: 0;\n  padding: 16px 20px;\n  z-index: 10;\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  background: linear-gradient(to bottom, rgba(255,255,255,0.95), rgba(255,255,255,0));\n  pointer-events: none;\n}\n\n.panel-title {\n  font-size: 14px;\n  font-weight: 600;\n  color: #333;\n  pointer-events: auto;\n}\n\n.header-tools {\n  pointer-events: auto;\n  display: flex;\n  gap: 10px;\n  align-items: center;\n}\n\n.tool-btn {\n  height: 32px;\n  padding: 0 12px;\n  border: 1px solid #E0E0E0;\n  background: #FFF;\n  border-radius: 6px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  gap: 6px;\n  cursor: pointer;\n  color: #666;\n  transition: all 0.2s;\n  box-shadow: 0 2px 4px rgba(0,0,0,0.02);\n  font-size: 13px;\n}\n\n.tool-btn:hover {\n  background: #F5F5F5;\n  color: #000;\n  border-color: #CCC;\n}\n\n.tool-btn .btn-text {\n  font-size: 12px;\n}\n\n.icon-refresh.spinning {\n  animation: spin 1s linear infinite;\n}\n\n@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }\n\n.graph-container {\n  width: 100%;\n  height: 100%;\n}\n\n.graph-view, .graph-svg {\n  width: 100%;\n  height: 100%;\n  display: block;\n}\n\n.graph-state {\n  position: absolute;\n  top: 50%;\n  left: 50%;\n  transform: translate(-50%, -50%);\n  text-align: center;\n  color: #999;\n}\n\n.empty-icon {\n  font-size: 48px;\n  margin-bottom: 16px;\n  opacity: 0.2;\n}\n\n/* Entity Types Legend - Bottom Left */\n.graph-legend {\n  position: absolute;\n  bottom: 24px;\n  left: 24px;\n  background: rgba(255,255,255,0.95);\n  padding: 12px 16px;\n  border-radius: 8px;\n  border: 1px solid #EAEAEA;\n  box-shadow: 0 4px 16px rgba(0,0,0,0.06);\n  z-index: 10;\n}\n\n.legend-title {\n  display: block;\n  font-size: 11px;\n  font-weight: 600;\n  color: #E91E63;\n  margin-bottom: 10px;\n  text-transform: uppercase;\n  letter-spacing: 0.5px;\n}\n\n.legend-items {\n  display: flex;\n  flex-wrap: wrap;\n  gap: 10px 16px;\n  max-width: 320px;\n}\n\n.legend-item {\n  display: flex;\n  align-items: center;\n  gap: 6px;\n  font-size: 12px;\n  color: #555;\n}\n\n.legend-dot {\n  width: 10px;\n  height: 10px;\n  border-radius: 50%;\n  flex-shrink: 0;\n}\n\n.legend-label {\n  white-space: nowrap;\n}\n\n/* Edge Labels Toggle - Top Right */\n.edge-labels-toggle {\n  position: absolute;\n  top: 60px;\n  right: 20px;\n  display: flex;\n  align-items: center;\n  gap: 10px;\n  background: #FFF;\n  padding: 8px 14px;\n  border-radius: 20px;\n  border: 1px solid #E0E0E0;\n  box-shadow: 0 2px 8px rgba(0,0,0,0.04);\n  z-index: 10;\n}\n\n.toggle-switch {\n  position: relative;\n  display: inline-block;\n  width: 40px;\n  height: 22px;\n}\n\n.toggle-switch input {\n  opacity: 0;\n  width: 0;\n  height: 0;\n}\n\n.slider {\n  position: absolute;\n  cursor: pointer;\n  top: 0;\n  left: 0;\n  right: 0;\n  bottom: 0;\n  background-color: #E0E0E0;\n  border-radius: 22px;\n  transition: 0.3s;\n}\n\n.slider:before {\n  position: absolute;\n  content: \"\";\n  height: 16px;\n  width: 16px;\n  left: 3px;\n  bottom: 3px;\n  background-color: white;\n  border-radius: 50%;\n  transition: 0.3s;\n}\n\ninput:checked + .slider {\n  background-color: #7B2D8E;\n}\n\ninput:checked + .slider:before {\n  transform: translateX(18px);\n}\n\n.toggle-label {\n  font-size: 12px;\n  color: #666;\n}\n\n/* Detail Panel - Right Side */\n.detail-panel {\n  position: absolute;\n  top: 60px;\n  right: 20px;\n  width: 320px;\n  max-height: calc(100% - 100px);\n  background: #FFF;\n  border: 1px solid #EAEAEA;\n  border-radius: 10px;\n  box-shadow: 0 8px 32px rgba(0,0,0,0.1);\n  overflow: hidden;\n  font-family: 'Noto Sans SC', system-ui, sans-serif;\n  font-size: 13px;\n  z-index: 20;\n  display: flex;\n  flex-direction: column;\n}\n\n.detail-panel-header {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  padding: 14px 16px;\n  background: #FAFAFA;\n  border-bottom: 1px solid #EEE;\n  flex-shrink: 0;\n}\n\n.detail-title {\n  font-weight: 600;\n  color: #333;\n  font-size: 14px;\n}\n\n.detail-type-badge {\n  padding: 4px 10px;\n  border-radius: 12px;\n  font-size: 11px;\n  font-weight: 500;\n  margin-left: auto;\n  margin-right: 12px;\n}\n\n.detail-close {\n  background: none;\n  border: none;\n  font-size: 20px;\n  cursor: pointer;\n  color: #999;\n  line-height: 1;\n  padding: 0;\n  transition: color 0.2s;\n}\n\n.detail-close:hover {\n  color: #333;\n}\n\n.detail-content {\n  padding: 16px;\n  overflow-y: auto;\n  flex: 1;\n}\n\n.detail-row {\n  margin-bottom: 12px;\n  display: flex;\n  flex-wrap: wrap;\n  gap: 4px;\n}\n\n.detail-label {\n  color: #888;\n  font-size: 12px;\n  font-weight: 500;\n  min-width: 80px;\n}\n\n.detail-value {\n  color: #333;\n  flex: 1;\n  word-break: break-word;\n}\n\n.detail-value.uuid-text {\n  font-family: 'JetBrains Mono', monospace;\n  font-size: 11px;\n  color: #666;\n}\n\n.detail-value.fact-text {\n  line-height: 1.5;\n  color: #444;\n}\n\n.detail-section {\n  margin-top: 16px;\n  padding-top: 14px;\n  border-top: 1px solid #F0F0F0;\n}\n\n.section-title {\n  font-size: 12px;\n  font-weight: 600;\n  color: #666;\n  margin-bottom: 10px;\n}\n\n.properties-list {\n  display: flex;\n  flex-direction: column;\n  gap: 8px;\n}\n\n.property-item {\n  display: flex;\n  gap: 8px;\n}\n\n.property-key {\n  color: #888;\n  font-weight: 500;\n  min-width: 90px;\n}\n\n.property-value {\n  color: #333;\n  flex: 1;\n}\n\n.summary-text {\n  line-height: 1.6;\n  color: #444;\n  font-size: 12px;\n}\n\n.labels-list {\n  display: flex;\n  flex-wrap: wrap;\n  gap: 8px;\n}\n\n.label-tag {\n  display: inline-block;\n  padding: 4px 12px;\n  background: #F5F5F5;\n  border: 1px solid #E0E0E0;\n  border-radius: 16px;\n  font-size: 11px;\n  color: #555;\n}\n\n.episodes-list {\n  display: flex;\n  flex-direction: column;\n  gap: 6px;\n}\n\n.episode-tag {\n  display: inline-block;\n  padding: 6px 10px;\n  background: #F8F8F8;\n  border: 1px solid #E8E8E8;\n  border-radius: 6px;\n  font-family: 'JetBrains Mono', monospace;\n  font-size: 10px;\n  color: #666;\n  word-break: break-all;\n}\n\n/* Edge relation header */\n.edge-relation-header {\n  background: #F8F8F8;\n  padding: 12px;\n  border-radius: 8px;\n  margin-bottom: 16px;\n  font-size: 13px;\n  font-weight: 500;\n  color: #333;\n  line-height: 1.5;\n  word-break: break-word;\n}\n\n/* Building hint */\n.graph-building-hint {\n  position: absolute;\n  bottom: 160px; /* Moved up from 80px */\n  left: 50%;\n  transform: translateX(-50%);\n  background: rgba(0, 0, 0, 0.65);\n  backdrop-filter: blur(8px);\n  color: #fff;\n  padding: 10px 20px;\n  border-radius: 30px;\n  font-size: 13px;\n  display: flex;\n  align-items: center;\n  gap: 10px;\n  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);\n  border: 1px solid rgba(255, 255, 255, 0.1);\n  font-weight: 500;\n  letter-spacing: 0.5px;\n  z-index: 100;\n}\n\n.memory-icon-wrapper {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  animation: breathe 2s ease-in-out infinite;\n}\n\n.memory-icon {\n  width: 18px;\n  height: 18px;\n  color: #4CAF50;\n}\n\n@keyframes breathe {\n  0%, 100% { opacity: 0.7; transform: scale(1); filter: drop-shadow(0 0 2px rgba(76, 175, 80, 0.3)); }\n  50% { opacity: 1; transform: scale(1.15); filter: drop-shadow(0 0 8px rgba(76, 175, 80, 0.6)); }\n}\n\n/* 模拟结束后的提示样式 */\n.graph-building-hint.finished-hint {\n  background: rgba(0, 0, 0, 0.65);\n  border: 1px solid rgba(255, 255, 255, 0.1);\n}\n\n.finished-hint .hint-icon-wrapper {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n}\n\n.finished-hint .hint-icon {\n  width: 18px;\n  height: 18px;\n  color: #FFF;\n}\n\n.finished-hint .hint-text {\n  flex: 1;\n  white-space: nowrap;\n}\n\n.hint-close-btn {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  width: 22px;\n  height: 22px;\n  background: rgba(255, 255, 255, 0.2);\n  border: none;\n  border-radius: 50%;\n  cursor: pointer;\n  color: #FFF;\n  transition: all 0.2s;\n  margin-left: 8px;\n  flex-shrink: 0;\n}\n\n.hint-close-btn:hover {\n  background: rgba(255, 255, 255, 0.35);\n  transform: scale(1.1);\n}\n\n/* Loading spinner */\n.loading-spinner {\n  width: 40px;\n  height: 40px;\n  border: 3px solid #E0E0E0;\n  border-top-color: #7B2D8E;\n  border-radius: 50%;\n  animation: spin 1s linear infinite;\n  margin: 0 auto 16px;\n}\n\n/* Self-loop styles */\n.self-loop-header {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  background: linear-gradient(135deg, #E8F5E9 0%, #F1F8E9 100%);\n  border: 1px solid #C8E6C9;\n}\n\n.self-loop-count {\n  margin-left: auto;\n  font-size: 11px;\n  color: #666;\n  background: rgba(255,255,255,0.8);\n  padding: 2px 8px;\n  border-radius: 10px;\n}\n\n.self-loop-list {\n  display: flex;\n  flex-direction: column;\n  gap: 10px;\n}\n\n.self-loop-item {\n  background: #FAFAFA;\n  border: 1px solid #EAEAEA;\n  border-radius: 8px;\n}\n\n.self-loop-item-header {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  padding: 10px 12px;\n  background: #F5F5F5;\n  cursor: pointer;\n  transition: background 0.2s;\n}\n\n.self-loop-item-header:hover {\n  background: #EEEEEE;\n}\n\n.self-loop-item.expanded .self-loop-item-header {\n  background: #E8E8E8;\n}\n\n.self-loop-index {\n  font-size: 10px;\n  font-weight: 600;\n  color: #888;\n  background: #E0E0E0;\n  padding: 2px 6px;\n  border-radius: 4px;\n}\n\n.self-loop-name {\n  font-size: 12px;\n  font-weight: 500;\n  color: #333;\n  flex: 1;\n}\n\n.self-loop-toggle {\n  width: 20px;\n  height: 20px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  font-size: 14px;\n  font-weight: 600;\n  color: #888;\n  background: #E0E0E0;\n  border-radius: 4px;\n  transition: all 0.2s;\n}\n\n.self-loop-item.expanded .self-loop-toggle {\n  background: #D0D0D0;\n  color: #666;\n}\n\n.self-loop-item-content {\n  padding: 12px;\n  border-top: 1px solid #EAEAEA;\n}\n\n.self-loop-item-content .detail-row {\n  margin-bottom: 8px;\n}\n\n.self-loop-item-content .detail-label {\n  font-size: 11px;\n  min-width: 60px;\n}\n\n.self-loop-item-content .detail-value {\n  font-size: 12px;\n}\n\n.self-loop-episodes {\n  margin-top: 8px;\n}\n\n.episodes-list.compact {\n  flex-direction: row;\n  flex-wrap: wrap;\n  gap: 4px;\n}\n\n.episode-tag.small {\n  padding: 3px 6px;\n  font-size: 9px;\n}\n</style>\n"
  },
  {
    "path": "frontend/src/components/HistoryDatabase.vue",
    "content": "<template>\n  <div \n    class=\"history-database\"\n    :class=\"{ 'no-projects': projects.length === 0 && !loading }\"\n    ref=\"historyContainer\"\n  >\n    <!-- 背景装饰：技术网格线（只在有项目时显示） -->\n    <div v-if=\"projects.length > 0 || loading\" class=\"tech-grid-bg\">\n      <div class=\"grid-pattern\"></div>\n      <div class=\"gradient-overlay\"></div>\n    </div>\n\n    <!-- 标题区域 -->\n    <div class=\"section-header\">\n      <div class=\"section-line\"></div>\n      <span class=\"section-title\">推演记录</span>\n      <div class=\"section-line\"></div>\n    </div>\n\n    <!-- 卡片容器（只在有项目时显示） -->\n    <div v-if=\"projects.length > 0\" class=\"cards-container\" :class=\"{ expanded: isExpanded }\" :style=\"containerStyle\">\n      <div \n        v-for=\"(project, index) in projects\" \n        :key=\"project.simulation_id\"\n        class=\"project-card\"\n        :class=\"{ expanded: isExpanded, hovering: hoveringCard === index }\"\n        :style=\"getCardStyle(index)\"\n        @mouseenter=\"hoveringCard = index\"\n        @mouseleave=\"hoveringCard = null\"\n        @click=\"navigateToProject(project)\"\n      >\n        <!-- 卡片头部：simulation_id 和 功能可用状态 -->\n        <div class=\"card-header\">\n          <span class=\"card-id\">{{ formatSimulationId(project.simulation_id) }}</span>\n          <div class=\"card-status-icons\">\n            <span \n              class=\"status-icon\" \n              :class=\"{ available: project.project_id, unavailable: !project.project_id }\"\n              title=\"图谱构建\"\n            >◇</span>\n            <span \n              class=\"status-icon available\" \n              title=\"环境搭建\"\n            >◈</span>\n            <span \n              class=\"status-icon\" \n              :class=\"{ available: project.report_id, unavailable: !project.report_id }\"\n              title=\"分析报告\"\n            >◆</span>\n          </div>\n        </div>\n\n        <!-- 文件列表区域 -->\n        <div class=\"card-files-wrapper\">\n          <!-- 角落装饰 - 取景框风格 -->\n          <div class=\"corner-mark top-left-only\"></div>\n          \n          <!-- 文件列表 -->\n          <div class=\"files-list\" v-if=\"project.files && project.files.length > 0\">\n            <div \n              v-for=\"(file, fileIndex) in project.files.slice(0, 3)\" \n              :key=\"fileIndex\"\n              class=\"file-item\"\n            >\n              <span class=\"file-tag\" :class=\"getFileType(file.filename)\">{{ getFileTypeLabel(file.filename) }}</span>\n              <span class=\"file-name\">{{ truncateFilename(file.filename, 20) }}</span>\n            </div>\n            <!-- 如果有更多文件，显示提示 -->\n            <div v-if=\"project.files.length > 3\" class=\"files-more\">\n              +{{ project.files.length - 3 }} 个文件\n            </div>\n          </div>\n          <!-- 无文件时的占位 -->\n          <div class=\"files-empty\" v-else>\n            <span class=\"empty-file-icon\">◇</span>\n            <span class=\"empty-file-text\">暂无文件</span>\n          </div>\n        </div>\n\n        <!-- 卡片标题（使用模拟需求的前20字作为标题） -->\n        <h3 class=\"card-title\">{{ getSimulationTitle(project.simulation_requirement) }}</h3>\n\n        <!-- 卡片描述（模拟需求完整展示） -->\n        <p class=\"card-desc\">{{ truncateText(project.simulation_requirement, 55) }}</p>\n\n        <!-- 卡片底部 -->\n        <div class=\"card-footer\">\n          <div class=\"card-datetime\">\n            <span class=\"card-date\">{{ formatDate(project.created_at) }}</span>\n            <span class=\"card-time\">{{ formatTime(project.created_at) }}</span>\n          </div>\n          <span class=\"card-progress\" :class=\"getProgressClass(project)\">\n            <span class=\"status-dot\">●</span> {{ formatRounds(project) }}\n          </span>\n        </div>\n        \n        <!-- 底部装饰线 (hover时展开) -->\n        <div class=\"card-bottom-line\"></div>\n      </div>\n    </div>\n\n    <!-- 加载状态 -->\n    <div v-if=\"loading\" class=\"loading-state\">\n      <span class=\"loading-spinner\"></span>\n      <span class=\"loading-text\">加载中...</span>\n    </div>\n\n    <!-- 历史回放详情弹窗 -->\n    <Teleport to=\"body\">\n      <Transition name=\"modal\">\n        <div v-if=\"selectedProject\" class=\"modal-overlay\" @click.self=\"closeModal\">\n          <div class=\"modal-content\">\n            <!-- 弹窗头部 -->\n            <div class=\"modal-header\">\n              <div class=\"modal-title-section\">\n                <span class=\"modal-id\">{{ formatSimulationId(selectedProject.simulation_id) }}</span>\n                <span class=\"modal-progress\" :class=\"getProgressClass(selectedProject)\">\n                  <span class=\"status-dot\">●</span> {{ formatRounds(selectedProject) }}\n                </span>\n                <span class=\"modal-create-time\">{{ formatDate(selectedProject.created_at) }} {{ formatTime(selectedProject.created_at) }}</span>\n              </div>\n              <button class=\"modal-close\" @click=\"closeModal\">×</button>\n            </div>\n\n            <!-- 弹窗内容 -->\n            <div class=\"modal-body\">\n              <!-- 模拟需求 -->\n              <div class=\"modal-section\">\n                <div class=\"modal-label\">模拟需求</div>\n                <div class=\"modal-requirement\">{{ selectedProject.simulation_requirement || '无' }}</div>\n              </div>\n\n              <!-- 文件列表 -->\n              <div class=\"modal-section\">\n                <div class=\"modal-label\">关联文件</div>\n                <div class=\"modal-files\" v-if=\"selectedProject.files && selectedProject.files.length > 0\">\n                  <div v-for=\"(file, index) in selectedProject.files\" :key=\"index\" class=\"modal-file-item\">\n                    <span class=\"file-tag\" :class=\"getFileType(file.filename)\">{{ getFileTypeLabel(file.filename) }}</span>\n                    <span class=\"modal-file-name\">{{ file.filename }}</span>\n                  </div>\n                </div>\n                <div class=\"modal-empty\" v-else>暂无关联文件</div>\n              </div>\n            </div>\n\n            <!-- 推演回放分割线 -->\n            <div class=\"modal-divider\">\n              <span class=\"divider-line\"></span>\n              <span class=\"divider-text\">推演回放</span>\n              <span class=\"divider-line\"></span>\n            </div>\n\n            <!-- 导航按钮 -->\n            <div class=\"modal-actions\">\n              <button \n                class=\"modal-btn btn-project\" \n                @click=\"goToProject\"\n                :disabled=\"!selectedProject.project_id\"\n              >\n                <span class=\"btn-step\">Step1</span>\n                <span class=\"btn-icon\">◇</span>\n                <span class=\"btn-text\">图谱构建</span>\n              </button>\n              <button \n                class=\"modal-btn btn-simulation\" \n                @click=\"goToSimulation\"\n              >\n                <span class=\"btn-step\">Step2</span>\n                <span class=\"btn-icon\">◈</span>\n                <span class=\"btn-text\">环境搭建</span>\n              </button>\n              <button \n                class=\"modal-btn btn-report\" \n                @click=\"goToReport\"\n                :disabled=\"!selectedProject.report_id\"\n              >\n                <span class=\"btn-step\">Step4</span>\n                <span class=\"btn-icon\">◆</span>\n                <span class=\"btn-text\">分析报告</span>\n              </button>\n            </div>\n            <!-- 不可回放提示 -->\n            <div class=\"modal-playback-hint\">\n              <span class=\"hint-text\">Step3「开始模拟」与 Step5「深度互动」需在运行中启动，不支持历史回放</span>\n            </div>\n          </div>\n        </div>\n      </Transition>\n    </Teleport>\n  </div>\n</template>\n\n<script setup>\nimport { ref, computed, onMounted, onUnmounted, onActivated, watch, nextTick } from 'vue'\nimport { useRouter, useRoute } from 'vue-router'\nimport { getSimulationHistory } from '../api/simulation'\n\nconst router = useRouter()\nconst route = useRoute()\n\n// 状态\nconst projects = ref([])\nconst loading = ref(true)\nconst isExpanded = ref(false)\nconst hoveringCard = ref(null)\nconst historyContainer = ref(null)\nconst selectedProject = ref(null)  // 当前选中的项目（用于弹窗）\nlet observer = null\nlet isAnimating = false  // 动画锁，防止闪烁\nlet expandDebounceTimer = null  // 防抖定时器\nlet pendingState = null  // 记录待执行的目标状态\n\n// 卡片布局配置 - 调整为更宽的比例\nconst CARDS_PER_ROW = 4\nconst CARD_WIDTH = 280  \nconst CARD_HEIGHT = 280 \nconst CARD_GAP = 24\n\n// 动态计算容器高度样式\nconst containerStyle = computed(() => {\n  if (!isExpanded.value) {\n    // 折叠态：固定高度\n    return { minHeight: '420px' }\n  }\n  \n  // 展开态：根据卡片数量动态计算高度\n  const total = projects.value.length\n  if (total === 0) {\n    return { minHeight: '280px' }\n  }\n  \n  const rows = Math.ceil(total / CARDS_PER_ROW)\n  // 计算实际需要的高度：行数 * 卡片高度 + (行数-1) * 间距 + 少量底部间距\n  const expandedHeight = rows * CARD_HEIGHT + (rows - 1) * CARD_GAP + 10\n  \n  return { minHeight: `${expandedHeight}px` }\n})\n\n// 获取卡片样式\nconst getCardStyle = (index) => {\n  const total = projects.value.length\n  \n  if (isExpanded.value) {\n    // 展开态：网格布局\n    const transition = 'transform 700ms cubic-bezier(0.23, 1, 0.32, 1), opacity 700ms cubic-bezier(0.23, 1, 0.32, 1), box-shadow 0.3s ease, border-color 0.3s ease'\n\n    const col = index % CARDS_PER_ROW\n    const row = Math.floor(index / CARDS_PER_ROW)\n    \n    // 计算当前行的卡片数量，确保每行居中\n    const currentRowStart = row * CARDS_PER_ROW\n    const currentRowCards = Math.min(CARDS_PER_ROW, total - currentRowStart)\n    \n    const rowWidth = currentRowCards * CARD_WIDTH + (currentRowCards - 1) * CARD_GAP\n    \n    const startX = -(rowWidth / 2) + (CARD_WIDTH / 2)\n    const colInRow = index % CARDS_PER_ROW\n    const x = startX + colInRow * (CARD_WIDTH + CARD_GAP)\n    \n    // 向下展开，增加与标题的间距\n    const y = 20 + row * (CARD_HEIGHT + CARD_GAP)\n\n    return {\n      transform: `translate(${x}px, ${y}px) rotate(0deg) scale(1)`,\n      zIndex: 100 + index,\n      opacity: 1,\n      transition: transition\n    }\n  } else {\n    // 折叠态：扇形堆叠\n    const transition = 'transform 700ms cubic-bezier(0.23, 1, 0.32, 1), opacity 700ms cubic-bezier(0.23, 1, 0.32, 1), box-shadow 0.3s ease, border-color 0.3s ease'\n\n    const centerIndex = (total - 1) / 2\n    const offset = index - centerIndex\n    \n    const x = offset * 35\n    // 调整起始位置，靠近标题但保持适当间距\n    const y = 25 + Math.abs(offset) * 8\n    const r = offset * 3\n    const s = 0.95 - Math.abs(offset) * 0.05\n    \n    return {\n      transform: `translate(${x}px, ${y}px) rotate(${r}deg) scale(${s})`,\n      zIndex: 10 + index,\n      opacity: 1,\n      transition: transition\n    }\n  }\n}\n\n// 根据轮数进度获取样式类\nconst getProgressClass = (simulation) => {\n  const current = simulation.current_round || 0\n  const total = simulation.total_rounds || 0\n  \n  if (total === 0 || current === 0) {\n    // 未开始\n    return 'not-started'\n  } else if (current >= total) {\n    // 已完成\n    return 'completed'\n  } else {\n    // 进行中\n    return 'in-progress'\n  }\n}\n\n// 格式化日期（只显示日期部分）\nconst formatDate = (dateStr) => {\n  if (!dateStr) return ''\n  try {\n    const date = new Date(dateStr)\n    return date.toISOString().slice(0, 10)\n  } catch {\n    return dateStr?.slice(0, 10) || ''\n  }\n}\n\n// 格式化时间（显示时:分）\nconst formatTime = (dateStr) => {\n  if (!dateStr) return ''\n  try {\n    const date = new Date(dateStr)\n    const hours = date.getHours().toString().padStart(2, '0')\n    const minutes = date.getMinutes().toString().padStart(2, '0')\n    return `${hours}:${minutes}`\n  } catch {\n    return ''\n  }\n}\n\n// 截断文本\nconst truncateText = (text, maxLength) => {\n  if (!text) return ''\n  return text.length > maxLength ? text.slice(0, maxLength) + '...' : text\n}\n\n// 从模拟需求生成标题（取前20字）\nconst getSimulationTitle = (requirement) => {\n  if (!requirement) return '未命名模拟'\n  const title = requirement.slice(0, 20)\n  return requirement.length > 20 ? title + '...' : title\n}\n\n// 格式化 simulation_id 显示（截取前6位）\nconst formatSimulationId = (simulationId) => {\n  if (!simulationId) return 'SIM_UNKNOWN'\n  const prefix = simulationId.replace('sim_', '').slice(0, 6)\n  return `SIM_${prefix.toUpperCase()}`\n}\n\n// 格式化轮数显示（当前轮/总轮数）\nconst formatRounds = (simulation) => {\n  const current = simulation.current_round || 0\n  const total = simulation.total_rounds || 0\n  if (total === 0) return '未开始'\n  return `${current}/${total} 轮`\n}\n\n// 获取文件类型（用于样式）\nconst getFileType = (filename) => {\n  if (!filename) return 'other'\n  const ext = filename.split('.').pop()?.toLowerCase()\n  const typeMap = {\n    'pdf': 'pdf',\n    'doc': 'doc', 'docx': 'doc',\n    'xls': 'xls', 'xlsx': 'xls', 'csv': 'xls',\n    'ppt': 'ppt', 'pptx': 'ppt',\n    'txt': 'txt', 'md': 'txt', 'json': 'code',\n    'jpg': 'img', 'jpeg': 'img', 'png': 'img', 'gif': 'img',\n    'zip': 'zip', 'rar': 'zip', '7z': 'zip'\n  }\n  return typeMap[ext] || 'other'\n}\n\n// 获取文件类型标签文本\nconst getFileTypeLabel = (filename) => {\n  if (!filename) return 'FILE'\n  const ext = filename.split('.').pop()?.toUpperCase()\n  return ext || 'FILE'\n}\n\n// 截断文件名（保留扩展名）\nconst truncateFilename = (filename, maxLength) => {\n  if (!filename) return '未知文件'\n  if (filename.length <= maxLength) return filename\n  \n  const ext = filename.includes('.') ? '.' + filename.split('.').pop() : ''\n  const nameWithoutExt = filename.slice(0, filename.length - ext.length)\n  const truncatedName = nameWithoutExt.slice(0, maxLength - ext.length - 3) + '...'\n  return truncatedName + ext\n}\n\n// 打开项目详情弹窗\nconst navigateToProject = (simulation) => {\n  selectedProject.value = simulation\n}\n\n// 关闭弹窗\nconst closeModal = () => {\n  selectedProject.value = null\n}\n\n// 导航到图谱构建页面（Project）\nconst goToProject = () => {\n  if (selectedProject.value?.project_id) {\n    router.push({\n      name: 'Process',\n      params: { projectId: selectedProject.value.project_id }\n    })\n    closeModal()\n  }\n}\n\n// 导航到环境配置页面（Simulation）\nconst goToSimulation = () => {\n  if (selectedProject.value?.simulation_id) {\n    router.push({\n      name: 'Simulation',\n      params: { simulationId: selectedProject.value.simulation_id }\n    })\n    closeModal()\n  }\n}\n\n// 导航到分析报告页面（Report）\nconst goToReport = () => {\n  if (selectedProject.value?.report_id) {\n    router.push({\n      name: 'Report',\n      params: { reportId: selectedProject.value.report_id }\n    })\n    closeModal()\n  }\n}\n\n// 加载历史项目\nconst loadHistory = async () => {\n  try {\n    loading.value = true\n    const response = await getSimulationHistory(20)\n    if (response.success) {\n      projects.value = response.data || []\n    }\n  } catch (error) {\n    console.error('加载历史项目失败:', error)\n    projects.value = []\n  } finally {\n    loading.value = false\n  }\n}\n\n// 初始化 IntersectionObserver\nconst initObserver = () => {\n  if (observer) {\n    observer.disconnect()\n  }\n  \n  observer = new IntersectionObserver(\n    (entries) => {\n      entries.forEach((entry) => {\n        const shouldExpand = entry.isIntersecting\n        \n        // 更新待执行的目标状态（无论是否在动画中都要记录最新的目标状态）\n        pendingState = shouldExpand\n        \n        // 清除之前的防抖定时器（新的滚动意图会覆盖旧的）\n        if (expandDebounceTimer) {\n          clearTimeout(expandDebounceTimer)\n          expandDebounceTimer = null\n        }\n        \n        // 如果正在动画中，只记录状态，等动画结束后处理\n        if (isAnimating) return\n        \n        // 如果目标状态与当前状态相同，不需要处理\n        if (shouldExpand === isExpanded.value) {\n          pendingState = null\n          return\n        }\n        \n        // 使用防抖延迟状态切换，防止快速闪烁\n        // 展开时延迟较短(50ms)，收起时延迟较长(200ms)以增加稳定性\n        const delay = shouldExpand ? 50 : 200\n        \n        expandDebounceTimer = setTimeout(() => {\n          // 检查是否正在动画\n          if (isAnimating) return\n          \n          // 检查待执行状态是否仍需要执行（可能已被后续滚动覆盖）\n          if (pendingState === null || pendingState === isExpanded.value) return\n          \n          // 设置动画锁\n          isAnimating = true\n          isExpanded.value = pendingState\n          pendingState = null\n          \n          // 动画完成后解除锁定，并检查是否有待处理的状态变化\n          setTimeout(() => {\n            isAnimating = false\n            \n            // 动画结束后，检查是否有新的待执行状态\n            if (pendingState !== null && pendingState !== isExpanded.value) {\n              // 延迟一小段时间再执行，避免太快切换\n              expandDebounceTimer = setTimeout(() => {\n                if (pendingState !== null && pendingState !== isExpanded.value) {\n                  isAnimating = true\n                  isExpanded.value = pendingState\n                  pendingState = null\n                  setTimeout(() => {\n                    isAnimating = false\n                  }, 750)\n                }\n              }, 100)\n            }\n          }, 750)\n        }, delay)\n      })\n    },\n    {\n      // 使用多个阈值，使检测更平滑\n      threshold: [0.4, 0.6, 0.8],\n      // 调整 rootMargin，视口底部向上收缩，需要滚动更多才触发展开\n      rootMargin: '0px 0px -150px 0px'\n    }\n  )\n  \n  // 开始观察\n  if (historyContainer.value) {\n    observer.observe(historyContainer.value)\n  }\n}\n\n// 监听路由变化，当返回首页时重新加载数据\nwatch(() => route.path, (newPath) => {\n  if (newPath === '/') {\n    loadHistory()\n  }\n})\n\nonMounted(async () => {\n  // 确保 DOM 渲染完成后再加载数据\n  await nextTick()\n  await loadHistory()\n  \n  // 等待 DOM 渲染后初始化观察器\n  setTimeout(() => {\n    initObserver()\n  }, 100)\n})\n\n// 如果使用 keep-alive，在组件激活时重新加载数据\nonActivated(() => {\n  loadHistory()\n})\n\nonUnmounted(() => {\n  // 清理 Intersection Observer\n  if (observer) {\n    observer.disconnect()\n    observer = null\n  }\n  // 清理防抖定时器\n  if (expandDebounceTimer) {\n    clearTimeout(expandDebounceTimer)\n    expandDebounceTimer = null\n  }\n})\n</script>\n\n<style scoped>\n/* 容器 */\n.history-database {\n  position: relative;\n  width: 100%;\n  min-height: 280px;\n  margin-top: 40px;\n  padding: 35px 0 40px;\n  overflow: visible;\n}\n\n/* 无项目时简化显示 */\n.history-database.no-projects {\n  min-height: auto;\n  padding: 40px 0 20px;\n}\n\n/* 技术网格背景 */\n.tech-grid-bg {\n  position: absolute;\n  top: 0;\n  left: 0;\n  right: 0;\n  bottom: 0;\n  overflow: hidden;\n  pointer-events: none;\n}\n\n/* 使用CSS背景图案创建固定间距的正方形网格 */\n.grid-pattern {\n  position: absolute;\n  top: 0;\n  left: 0;\n  right: 0;\n  bottom: 0;\n  background-image: \n    linear-gradient(to right, rgba(0, 0, 0, 0.05) 1px, transparent 1px),\n    linear-gradient(to bottom, rgba(0, 0, 0, 0.05) 1px, transparent 1px);\n  background-size: 50px 50px;\n  /* 从左上角开始定位，高度变化时只在底部扩展，不影响已有网格位置 */\n  background-position: top left;\n}\n\n.gradient-overlay {\n  position: absolute;\n  top: 0;\n  left: 0;\n  right: 0;\n  bottom: 0;\n  background: \n    linear-gradient(to right, rgba(255, 255, 255, 0.9) 0%, transparent 15%, transparent 85%, rgba(255, 255, 255, 0.9) 100%),\n    linear-gradient(to bottom, rgba(255, 255, 255, 0.8) 0%, transparent 20%, transparent 80%, rgba(255, 255, 255, 0.8) 100%);\n  pointer-events: none;\n}\n\n/* 标题区域 */\n.section-header {\n  position: relative;\n  z-index: 100;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  gap: 24px;\n  margin-bottom: 24px;\n  font-family: 'JetBrains Mono', 'SF Mono', monospace;\n  padding: 0 40px;\n}\n\n.section-line {\n  flex: 1;\n  height: 1px;\n  background: linear-gradient(90deg, transparent, #E5E7EB, transparent);\n  max-width: 300px;\n}\n\n.section-title {\n  font-size: 0.8rem;\n  font-weight: 500;\n  color: #9CA3AF;\n  letter-spacing: 3px;\n  text-transform: uppercase;\n}\n\n/* 卡片容器 */\n.cards-container {\n  position: relative;\n  display: flex;\n  justify-content: center;\n  align-items: flex-start;\n  padding: 0 40px;\n  transition: min-height 700ms cubic-bezier(0.23, 1, 0.32, 1);\n  /* min-height 由 JS 动态计算，根据卡片数量自适应 */\n}\n\n/* 项目卡片 */\n.project-card {\n  position: absolute;\n  width: 280px;\n  background: #FFFFFF;\n  border: 1px solid #E5E7EB;\n  border-radius: 0;\n  padding: 14px;\n  cursor: pointer;\n  box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);\n  transition: box-shadow 0.3s ease, border-color 0.3s ease, transform 700ms cubic-bezier(0.23, 1, 0.32, 1), opacity 700ms cubic-bezier(0.23, 1, 0.32, 1);\n}\n\n.project-card:hover {\n  box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);\n  border-color: rgba(0, 0, 0, 0.4);\n  z-index: 1000 !important;\n}\n\n.project-card.hovering {\n  z-index: 1000 !important;\n}\n\n/* 卡片头部 */\n.card-header {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  margin-bottom: 12px;\n  padding-bottom: 12px;\n  border-bottom: 1px solid #F3F4F6;\n  font-family: 'JetBrains Mono', 'SF Mono', monospace;\n  font-size: 0.7rem;\n}\n\n.card-id {\n  color: #6B7280;\n  letter-spacing: 0.5px;\n  font-weight: 500;\n}\n\n/* 功能状态图标组 */\n.card-status-icons {\n  display: flex;\n  align-items: center;\n  gap: 6px;\n}\n\n.status-icon {\n  font-size: 0.75rem;\n  transition: all 0.2s ease;\n  cursor: default;\n}\n\n.status-icon.available {\n  opacity: 1;\n}\n\n/* 不同功能的颜色 */\n.status-icon:nth-child(1).available { color: #3B82F6; } /* 图谱构建 - 蓝色 */\n.status-icon:nth-child(2).available { color: #F59E0B; } /* 环境搭建 - 橙色 */\n.status-icon:nth-child(3).available { color: #10B981; } /* 分析报告 - 绿色 */\n\n.status-icon.unavailable {\n  color: #D1D5DB;\n  opacity: 0.5;\n}\n\n/* 轮数进度显示 */\n.card-progress {\n  display: flex;\n  align-items: center;\n  gap: 6px;\n  letter-spacing: 0.5px;\n  font-weight: 600;\n  font-size: 0.65rem;\n}\n\n.status-dot {\n  font-size: 0.5rem;\n}\n\n/* 进度状态颜色 */\n.card-progress.completed { color: #10B981; }    /* 已完成 - 绿色 */\n.card-progress.in-progress { color: #F59E0B; }  /* 进行中 - 橙色 */\n.card-progress.not-started { color: #9CA3AF; }  /* 未开始 - 灰色 */\n.card-status.pending { color: #9CA3AF; }\n\n/* 文件列表区域 */\n.card-files-wrapper {\n  position: relative;\n  width: 100%;\n  min-height: 48px;\n  max-height: 110px;\n  margin-bottom: 12px;\n  padding: 8px 10px;\n  background: linear-gradient(135deg, #f8f9fa 0%, #f1f3f4 100%);\n  border-radius: 4px;\n  border: 1px solid #e8eaed;\n  overflow: hidden;\n}\n\n.files-list {\n  display: flex;\n  flex-direction: column;\n  gap: 4px;\n}\n\n/* 更多文件提示 */\n.files-more {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  padding: 3px 6px;\n  font-family: 'JetBrains Mono', monospace;\n  font-size: 0.6rem;\n  color: #6B7280;\n  background: rgba(255, 255, 255, 0.5);\n  border-radius: 3px;\n  letter-spacing: 0.3px;\n}\n\n.file-item {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  padding: 4px 6px;\n  background: rgba(255, 255, 255, 0.7);\n  border-radius: 3px;\n  transition: all 0.2s ease;\n}\n\n.file-item:hover {\n  background: rgba(255, 255, 255, 1);\n  transform: translateX(2px);\n  border-color: #e5e7eb;\n}\n\n/* 简约文件标签样式 */\n.file-tag {\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  height: 16px;\n  padding: 0 4px;\n  border-radius: 2px;\n  font-family: 'JetBrains Mono', monospace;\n  font-size: 0.55rem;\n  font-weight: 600;\n  line-height: 1;\n  text-transform: uppercase;\n  letter-spacing: 0.2px;\n  flex-shrink: 0;\n  min-width: 28px;\n}\n\n/* 低饱和度配色方案 - Morandi色系 */\n.file-tag.pdf { background: #f2e6e6; color: #a65a5a; }\n.file-tag.doc { background: #e6eff5; color: #5a7ea6; }\n.file-tag.xls { background: #e6f2e8; color: #5aa668; }\n.file-tag.ppt { background: #f5efe6; color: #a6815a; }\n.file-tag.txt { background: #f0f0f0; color: #757575; }\n.file-tag.code { background: #eae6f2; color: #815aa6; }\n.file-tag.img { background: #e6f2f2; color: #5aa6a6; }\n.file-tag.zip { background: #f2f0e6; color: #a69b5a; }\n.file-tag.other { background: #f3f4f6; color: #6b7280; }\n\n.file-name {\n  font-family: 'Inter', sans-serif;\n  font-size: 0.7rem;\n  color: #4b5563;\n  white-space: nowrap;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  letter-spacing: 0.1px;\n}\n\n/* 无文件时的占位 */\n.files-empty {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  gap: 8px;\n  height: 48px;\n  color: #9CA3AF;\n}\n\n.empty-file-icon {\n  font-size: 1rem;\n  opacity: 0.5;\n}\n\n.empty-file-text {\n  font-family: 'JetBrains Mono', monospace;\n  font-size: 0.7rem;\n  letter-spacing: 0.5px;\n}\n\n/* 悬停时文件区域效果 */\n.project-card:hover .card-files-wrapper {\n  border-color: #d1d5db;\n  background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);\n}\n\n/* 角落装饰 */\n.corner-mark.top-left-only {\n  position: absolute;\n  top: 6px;\n  left: 6px;\n  width: 8px;\n  height: 8px;\n  border-top: 1.5px solid rgba(0, 0, 0, 0.4);\n  border-left: 1.5px solid rgba(0, 0, 0, 0.4);\n  pointer-events: none;\n  z-index: 10;\n}\n\n/* 卡片标题 */\n.card-title {\n  font-family: 'Inter', -apple-system, sans-serif;\n  font-size: 0.9rem;\n  font-weight: 700;\n  color: #111827;\n  margin: 0 0 6px 0;\n  line-height: 1.4;\n  white-space: nowrap;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  transition: color 0.3s ease;\n}\n\n.project-card:hover .card-title {\n  color: #2563EB;\n}\n\n/* 卡片描述 */\n.card-desc {\n  font-family: 'Inter', sans-serif;\n  font-size: 0.75rem;\n  color: #6B7280;\n  margin: 0 0 16px 0;\n  line-height: 1.5;\n  height: 34px;\n  overflow: hidden;\n  display: -webkit-box;\n  -webkit-line-clamp: 2;\n  -webkit-box-orient: vertical;\n}\n\n/* 卡片底部 */\n.card-footer {\n  position: relative;\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  padding-top: 12px;\n  border-top: 1px solid #F3F4F6;\n  font-family: 'JetBrains Mono', monospace;\n  font-size: 0.65rem;\n  color: #9CA3AF;\n  font-weight: 500;\n}\n\n/* 日期时间组合 */\n.card-datetime {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n}\n\n/* 底部轮数进度显示 */\n.card-footer .card-progress {\n  display: flex;\n  align-items: center;\n  gap: 6px;\n  letter-spacing: 0.5px;\n  font-weight: 600;\n  font-size: 0.65rem;\n}\n\n.card-footer .status-dot {\n  font-size: 0.5rem;\n}\n\n/* 进度状态颜色 - 底部 */\n.card-footer .card-progress.completed { color: #10B981; }\n.card-footer .card-progress.in-progress { color: #F59E0B; }\n.card-footer .card-progress.not-started { color: #9CA3AF; }\n\n/* 底部装饰线 */\n.card-bottom-line {\n  position: absolute;\n  bottom: 0;\n  left: 0;\n  height: 2px;\n  width: 0;\n  background-color: #000;\n  transition: width 0.5s cubic-bezier(0.23, 1, 0.32, 1);\n  z-index: 20;\n}\n\n.project-card:hover .card-bottom-line {\n  width: 100%;\n}\n\n/* 空状态 */\n.empty-state, .loading-state {\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  gap: 14px;\n  padding: 48px;\n  color: #9CA3AF;\n}\n\n.empty-icon {\n  font-size: 2rem;\n  opacity: 0.5;\n}\n\n.loading-spinner {\n  width: 24px;\n  height: 24px;\n  border: 2px solid #E5E7EB;\n  border-top-color: #6B7280;\n  border-radius: 50%;\n  animation: spin 0.8s linear infinite;\n}\n\n@keyframes spin {\n  to { transform: rotate(360deg); }\n}\n\n/* 响应式 */\n@media (max-width: 1200px) {\n  .project-card {\n    width: 240px;\n  }\n}\n\n@media (max-width: 768px) {\n  .cards-container {\n    padding: 0 20px;\n  }\n  .project-card {\n    width: 200px;\n  }\n}\n\n/* ===== 历史回放详情弹窗样式 ===== */\n.modal-overlay {\n  position: fixed;\n  top: 0;\n  left: 0;\n  right: 0;\n  bottom: 0;\n  background: rgba(0, 0, 0, 0.4);\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  z-index: 9999;\n  backdrop-filter: blur(4px);\n}\n\n.modal-content {\n  background: #FFFFFF;\n  width: 560px;\n  max-width: 90vw;\n  max-height: 85vh;\n  overflow-y: auto;\n  border: 1px solid #E5E7EB;\n  border-radius: 8px;\n  box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);\n}\n\n/* 动画过渡 */\n.modal-enter-active,\n.modal-leave-active {\n  transition: opacity 0.3s ease;\n}\n\n.modal-enter-from,\n.modal-leave-to {\n  opacity: 0;\n}\n\n.modal-enter-active .modal-content {\n  transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);\n}\n\n.modal-leave-active .modal-content {\n  transition: all 0.2s ease-in;\n}\n\n.modal-enter-from .modal-content {\n  transform: scale(0.95) translateY(10px);\n  opacity: 0;\n}\n\n.modal-leave-to .modal-content {\n  transform: scale(0.95) translateY(10px);\n  opacity: 0;\n}\n\n/* 弹窗头部 */\n.modal-header {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  padding: 20px 32px;\n  border-bottom: 1px solid #F3F4F6;\n  background: #FFFFFF;\n}\n\n.modal-title-section {\n  display: flex;\n  align-items: center;\n  gap: 16px;\n}\n\n.modal-id {\n  font-family: 'JetBrains Mono', monospace;\n  font-size: 1rem;\n  font-weight: 600;\n  color: #111827;\n  letter-spacing: 0.5px;\n}\n\n.modal-progress {\n  display: flex;\n  align-items: center;\n  gap: 6px;\n  font-family: 'JetBrains Mono', monospace;\n  font-size: 0.75rem;\n  font-weight: 600;\n  padding: 4px 8px;\n  border-radius: 4px;\n  background: #F9FAFB;\n}\n\n.modal-progress.completed { color: #10B981; background: rgba(16, 185, 129, 0.1); }\n.modal-progress.in-progress { color: #F59E0B; background: rgba(245, 158, 11, 0.1); }\n.modal-progress.not-started { color: #9CA3AF; background: #F3F4F6; }\n\n.modal-create-time {\n  font-family: 'JetBrains Mono', monospace;\n  font-size: 0.75rem;\n  color: #9CA3AF;\n  letter-spacing: 0.3px;\n}\n\n.modal-close {\n  width: 32px;\n  height: 32px;\n  border: none;\n  background: transparent;\n  font-size: 1.5rem;\n  color: #9CA3AF;\n  cursor: pointer;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  transition: all 0.2s ease;\n  border-radius: 6px;\n}\n\n.modal-close:hover {\n  background: #F3F4F6;\n  color: #111827;\n}\n\n/* 弹窗内容 */\n.modal-body {\n  padding: 24px 32px;\n}\n\n.modal-section {\n  margin-bottom: 24px;\n}\n\n.modal-section:last-child {\n  margin-bottom: 0;\n}\n\n.modal-label {\n  font-family: 'JetBrains Mono', monospace;\n  font-size: 0.75rem;\n  color: #6B7280;\n  text-transform: uppercase;\n  letter-spacing: 1px;\n  margin-bottom: 10px;\n  font-weight: 500;\n}\n\n.modal-requirement {\n  font-size: 0.95rem;\n  color: #374151;\n  line-height: 1.6;\n  padding: 16px;\n  background: #F9FAFB;\n  border: 1px solid #F3F4F6;\n  border-radius: 8px;\n}\n\n.modal-files {\n  display: flex;\n  flex-direction: column;\n  gap: 10px;\n  max-height: 200px;\n  overflow-y: auto;\n  padding-right: 4px;\n}\n\n/* 自定义滚动条样式 */\n.modal-files::-webkit-scrollbar {\n  width: 4px;\n}\n\n.modal-files::-webkit-scrollbar-track {\n  background: #F3F4F6;\n  border-radius: 2px;\n}\n\n.modal-files::-webkit-scrollbar-thumb {\n  background: #D1D5DB;\n  border-radius: 2px;\n}\n\n.modal-files::-webkit-scrollbar-thumb:hover {\n  background: #9CA3AF;\n}\n\n.modal-file-item {\n  display: flex;\n  align-items: center;\n  gap: 12px;\n  padding: 10px 14px;\n  background: #FFFFFF;\n  border: 1px solid #E5E7EB;\n  border-radius: 6px;\n  transition: all 0.2s ease;\n}\n\n.modal-file-item:hover {\n  border-color: #D1D5DB;\n  box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);\n}\n\n.modal-file-name {\n  font-size: 0.85rem;\n  color: #4B5563;\n  flex: 1;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n\n.modal-empty {\n  font-size: 0.85rem;\n  color: #9CA3AF;\n  padding: 16px;\n  background: #F9FAFB;\n  border: 1px dashed #E5E7EB;\n  border-radius: 6px;\n  text-align: center;\n}\n\n/* 推演回放分割线 */\n.modal-divider {\n  display: flex;\n  align-items: center;\n  gap: 16px;\n  padding: 10px 32px 0;\n  background: #FFFFFF;\n}\n\n.divider-line {\n  flex: 1;\n  height: 1px;\n  background: linear-gradient(90deg, transparent, #E5E7EB, transparent);\n}\n\n.divider-text {\n  font-family: 'JetBrains Mono', monospace;\n  font-size: 0.7rem;\n  color: #9CA3AF;\n  letter-spacing: 2px;\n  text-transform: uppercase;\n  white-space: nowrap;\n}\n\n/* 导航按钮 */\n.modal-actions {\n  display: flex;\n  gap: 16px;\n  padding: 20px 32px;\n  background: #FFFFFF;\n}\n\n.modal-btn {\n  flex: 1;\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  gap: 8px;\n  padding: 16px;\n  border: 1px solid #E5E7EB;\n  border-radius: 8px;\n  background: #FFFFFF;\n  cursor: pointer;\n  transition: all 0.2s ease;\n  position: relative;\n  overflow: hidden;\n}\n\n.modal-btn:hover:not(:disabled) {\n  border-color: #000000;\n  transform: translateY(-2px);\n  box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);\n}\n\n.modal-btn:disabled {\n  opacity: 0.5;\n  cursor: not-allowed;\n  background: #F9FAFB;\n}\n\n.btn-step {\n  font-family: 'JetBrains Mono', monospace;\n  font-size: 0.6rem;\n  font-weight: 500;\n  color: #9CA3AF;\n  letter-spacing: 0.5px;\n  text-transform: uppercase;\n}\n\n.btn-icon {\n  font-size: 1.4rem;\n  line-height: 1;\n  transition: color 0.2s ease;\n}\n\n.btn-text {\n  font-family: 'JetBrains Mono', monospace;\n  font-size: 0.75rem;\n  font-weight: 600;\n  letter-spacing: 0.5px;\n  color: #4B5563;\n}\n\n.modal-btn.btn-project .btn-icon { color: #3B82F6; }\n.modal-btn.btn-simulation .btn-icon { color: #F59E0B; }\n.modal-btn.btn-report .btn-icon { color: #10B981; }\n\n.modal-btn:hover:not(:disabled) .btn-text {\n  color: #111827;\n}\n\n/* 不可回放提示 */\n.modal-playback-hint {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  padding: 0 32px 20px;\n  background: #FFFFFF;\n}\n\n.hint-text {\n  font-family: 'JetBrains Mono', monospace;\n  font-size: 0.7rem;\n  color: #9CA3AF;\n  letter-spacing: 0.3px;\n  text-align: center;\n  line-height: 1.5;\n}\n</style>\n"
  },
  {
    "path": "frontend/src/components/Step1GraphBuild.vue",
    "content": "<template>\n  <div class=\"workbench-panel\">\n    <div class=\"scroll-container\">\n      <!-- Step 01: Ontology -->\n      <div class=\"step-card\" :class=\"{ 'active': currentPhase === 0, 'completed': currentPhase > 0 }\">\n        <div class=\"card-header\">\n          <div class=\"step-info\">\n            <span class=\"step-num\">01</span>\n            <span class=\"step-title\">本体生成</span>\n          </div>\n          <div class=\"step-status\">\n            <span v-if=\"currentPhase > 0\" class=\"badge success\">已完成</span>\n            <span v-else-if=\"currentPhase === 0\" class=\"badge processing\">生成中</span>\n            <span v-else class=\"badge pending\">等待</span>\n          </div>\n        </div>\n        \n        <div class=\"card-content\">\n          <p class=\"api-note\">POST /api/graph/ontology/generate</p>\n          <p class=\"description\">\n            LLM分析文档内容与模拟需求，提取出现实种子，自动生成合适的本体结构\n          </p>\n\n          <!-- Loading / Progress -->\n          <div v-if=\"currentPhase === 0 && ontologyProgress\" class=\"progress-section\">\n            <div class=\"spinner-sm\"></div>\n            <span>{{ ontologyProgress.message || '正在分析文档...' }}</span>\n          </div>\n\n          <!-- Detail Overlay -->\n          <div v-if=\"selectedOntologyItem\" class=\"ontology-detail-overlay\">\n            <div class=\"detail-header\">\n               <div class=\"detail-title-group\">\n                  <span class=\"detail-type-badge\">{{ selectedOntologyItem.itemType === 'entity' ? 'ENTITY' : 'RELATION' }}</span>\n                  <span class=\"detail-name\">{{ selectedOntologyItem.name }}</span>\n               </div>\n               <button class=\"close-btn\" @click=\"selectedOntologyItem = null\">×</button>\n            </div>\n            <div class=\"detail-body\">\n               <div class=\"detail-desc\">{{ selectedOntologyItem.description }}</div>\n               \n               <!-- Attributes -->\n               <div class=\"detail-section\" v-if=\"selectedOntologyItem.attributes?.length\">\n                  <span class=\"section-label\">ATTRIBUTES</span>\n                  <div class=\"attr-list\">\n                     <div v-for=\"attr in selectedOntologyItem.attributes\" :key=\"attr.name\" class=\"attr-item\">\n                        <span class=\"attr-name\">{{ attr.name }}</span>\n                        <span class=\"attr-type\">({{ attr.type }})</span>\n                        <span class=\"attr-desc\">{{ attr.description }}</span>\n                     </div>\n                  </div>\n               </div>\n\n               <!-- Examples (Entity) -->\n               <div class=\"detail-section\" v-if=\"selectedOntologyItem.examples?.length\">\n                  <span class=\"section-label\">EXAMPLES</span>\n                  <div class=\"example-list\">\n                     <span v-for=\"ex in selectedOntologyItem.examples\" :key=\"ex\" class=\"example-tag\">{{ ex }}</span>\n                  </div>\n               </div>\n\n               <!-- Source/Target (Relation) -->\n               <div class=\"detail-section\" v-if=\"selectedOntologyItem.source_targets?.length\">\n                  <span class=\"section-label\">CONNECTIONS</span>\n                  <div class=\"conn-list\">\n                     <div v-for=\"(conn, idx) in selectedOntologyItem.source_targets\" :key=\"idx\" class=\"conn-item\">\n                        <span class=\"conn-node\">{{ conn.source }}</span>\n                        <span class=\"conn-arrow\">→</span>\n                        <span class=\"conn-node\">{{ conn.target }}</span>\n                     </div>\n                  </div>\n               </div>\n            </div>\n          </div>\n\n          <!-- Generated Entity Tags -->\n          <div v-if=\"projectData?.ontology?.entity_types\" class=\"tags-container\" :class=\"{ 'dimmed': selectedOntologyItem }\">\n            <span class=\"tag-label\">GENERATED ENTITY TYPES</span>\n            <div class=\"tags-list\">\n              <span \n                v-for=\"entity in projectData.ontology.entity_types\" \n                :key=\"entity.name\" \n                class=\"entity-tag clickable\"\n                @click=\"selectOntologyItem(entity, 'entity')\"\n              >\n                {{ entity.name }}\n              </span>\n            </div>\n          </div>\n\n          <!-- Generated Relation Tags -->\n          <div v-if=\"projectData?.ontology?.edge_types\" class=\"tags-container\" :class=\"{ 'dimmed': selectedOntologyItem }\">\n            <span class=\"tag-label\">GENERATED RELATION TYPES</span>\n            <div class=\"tags-list\">\n              <span \n                v-for=\"rel in projectData.ontology.edge_types\" \n                :key=\"rel.name\" \n                class=\"entity-tag clickable\"\n                @click=\"selectOntologyItem(rel, 'relation')\"\n              >\n                {{ rel.name }}\n              </span>\n            </div>\n          </div>\n        </div>\n      </div>\n\n      <!-- Step 02: Graph Build -->\n      <div class=\"step-card\" :class=\"{ 'active': currentPhase === 1, 'completed': currentPhase > 1 }\">\n        <div class=\"card-header\">\n          <div class=\"step-info\">\n            <span class=\"step-num\">02</span>\n            <span class=\"step-title\">GraphRAG构建</span>\n          </div>\n          <div class=\"step-status\">\n            <span v-if=\"currentPhase > 1\" class=\"badge success\">已完成</span>\n            <span v-else-if=\"currentPhase === 1\" class=\"badge processing\">{{ buildProgress?.progress || 0 }}%</span>\n            <span v-else class=\"badge pending\">等待</span>\n          </div>\n        </div>\n\n        <div class=\"card-content\">\n          <p class=\"api-note\">POST /api/graph/build</p>\n          <p class=\"description\">\n            基于生成的本体，将文档自动分块后调用 Zep 构建知识图谱，提取实体和关系，并形成时序记忆与社区摘要\n          </p>\n          \n          <!-- Stats Cards -->\n          <div class=\"stats-grid\">\n            <div class=\"stat-card\">\n              <span class=\"stat-value\">{{ graphStats.nodes }}</span>\n              <span class=\"stat-label\">实体节点</span>\n            </div>\n            <div class=\"stat-card\">\n              <span class=\"stat-value\">{{ graphStats.edges }}</span>\n              <span class=\"stat-label\">关系边</span>\n            </div>\n            <div class=\"stat-card\">\n              <span class=\"stat-value\">{{ graphStats.types }}</span>\n              <span class=\"stat-label\">SCHEMA类型</span>\n            </div>\n          </div>\n        </div>\n      </div>\n\n      <!-- Step 03: Complete -->\n      <div class=\"step-card\" :class=\"{ 'active': currentPhase === 2, 'completed': currentPhase >= 2 }\">\n        <div class=\"card-header\">\n          <div class=\"step-info\">\n            <span class=\"step-num\">03</span>\n            <span class=\"step-title\">构建完成</span>\n          </div>\n          <div class=\"step-status\">\n            <span v-if=\"currentPhase >= 2\" class=\"badge accent\">进行中</span>\n          </div>\n        </div>\n        \n        <div class=\"card-content\">\n          <p class=\"api-note\">POST /api/simulation/create</p>\n          <p class=\"description\">图谱构建已完成，请进入下一步进行模拟环境搭建</p>\n          <button \n            class=\"action-btn\" \n            :disabled=\"currentPhase < 2 || creatingSimulation\"\n            @click=\"handleEnterEnvSetup\"\n          >\n            <span v-if=\"creatingSimulation\" class=\"spinner-sm\"></span>\n            {{ creatingSimulation ? '创建中...' : '进入环境搭建 ➝' }}\n          </button>\n        </div>\n      </div>\n    </div>\n\n    <!-- Bottom Info / Logs -->\n    <div class=\"system-logs\">\n      <div class=\"log-header\">\n        <span class=\"log-title\">SYSTEM DASHBOARD</span>\n        <span class=\"log-id\">{{ projectData?.project_id || 'NO_PROJECT' }}</span>\n      </div>\n      <div class=\"log-content\" ref=\"logContent\">\n        <div class=\"log-line\" v-for=\"(log, idx) in systemLogs\" :key=\"idx\">\n          <span class=\"log-time\">{{ log.time }}</span>\n          <span class=\"log-msg\">{{ log.msg }}</span>\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { computed, ref, watch, nextTick } from 'vue'\nimport { useRouter } from 'vue-router'\nimport { createSimulation } from '../api/simulation'\n\nconst router = useRouter()\n\nconst props = defineProps({\n  currentPhase: { type: Number, default: 0 },\n  projectData: Object,\n  ontologyProgress: Object,\n  buildProgress: Object,\n  graphData: Object,\n  systemLogs: { type: Array, default: () => [] }\n})\n\ndefineEmits(['next-step'])\n\nconst selectedOntologyItem = ref(null)\nconst logContent = ref(null)\nconst creatingSimulation = ref(false)\n\n// 进入环境搭建 - 创建 simulation 并跳转\nconst handleEnterEnvSetup = async () => {\n  if (!props.projectData?.project_id || !props.projectData?.graph_id) {\n    console.error('缺少项目或图谱信息')\n    return\n  }\n  \n  creatingSimulation.value = true\n  \n  try {\n    const res = await createSimulation({\n      project_id: props.projectData.project_id,\n      graph_id: props.projectData.graph_id,\n      enable_twitter: true,\n      enable_reddit: true\n    })\n    \n    if (res.success && res.data?.simulation_id) {\n      // 跳转到 simulation 页面\n      router.push({\n        name: 'Simulation',\n        params: { simulationId: res.data.simulation_id }\n      })\n    } else {\n      console.error('创建模拟失败:', res.error)\n      alert('创建模拟失败: ' + (res.error || '未知错误'))\n    }\n  } catch (err) {\n    console.error('创建模拟异常:', err)\n    alert('创建模拟异常: ' + err.message)\n  } finally {\n    creatingSimulation.value = false\n  }\n}\n\nconst selectOntologyItem = (item, type) => {\n  selectedOntologyItem.value = { ...item, itemType: type }\n}\n\nconst graphStats = computed(() => {\n  const nodes = props.graphData?.node_count || props.graphData?.nodes?.length || 0\n  const edges = props.graphData?.edge_count || props.graphData?.edges?.length || 0\n  const types = props.projectData?.ontology?.entity_types?.length || 0\n  return { nodes, edges, types }\n})\n\nconst formatDate = (dateStr) => {\n  if (!dateStr) return '--:--:--'\n  const d = new Date(dateStr)\n  return d.toLocaleTimeString('en-US', { hour12: false }) + '.' + d.getMilliseconds()\n}\n\n// Auto-scroll logs\nwatch(() => props.systemLogs.length, () => {\n  nextTick(() => {\n    if (logContent.value) {\n      logContent.value.scrollTop = logContent.value.scrollHeight\n    }\n  })\n})\n</script>\n\n<style scoped>\n.workbench-panel {\n  height: 100%;\n  background-color: #FAFAFA;\n  display: flex;\n  flex-direction: column;\n  position: relative;\n  overflow: hidden;\n}\n\n.scroll-container {\n  flex: 1;\n  overflow-y: auto;\n  padding: 24px;\n  display: flex;\n  flex-direction: column;\n  gap: 20px;\n}\n\n.step-card {\n  background: #FFF;\n  border-radius: 8px;\n  padding: 20px;\n  box-shadow: 0 2px 8px rgba(0,0,0,0.04);\n  border: 1px solid #EAEAEA;\n  transition: all 0.3s ease;\n  position: relative; /* For absolute overlay */\n}\n\n.step-card.active {\n  border-color: #FF5722;\n  box-shadow: 0 4px 12px rgba(255, 87, 34, 0.08);\n}\n\n.card-header {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  margin-bottom: 16px;\n}\n\n.step-info {\n  display: flex;\n  align-items: center;\n  gap: 12px;\n}\n\n.step-num {\n  font-family: 'JetBrains Mono', monospace;\n  font-size: 20px;\n  font-weight: 700;\n  color: #E0E0E0;\n}\n\n.step-card.active .step-num,\n.step-card.completed .step-num {\n  color: #000;\n}\n\n.step-title {\n  font-weight: 600;\n  font-size: 14px;\n  letter-spacing: 0.5px;\n}\n\n.badge {\n  font-size: 10px;\n  padding: 4px 8px;\n  border-radius: 4px;\n  font-weight: 600;\n  text-transform: uppercase;\n}\n\n.badge.success { background: #E8F5E9; color: #2E7D32; }\n.badge.processing { background: #FF5722; color: #FFF; }\n.badge.accent { background: #FF5722; color: #FFF; }\n.badge.pending { background: #F5F5F5; color: #999; }\n\n.api-note {\n  font-family: 'JetBrains Mono', monospace;\n  font-size: 10px;\n  color: #999;\n  margin-bottom: 8px;\n}\n\n.description {\n  font-size: 12px;\n  color: #666;\n  line-height: 1.5;\n  margin-bottom: 16px;\n}\n\n/* Step 01 Tags */\n.tags-container {\n  margin-top: 12px;\n  transition: opacity 0.3s;\n}\n\n.tags-container.dimmed {\n    opacity: 0.3;\n    pointer-events: none;\n}\n\n.tag-label {\n  display: block;\n  font-size: 10px;\n  color: #AAA;\n  margin-bottom: 8px;\n  font-weight: 600;\n}\n\n.tags-list {\n  display: flex;\n  flex-wrap: wrap;\n  gap: 8px;\n}\n\n.entity-tag {\n  background: #F5F5F5;\n  border: 1px solid #EEE;\n  padding: 4px 10px;\n  border-radius: 4px;\n  font-size: 11px;\n  color: #333;\n  font-family: 'JetBrains Mono', monospace;\n  transition: all 0.2s;\n}\n\n.entity-tag.clickable {\n    cursor: pointer;\n}\n\n.entity-tag.clickable:hover {\n    background: #E0E0E0;\n    border-color: #CCC;\n}\n\n/* Ontology Detail Overlay */\n.ontology-detail-overlay {\n    position: absolute;\n    top: 60px; /* Below header roughly */\n    left: 20px;\n    right: 20px;\n    bottom: 20px;\n    background: rgba(255, 255, 255, 0.98);\n    backdrop-filter: blur(4px);\n    z-index: 10;\n    border: 1px solid #EAEAEA;\n    box-shadow: 0 4px 20px rgba(0,0,0,0.05);\n    border-radius: 6px;\n    display: flex;\n    flex-direction: column;\n    overflow: hidden;\n    animation: fadeIn 0.2s ease-out;\n}\n\n@keyframes fadeIn { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: translateY(0); } }\n\n.detail-header {\n    display: flex;\n    justify-content: space-between;\n    align-items: center;\n    padding: 12px 16px;\n    border-bottom: 1px solid #EAEAEA;\n    background: #FAFAFA;\n}\n\n.detail-title-group {\n    display: flex;\n    align-items: center;\n    gap: 8px;\n}\n\n.detail-type-badge {\n    font-size: 9px;\n    font-weight: 700;\n    color: #FFF;\n    background: #000;\n    padding: 2px 6px;\n    border-radius: 2px;\n    text-transform: uppercase;\n}\n\n.detail-name {\n    font-size: 14px;\n    font-weight: 700;\n    font-family: 'JetBrains Mono', monospace;\n}\n\n.close-btn {\n    background: none;\n    border: none;\n    font-size: 18px;\n    color: #999;\n    cursor: pointer;\n    line-height: 1;\n}\n\n.close-btn:hover {\n    color: #333;\n}\n\n.detail-body {\n    flex: 1;\n    overflow-y: auto;\n    padding: 16px;\n}\n\n.detail-desc {\n    font-size: 12px;\n    color: #444;\n    line-height: 1.5;\n    margin-bottom: 16px;\n    padding-bottom: 12px;\n    border-bottom: 1px dashed #EAEAEA;\n}\n\n.detail-section {\n    margin-bottom: 16px;\n}\n\n.section-label {\n    display: block;\n    font-size: 10px;\n    font-weight: 600;\n    color: #AAA;\n    margin-bottom: 8px;\n}\n\n.attr-list, .conn-list {\n    display: flex;\n    flex-direction: column;\n    gap: 6px;\n}\n\n.attr-item {\n    font-size: 11px;\n    display: flex;\n    flex-wrap: wrap;\n    gap: 6px;\n    align-items: baseline;\n    padding: 4px;\n    background: #F9F9F9;\n    border-radius: 4px;\n}\n\n.attr-name {\n    font-family: 'JetBrains Mono', monospace;\n    font-weight: 600;\n    color: #000;\n}\n\n.attr-type {\n    color: #999;\n    font-size: 10px;\n}\n\n.attr-desc {\n    color: #555;\n    flex: 1;\n    min-width: 150px;\n}\n\n.example-list {\n    display: flex;\n    flex-wrap: wrap;\n    gap: 6px;\n}\n\n.example-tag {\n    font-size: 11px;\n    background: #FFF;\n    border: 1px solid #E0E0E0;\n    padding: 3px 8px;\n    border-radius: 12px;\n    color: #555;\n}\n\n.conn-item {\n    display: flex;\n    align-items: center;\n    gap: 8px;\n    font-size: 11px;\n    padding: 6px;\n    background: #F5F5F5;\n    border-radius: 4px;\n    font-family: 'JetBrains Mono', monospace;\n}\n\n.conn-node {\n    font-weight: 600;\n    color: #333;\n}\n\n.conn-arrow {\n    color: #BBB;\n}\n\n/* Step 02 Stats */\n.stats-grid {\n  display: grid;\n  grid-template-columns: 1fr 1fr 1fr;\n  gap: 12px;\n  background: #F9F9F9;\n  padding: 16px;\n  border-radius: 6px;\n}\n\n.stat-card {\n  text-align: center;\n}\n\n.stat-value {\n  display: block;\n  font-size: 20px;\n  font-weight: 700;\n  color: #000;\n  font-family: 'JetBrains Mono', monospace;\n}\n\n.stat-label {\n  font-size: 9px;\n  color: #999;\n  text-transform: uppercase;\n  margin-top: 4px;\n  display: block;\n}\n\n/* Step 03 Button */\n.action-btn {\n  width: 100%;\n  background: #000;\n  color: #FFF;\n  border: none;\n  padding: 14px;\n  border-radius: 4px;\n  font-size: 12px;\n  font-weight: 600;\n  cursor: pointer;\n  transition: opacity 0.2s;\n}\n\n.action-btn:hover:not(:disabled) {\n  opacity: 0.8;\n}\n\n.action-btn:disabled {\n  background: #CCC;\n  cursor: not-allowed;\n}\n\n.progress-section {\n  display: flex;\n  align-items: center;\n  gap: 10px;\n  font-size: 12px;\n  color: #FF5722;\n  margin-bottom: 12px;\n}\n\n.spinner-sm {\n  width: 14px;\n  height: 14px;\n  border: 2px solid #FFCCBC;\n  border-top-color: #FF5722;\n  border-radius: 50%;\n  animation: spin 1s linear infinite;\n}\n\n@keyframes spin { to { transform: rotate(360deg); } }\n\n/* System Logs */\n.system-logs {\n  background: #000;\n  color: #DDD;\n  padding: 16px;\n  font-family: 'JetBrains Mono', monospace;\n  border-top: 1px solid #222;\n  flex-shrink: 0;\n}\n\n.log-header {\n  display: flex;\n  justify-content: space-between;\n  border-bottom: 1px solid #333;\n  padding-bottom: 8px;\n  margin-bottom: 8px;\n  font-size: 10px;\n  color: #888;\n}\n\n.log-content {\n  display: flex;\n  flex-direction: column;\n  gap: 4px;\n  height: 80px; /* Approx 4 lines visible */\n  overflow-y: auto;\n  padding-right: 4px;\n}\n\n.log-content::-webkit-scrollbar {\n  width: 4px;\n}\n\n.log-content::-webkit-scrollbar-thumb {\n  background: #333;\n  border-radius: 2px;\n}\n\n.log-line {\n  font-size: 11px;\n  display: flex;\n  gap: 12px;\n  line-height: 1.5;\n}\n\n.log-time {\n  color: #666;\n  min-width: 75px;\n}\n\n.log-msg {\n  color: #CCC;\n  word-break: break-all;\n}\n</style>\n"
  },
  {
    "path": "frontend/src/components/Step2EnvSetup.vue",
    "content": "<template>\n  <div class=\"env-setup-panel\">\n    <div class=\"scroll-container\">\n      <!-- Step 01: 模拟实例 -->\n      <div class=\"step-card\" :class=\"{ 'active': phase === 0, 'completed': phase > 0 }\">\n        <div class=\"card-header\">\n          <div class=\"step-info\">\n            <span class=\"step-num\">01</span>\n            <span class=\"step-title\">模拟实例初始化</span>\n          </div>\n          <div class=\"step-status\">\n            <span v-if=\"phase > 0\" class=\"badge success\">已完成</span>\n            <span v-else class=\"badge processing\">初始化</span>\n          </div>\n        </div>\n        \n        <div class=\"card-content\">\n          <p class=\"api-note\">POST /api/simulation/create</p>\n          <p class=\"description\">\n            新建simulation实例，拉取模拟世界参数模版\n          </p>\n\n          <div v-if=\"simulationId\" class=\"info-card\">\n            <div class=\"info-row\">\n              <span class=\"info-label\">Project ID</span>\n              <span class=\"info-value mono\">{{ projectData?.project_id }}</span>\n            </div>\n            <div class=\"info-row\">\n              <span class=\"info-label\">Graph ID</span>\n              <span class=\"info-value mono\">{{ projectData?.graph_id }}</span>\n            </div>\n            <div class=\"info-row\">\n              <span class=\"info-label\">Simulation ID</span>\n              <span class=\"info-value mono\">{{ simulationId }}</span>\n            </div>\n            <div class=\"info-row\">\n              <span class=\"info-label\">Task ID</span>\n              <span class=\"info-value mono\">{{ taskId || '异步任务已完成' }}</span>\n            </div>\n          </div>\n        </div>\n      </div>\n\n      <!-- Step 02: 生成 Agent 人设 -->\n      <div class=\"step-card\" :class=\"{ 'active': phase === 1, 'completed': phase > 1 }\">\n        <div class=\"card-header\">\n          <div class=\"step-info\">\n            <span class=\"step-num\">02</span>\n            <span class=\"step-title\">生成 Agent 人设</span>\n          </div>\n          <div class=\"step-status\">\n            <span v-if=\"phase > 1\" class=\"badge success\">已完成</span>\n            <span v-else-if=\"phase === 1\" class=\"badge processing\">{{ prepareProgress }}%</span>\n            <span v-else class=\"badge pending\">等待</span>\n          </div>\n        </div>\n\n        <div class=\"card-content\">\n          <p class=\"api-note\">POST /api/simulation/prepare</p>\n          <p class=\"description\">\n            结合上下文，自动调用工具从知识图谱梳理实体与关系，初始化模拟个体，并基于现实种子赋予他们独特的行为与记忆\n          </p>\n\n          <!-- Profiles Stats -->\n          <div v-if=\"profiles.length > 0\" class=\"stats-grid\">\n            <div class=\"stat-card\">\n              <span class=\"stat-value\">{{ profiles.length }}</span>\n              <span class=\"stat-label\">当前Agent数</span>\n            </div>\n            <div class=\"stat-card\">\n              <span class=\"stat-value\">{{ expectedTotal || '-' }}</span>\n              <span class=\"stat-label\">预期Agent总数</span>\n            </div>\n            <div class=\"stat-card\">\n              <span class=\"stat-value\">{{ totalTopicsCount }}</span>\n              <span class=\"stat-label\">现实种子当前关联话题数</span>\n            </div>\n          </div>\n\n          <!-- Profiles List Preview -->\n          <div v-if=\"profiles.length > 0\" class=\"profiles-preview\">\n            <div class=\"preview-header\">\n              <span class=\"preview-title\">已生成的 Agent 人设</span>\n            </div>\n            <div class=\"profiles-list\">\n              <div \n                v-for=\"(profile, idx) in profiles\" \n                :key=\"idx\" \n                class=\"profile-card\"\n                @click=\"selectProfile(profile)\"\n              >\n                <div class=\"profile-header\">\n                  <span class=\"profile-realname\">{{ profile.username || 'Unknown' }}</span>\n                  <span class=\"profile-username\">@{{ profile.name || `agent_${idx}` }}</span>\n                </div>\n                <div class=\"profile-meta\">\n                  <span class=\"profile-profession\">{{ profile.profession || '未知职业' }}</span>\n                </div>\n                <p class=\"profile-bio\">{{ profile.bio || '暂无简介' }}</p>\n                <div v-if=\"profile.interested_topics?.length\" class=\"profile-topics\">\n                  <span \n                    v-for=\"topic in profile.interested_topics.slice(0, 3)\" \n                    :key=\"topic\" \n                    class=\"topic-tag\"\n                  >{{ topic }}</span>\n                  <span v-if=\"profile.interested_topics.length > 3\" class=\"topic-more\">\n                    +{{ profile.interested_topics.length - 3 }}\n                  </span>\n                </div>\n              </div>\n            </div>\n          </div>\n        </div>\n      </div>\n\n      <!-- Step 03: 生成双平台模拟配置 -->\n      <div class=\"step-card\" :class=\"{ 'active': phase === 2, 'completed': phase > 2 }\">\n        <div class=\"card-header\">\n          <div class=\"step-info\">\n            <span class=\"step-num\">03</span>\n            <span class=\"step-title\">生成双平台模拟配置</span>\n          </div>\n          <div class=\"step-status\">\n            <span v-if=\"phase > 2\" class=\"badge success\">已完成</span>\n            <span v-else-if=\"phase === 2\" class=\"badge processing\">生成中</span>\n            <span v-else class=\"badge pending\">等待</span>\n          </div>\n        </div>\n\n        <div class=\"card-content\">\n          <p class=\"api-note\">POST /api/simulation/prepare</p>\n          <p class=\"description\">\n            LLM 根据模拟需求与现实种子，智能设置世界时间流速、推荐算法、每个个体的活跃时间段、发言频率、事件触发等参数\n          </p>\n          \n          <!-- Config Preview -->\n          <div v-if=\"simulationConfig\" class=\"config-detail-panel\">\n            <!-- 时间配置 -->\n            <div class=\"config-block\">\n              <div class=\"config-grid\">\n                <div class=\"config-item\">\n                  <span class=\"config-item-label\">模拟时长</span>\n                  <span class=\"config-item-value\">{{ simulationConfig.time_config?.total_simulation_hours || '-' }} 小时</span>\n                </div>\n                <div class=\"config-item\">\n                  <span class=\"config-item-label\">每轮时长</span>\n                  <span class=\"config-item-value\">{{ simulationConfig.time_config?.minutes_per_round || '-' }} 分钟</span>\n                </div>\n                <div class=\"config-item\">\n                  <span class=\"config-item-label\">总轮次</span>\n                  <span class=\"config-item-value\">{{ Math.floor((simulationConfig.time_config?.total_simulation_hours * 60 / simulationConfig.time_config?.minutes_per_round)) || '-' }} 轮</span>\n                </div>\n                <div class=\"config-item\">\n                  <span class=\"config-item-label\">每小时活跃</span>\n                  <span class=\"config-item-value\">{{ simulationConfig.time_config?.agents_per_hour_min }}-{{ simulationConfig.time_config?.agents_per_hour_max }}</span>\n                </div>\n              </div>\n              <div class=\"time-periods\">\n                <div class=\"period-item\">\n                  <span class=\"period-label\">高峰时段</span>\n                  <span class=\"period-hours\">{{ simulationConfig.time_config?.peak_hours?.join(':00, ') }}:00</span>\n                  <span class=\"period-multiplier\">×{{ simulationConfig.time_config?.peak_activity_multiplier }}</span>\n                </div>\n                <div class=\"period-item\">\n                  <span class=\"period-label\">工作时段</span>\n                  <span class=\"period-hours\">{{ simulationConfig.time_config?.work_hours?.[0] }}:00-{{ simulationConfig.time_config?.work_hours?.slice(-1)[0] }}:00</span>\n                  <span class=\"period-multiplier\">×{{ simulationConfig.time_config?.work_activity_multiplier }}</span>\n                </div>\n                <div class=\"period-item\">\n                  <span class=\"period-label\">早间时段</span>\n                  <span class=\"period-hours\">{{ simulationConfig.time_config?.morning_hours?.[0] }}:00-{{ simulationConfig.time_config?.morning_hours?.slice(-1)[0] }}:00</span>\n                  <span class=\"period-multiplier\">×{{ simulationConfig.time_config?.morning_activity_multiplier }}</span>\n                </div>\n                <div class=\"period-item\">\n                  <span class=\"period-label\">低谷时段</span>\n                  <span class=\"period-hours\">{{ simulationConfig.time_config?.off_peak_hours?.[0] }}:00-{{ simulationConfig.time_config?.off_peak_hours?.slice(-1)[0] }}:00</span>\n                  <span class=\"period-multiplier\">×{{ simulationConfig.time_config?.off_peak_activity_multiplier }}</span>\n                </div>\n              </div>\n            </div>\n\n            <!-- Agent 配置 -->\n            <div class=\"config-block\">\n              <div class=\"config-block-header\">\n                <span class=\"config-block-title\">Agent 配置</span>\n                <span class=\"config-block-badge\">{{ simulationConfig.agent_configs?.length || 0 }} 个</span>\n              </div>\n              <div class=\"agents-cards\">\n                <div \n                  v-for=\"agent in simulationConfig.agent_configs\" \n                  :key=\"agent.agent_id\" \n                  class=\"agent-card\"\n                >\n                  <!-- 卡片头部 -->\n                  <div class=\"agent-card-header\">\n                    <div class=\"agent-identity\">\n                      <span class=\"agent-id\">Agent {{ agent.agent_id }}</span>\n                      <span class=\"agent-name\">{{ agent.entity_name }}</span>\n                    </div>\n                    <div class=\"agent-tags\">\n                      <span class=\"agent-type\">{{ agent.entity_type }}</span>\n                      <span class=\"agent-stance\" :class=\"'stance-' + agent.stance\">{{ agent.stance }}</span>\n                    </div>\n                  </div>\n                  \n                  <!-- 活跃时间轴 -->\n                  <div class=\"agent-timeline\">\n                    <span class=\"timeline-label\">活跃时段</span>\n                    <div class=\"mini-timeline\">\n                      <div \n                        v-for=\"hour in 24\" \n                        :key=\"hour - 1\" \n                        class=\"timeline-hour\"\n                        :class=\"{ 'active': agent.active_hours?.includes(hour - 1) }\"\n                        :title=\"`${hour - 1}:00`\"\n                      ></div>\n                    </div>\n                    <div class=\"timeline-marks\">\n                      <span>0</span>\n                      <span>6</span>\n                      <span>12</span>\n                      <span>18</span>\n                      <span>24</span>\n                    </div>\n                  </div>\n\n                  <!-- 行为参数 -->\n                  <div class=\"agent-params\">\n                    <div class=\"param-group\">\n                      <div class=\"param-item\">\n                        <span class=\"param-label\">发帖/时</span>\n                        <span class=\"param-value\">{{ agent.posts_per_hour }}</span>\n                      </div>\n                      <div class=\"param-item\">\n                        <span class=\"param-label\">评论/时</span>\n                        <span class=\"param-value\">{{ agent.comments_per_hour }}</span>\n                      </div>\n                      <div class=\"param-item\">\n                        <span class=\"param-label\">响应延迟</span>\n                        <span class=\"param-value\">{{ agent.response_delay_min }}-{{ agent.response_delay_max }}min</span>\n                      </div>\n                    </div>\n                    <div class=\"param-group\">\n                      <div class=\"param-item\">\n                        <span class=\"param-label\">活跃度</span>\n                        <span class=\"param-value with-bar\">\n                          <span class=\"mini-bar\" :style=\"{ width: (agent.activity_level * 100) + '%' }\"></span>\n                          {{ (agent.activity_level * 100).toFixed(0) }}%\n                        </span>\n                      </div>\n                      <div class=\"param-item\">\n                        <span class=\"param-label\">情感倾向</span>\n                        <span class=\"param-value\" :class=\"agent.sentiment_bias > 0 ? 'positive' : agent.sentiment_bias < 0 ? 'negative' : 'neutral'\">\n                          {{ agent.sentiment_bias > 0 ? '+' : '' }}{{ agent.sentiment_bias?.toFixed(1) }}\n                        </span>\n                      </div>\n                      <div class=\"param-item\">\n                        <span class=\"param-label\">影响力</span>\n                        <span class=\"param-value highlight\">{{ agent.influence_weight?.toFixed(1) }}</span>\n                      </div>\n                    </div>\n                  </div>\n                </div>\n              </div>\n            </div>\n\n            <!-- 平台配置 -->\n            <div class=\"config-block\">\n              <div class=\"config-block-header\">\n                <span class=\"config-block-title\">推荐算法配置</span>\n              </div>\n              <div class=\"platforms-grid\">\n                <div v-if=\"simulationConfig.twitter_config\" class=\"platform-card\">\n                  <div class=\"platform-card-header\">\n                    <span class=\"platform-name\">平台 1：广场 / 信息流</span>\n                  </div>\n                  <div class=\"platform-params\">\n                    <div class=\"param-row\">\n                      <span class=\"param-label\">时效权重</span>\n                      <span class=\"param-value\">{{ simulationConfig.twitter_config.recency_weight }}</span>\n                    </div>\n                    <div class=\"param-row\">\n                      <span class=\"param-label\">热度权重</span>\n                      <span class=\"param-value\">{{ simulationConfig.twitter_config.popularity_weight }}</span>\n                    </div>\n                    <div class=\"param-row\">\n                      <span class=\"param-label\">相关性权重</span>\n                      <span class=\"param-value\">{{ simulationConfig.twitter_config.relevance_weight }}</span>\n                    </div>\n                    <div class=\"param-row\">\n                      <span class=\"param-label\">病毒阈值</span>\n                      <span class=\"param-value\">{{ simulationConfig.twitter_config.viral_threshold }}</span>\n                    </div>\n                    <div class=\"param-row\">\n                      <span class=\"param-label\">回音室强度</span>\n                      <span class=\"param-value\">{{ simulationConfig.twitter_config.echo_chamber_strength }}</span>\n                    </div>\n                  </div>\n                </div>\n                <div v-if=\"simulationConfig.reddit_config\" class=\"platform-card\">\n                  <div class=\"platform-card-header\">\n                    <span class=\"platform-name\">平台 2：话题 / 社区</span>\n                  </div>\n                  <div class=\"platform-params\">\n                    <div class=\"param-row\">\n                      <span class=\"param-label\">时效权重</span>\n                      <span class=\"param-value\">{{ simulationConfig.reddit_config.recency_weight }}</span>\n                    </div>\n                    <div class=\"param-row\">\n                      <span class=\"param-label\">热度权重</span>\n                      <span class=\"param-value\">{{ simulationConfig.reddit_config.popularity_weight }}</span>\n                    </div>\n                    <div class=\"param-row\">\n                      <span class=\"param-label\">相关性权重</span>\n                      <span class=\"param-value\">{{ simulationConfig.reddit_config.relevance_weight }}</span>\n                    </div>\n                    <div class=\"param-row\">\n                      <span class=\"param-label\">病毒阈值</span>\n                      <span class=\"param-value\">{{ simulationConfig.reddit_config.viral_threshold }}</span>\n                    </div>\n                    <div class=\"param-row\">\n                      <span class=\"param-label\">回音室强度</span>\n                      <span class=\"param-value\">{{ simulationConfig.reddit_config.echo_chamber_strength }}</span>\n                    </div>\n                  </div>\n                </div>\n              </div>\n            </div>\n\n            <!-- LLM 配置推理 -->\n            <div v-if=\"simulationConfig.generation_reasoning\" class=\"config-block\">\n              <div class=\"config-block-header\">\n                <span class=\"config-block-title\">LLM 配置推理</span>\n              </div>\n              <div class=\"reasoning-content\">\n                <div \n                  v-for=\"(reason, idx) in simulationConfig.generation_reasoning.split('|').slice(0, 2)\" \n                  :key=\"idx\" \n                  class=\"reasoning-item\"\n                >\n                  <p class=\"reasoning-text\">{{ reason.trim() }}</p>\n                </div>\n              </div>\n            </div>\n          </div>\n        </div>\n      </div>\n\n      <!-- Step 04: 初始激活编排 -->\n      <div class=\"step-card\" :class=\"{ 'active': phase === 3, 'completed': phase > 3 }\">\n        <div class=\"card-header\">\n          <div class=\"step-info\">\n            <span class=\"step-num\">04</span>\n            <span class=\"step-title\">初始激活编排</span>\n          </div>\n          <div class=\"step-status\">\n            <span v-if=\"phase > 3\" class=\"badge success\">已完成</span>\n            <span v-else-if=\"phase === 3\" class=\"badge processing\">编排中</span>\n            <span v-else class=\"badge pending\">等待</span>\n          </div>\n        </div>\n\n        <div class=\"card-content\">\n          <p class=\"api-note\">POST /api/simulation/prepare</p>\n          <p class=\"description\">\n            基于叙事方向，自动生成初始激活事件与热点话题，引导模拟世界的初始状态\n          </p>\n\n          <div v-if=\"simulationConfig?.event_config\" class=\"orchestration-content\">\n            <!-- 叙事方向 -->\n            <div class=\"narrative-box\">\n              <span class=\"box-label narrative-label\">\n                <svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\" class=\"special-icon\">\n                  <path d=\"M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z\" stroke=\"url(#paint0_linear)\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n                  <path d=\"M16.24 7.76L14.12 14.12L7.76 16.24L9.88 9.88L16.24 7.76Z\" fill=\"url(#paint0_linear)\" stroke=\"url(#paint0_linear)\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n                  <defs>\n                    <linearGradient id=\"paint0_linear\" x1=\"2\" y1=\"2\" x2=\"22\" y2=\"22\" gradientUnits=\"userSpaceOnUse\">\n                      <stop stop-color=\"#FF5722\"/>\n                      <stop offset=\"1\" stop-color=\"#FF9800\"/>\n                    </linearGradient>\n                  </defs>\n                </svg>\n                叙事引导方向\n              </span>\n              <p class=\"narrative-text\">{{ simulationConfig.event_config.narrative_direction }}</p>\n            </div>\n\n            <!-- 热点话题 -->\n            <div class=\"topics-section\">\n              <span class=\"box-label\">初始热点话题</span>\n              <div class=\"hot-topics-grid\">\n                <span v-for=\"topic in simulationConfig.event_config.hot_topics\" :key=\"topic\" class=\"hot-topic-tag\">\n                  # {{ topic }}\n                </span>\n              </div>\n            </div>\n\n            <!-- 初始帖子流 -->\n            <div class=\"initial-posts-section\">\n              <span class=\"box-label\">初始激活序列 ({{ simulationConfig.event_config.initial_posts.length }})</span>\n              <div class=\"posts-timeline\">\n                <div v-for=\"(post, idx) in simulationConfig.event_config.initial_posts\" :key=\"idx\" class=\"timeline-item\">\n                  <div class=\"timeline-marker\"></div>\n                  <div class=\"timeline-content\">\n                    <div class=\"post-header\">\n                      <span class=\"post-role\">{{ post.poster_type }}</span>\n                      <span class=\"post-agent-info\">\n                        <span class=\"post-id\">Agent {{ post.poster_agent_id }}</span>\n                        <span class=\"post-username\">@{{ getAgentUsername(post.poster_agent_id) }}</span>\n                      </span>\n                    </div>\n                    <p class=\"post-text\">{{ post.content }}</p>\n                  </div>\n                </div>\n              </div>\n            </div>\n          </div>\n        </div>\n      </div>\n\n      <!-- Step 05: 准备完成 -->\n      <div class=\"step-card\" :class=\"{ 'active': phase === 4 }\">\n        <div class=\"card-header\">\n          <div class=\"step-info\">\n            <span class=\"step-num\">05</span>\n            <span class=\"step-title\">准备完成</span>\n          </div>\n          <div class=\"step-status\">\n            <span v-if=\"phase >= 4\" class=\"badge processing\">进行中</span>\n            <span v-else class=\"badge pending\">等待</span>\n          </div>\n        </div>\n\n        <div class=\"card-content\">\n          <p class=\"api-note\">POST /api/simulation/start</p>\n          <p class=\"description\">模拟环境已准备完成，可以开始运行模拟</p>\n          \n          <!-- 模拟轮数配置 - 只有在配置生成完成且轮数计算出来后才显示 -->\n          <div v-if=\"simulationConfig && autoGeneratedRounds\" class=\"rounds-config-section\">\n            <div class=\"rounds-header\">\n              <div class=\"header-left\">\n                <span class=\"section-title\">模拟轮数设定</span>\n                <span class=\"section-desc\">MiroFish 自动规划推演现实 <span class=\"desc-highlight\">{{ simulationConfig?.time_config?.total_simulation_hours || '-' }}</span> 小时，每轮代表现实 <span class=\"desc-highlight\">{{ simulationConfig?.time_config?.minutes_per_round || '-' }}</span> 分钟时间流逝</span>\n              </div>\n              <label class=\"switch-control\">\n                <input type=\"checkbox\" v-model=\"useCustomRounds\">\n                <span class=\"switch-track\"></span>\n                <span class=\"switch-label\">自定义</span>\n              </label>\n            </div>\n            \n            <Transition name=\"fade\" mode=\"out-in\">\n              <div v-if=\"useCustomRounds\" class=\"rounds-content custom\" key=\"custom\">\n                <div class=\"slider-display\">\n                  <div class=\"slider-main-value\">\n                    <span class=\"val-num\">{{ customMaxRounds }}</span>\n                    <span class=\"val-unit\">轮</span>\n                  </div>\n                  <div class=\"slider-meta-info\">\n                    <span>若Agent规模为100：预计耗时约 {{ Math.round(customMaxRounds * 0.6) }} 分钟</span>\n                  </div>\n                </div>\n\n                <div class=\"range-wrapper\">\n                  <input \n                    type=\"range\" \n                    v-model.number=\"customMaxRounds\" \n                    min=\"10\" \n                    :max=\"autoGeneratedRounds\"\n                    step=\"5\"\n                    class=\"minimal-slider\"\n                    :style=\"{ '--percent': ((customMaxRounds - 10) / (autoGeneratedRounds - 10)) * 100 + '%' }\"\n                  />\n                  <div class=\"range-marks\">\n                    <span>10</span>\n                    <span \n                      class=\"mark-recommend\" \n                      :class=\"{ active: customMaxRounds === 40 }\"\n                      @click=\"customMaxRounds = 40\"\n                      :style=\"{ position: 'absolute', left: `calc(${(40 - 10) / (autoGeneratedRounds - 10) * 100}% - 30px)` }\"\n                    >40 (推荐)</span>\n                    <span>{{ autoGeneratedRounds }}</span>\n                  </div>\n                </div>\n              </div>\n              \n              <div v-else class=\"rounds-content auto\" key=\"auto\">\n                <div class=\"auto-info-card\">\n                  <div class=\"auto-value\">\n                    <span class=\"val-num\">{{ autoGeneratedRounds }}</span>\n                    <span class=\"val-unit\">轮</span>\n                  </div>\n                  <div class=\"auto-content\">\n                    <div class=\"auto-meta-row\">\n                      <span class=\"duration-badge\">\n                        <svg width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n                          <circle cx=\"12\" cy=\"12\" r=\"10\"></circle>\n                          <polyline points=\"12 6 12 12 16 14\"></polyline>\n                        </svg>\n                        若Agent规模为100：预计耗时 {{ Math.round(autoGeneratedRounds * 0.6) }} 分钟\n                      </span>\n                    </div>\n                    <div class=\"auto-desc\">\n                      <p class=\"highlight-tip\" @click=\"useCustomRounds = true\">若首次运行，强烈建议切换至‘自定义模式’减少模拟轮数，以便快速预览效果并降低报错风险 ➝</p>\n                    </div>\n                  </div>\n                </div>\n              </div>\n            </Transition>\n          </div>\n\n          <div class=\"action-group dual\">\n            <button \n              class=\"action-btn secondary\"\n              @click=\"$emit('go-back')\"\n            >\n              ← 返回图谱构建\n            </button>\n            <button \n              class=\"action-btn primary\"\n              :disabled=\"phase < 4\"\n              @click=\"handleStartSimulation\"\n            >\n              开始双世界并行模拟 ➝\n            </button>\n          </div>\n        </div>\n      </div>\n    </div>\n\n    <!-- Profile Detail Modal -->\n    <Transition name=\"modal\">\n      <div v-if=\"selectedProfile\" class=\"profile-modal-overlay\" @click.self=\"selectedProfile = null\">\n        <div class=\"profile-modal\">\n          <div class=\"modal-header\">\n          <div class=\"modal-header-info\">\n            <div class=\"modal-name-row\">\n              <span class=\"modal-realname\">{{ selectedProfile.username }}</span>\n              <span class=\"modal-username\">@{{ selectedProfile.name }}</span>\n            </div>\n            <span class=\"modal-profession\">{{ selectedProfile.profession }}</span>\n          </div>\n          <button class=\"close-btn\" @click=\"selectedProfile = null\">×</button>\n        </div>\n        \n        <div class=\"modal-body\">\n          <!-- 基本信息 -->\n          <div class=\"modal-info-grid\">\n            <div class=\"info-item\">\n              <span class=\"info-label\">事件外显年龄</span>\n              <span class=\"info-value\">{{ selectedProfile.age || '-' }} 岁</span>\n            </div>\n            <div class=\"info-item\">\n              <span class=\"info-label\">事件外显性别</span>\n              <span class=\"info-value\">{{ { male: '男', female: '女', other: '其他' }[selectedProfile.gender] || selectedProfile.gender }}</span>\n            </div>\n            <div class=\"info-item\">\n              <span class=\"info-label\">国家/地区</span>\n              <span class=\"info-value\">{{ selectedProfile.country || '-' }}</span>\n            </div>\n            <div class=\"info-item\">\n              <span class=\"info-label\">事件外显MBTI</span>\n              <span class=\"info-value mbti\">{{ selectedProfile.mbti || '-' }}</span>\n            </div>\n          </div>\n\n          <!-- 简介 -->\n          <div class=\"modal-section\">\n            <span class=\"section-label\">人设简介</span>\n            <p class=\"section-bio\">{{ selectedProfile.bio || '暂无简介' }}</p>\n          </div>\n\n          <!-- 关注话题 -->\n          <div class=\"modal-section\" v-if=\"selectedProfile.interested_topics?.length\">\n            <span class=\"section-label\">现实种子关联话题</span>\n            <div class=\"topics-grid\">\n              <span \n                v-for=\"topic in selectedProfile.interested_topics\" \n                :key=\"topic\" \n                class=\"topic-item\"\n              >{{ topic }}</span>\n            </div>\n          </div>\n\n          <!-- 详细人设 -->\n          <div class=\"modal-section\" v-if=\"selectedProfile.persona\">\n            <span class=\"section-label\">详细人设背景</span>\n            \n            <!-- 人设维度概览 -->\n            <div class=\"persona-dimensions\">\n              <div class=\"dimension-card\">\n                <span class=\"dim-title\">事件全景经历</span>\n                <span class=\"dim-desc\">在此事件中的完整行为轨迹</span>\n              </div>\n              <div class=\"dimension-card\">\n                <span class=\"dim-title\">行为模式侧写</span>\n                <span class=\"dim-desc\">经验总结与行事风格偏好</span>\n              </div>\n              <div class=\"dimension-card\">\n                <span class=\"dim-title\">独特记忆印记</span>\n                <span class=\"dim-desc\">基于现实种子形成的记忆</span>\n              </div>\n              <div class=\"dimension-card\">\n                <span class=\"dim-title\">社会关系网络</span>\n                <span class=\"dim-desc\">个体链接与交互图谱</span>\n              </div>\n            </div>\n\n            <div class=\"persona-content\">\n              <p class=\"section-persona\">{{ selectedProfile.persona }}</p>\n            </div>\n          </div>\n        </div>\n      </div>\n      </div>\n    </Transition>\n\n    <!-- Bottom Info / Logs -->\n    <div class=\"system-logs\">\n      <div class=\"log-header\">\n        <span class=\"log-title\">SYSTEM DASHBOARD</span>\n        <span class=\"log-id\">{{ simulationId || 'NO_SIMULATION' }}</span>\n      </div>\n      <div class=\"log-content\" ref=\"logContent\">\n        <div class=\"log-line\" v-for=\"(log, idx) in systemLogs\" :key=\"idx\">\n          <span class=\"log-time\">{{ log.time }}</span>\n          <span class=\"log-msg\">{{ log.msg }}</span>\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'\nimport { \n  prepareSimulation, \n  getPrepareStatus, \n  getSimulationProfilesRealtime,\n  getSimulationConfig,\n  getSimulationConfigRealtime \n} from '../api/simulation'\n\nconst props = defineProps({\n  simulationId: String,  // 从父组件传入\n  projectData: Object,\n  graphData: Object,\n  systemLogs: Array\n})\n\nconst emit = defineEmits(['go-back', 'next-step', 'add-log', 'update-status'])\n\n// State\nconst phase = ref(0) // 0: 初始化, 1: 生成人设, 2: 生成配置, 3: 完成\nconst taskId = ref(null)\nconst prepareProgress = ref(0)\nconst currentStage = ref('')\nconst progressMessage = ref('')\nconst profiles = ref([])\nconst entityTypes = ref([])\nconst expectedTotal = ref(null)\nconst simulationConfig = ref(null)\nconst selectedProfile = ref(null)\nconst showProfilesDetail = ref(true)\n\n// 日志去重：记录上一次输出的关键信息\nlet lastLoggedMessage = ''\nlet lastLoggedProfileCount = 0\nlet lastLoggedConfigStage = ''\n\n// 模拟轮数配置\nconst useCustomRounds = ref(false) // 默认使用自动配置轮数\nconst customMaxRounds = ref(40)   // 默认推荐40轮\n\n// Watch stage to update phase\nwatch(currentStage, (newStage) => {\n  if (newStage === '生成Agent人设' || newStage === 'generating_profiles') {\n    phase.value = 1\n  } else if (newStage === '生成模拟配置' || newStage === 'generating_config') {\n    phase.value = 2\n    // 进入配置生成阶段，开始轮询配置\n    if (!configTimer) {\n      addLog('开始生成双平台模拟配置...')\n      startConfigPolling()\n    }\n  } else if (newStage === '准备模拟脚本' || newStage === 'copying_scripts') {\n    phase.value = 2 // 仍属于配置阶段\n  }\n})\n\n// 从配置中计算自动生成的轮数（不使用硬编码默认值）\nconst autoGeneratedRounds = computed(() => {\n  if (!simulationConfig.value?.time_config) {\n    return null // 配置未生成时返回 null\n  }\n  const totalHours = simulationConfig.value.time_config.total_simulation_hours\n  const minutesPerRound = simulationConfig.value.time_config.minutes_per_round\n  if (!totalHours || !minutesPerRound) {\n    return null // 配置数据不完整时返回 null\n  }\n  const calculatedRounds = Math.floor((totalHours * 60) / minutesPerRound)\n  // 确保最大轮数不小于40（推荐值），避免滑动条范围异常\n  return Math.max(calculatedRounds, 40)\n})\n\n// Polling timer\nlet pollTimer = null\nlet profilesTimer = null\nlet configTimer = null\n\n// Computed\nconst displayProfiles = computed(() => {\n  if (showProfilesDetail.value) {\n    return profiles.value\n  }\n  return profiles.value.slice(0, 6)\n})\n\n// 根据agent_id获取对应的username\nconst getAgentUsername = (agentId) => {\n  if (profiles.value && profiles.value.length > agentId && agentId >= 0) {\n    const profile = profiles.value[agentId]\n    return profile?.username || `agent_${agentId}`\n  }\n  return `agent_${agentId}`\n}\n\n// 计算所有人设的关联话题总数\nconst totalTopicsCount = computed(() => {\n  return profiles.value.reduce((sum, p) => {\n    return sum + (p.interested_topics?.length || 0)\n  }, 0)\n})\n\n// Methods\nconst addLog = (msg) => {\n  emit('add-log', msg)\n}\n\n// 处理开始模拟按钮点击\nconst handleStartSimulation = () => {\n  // 构建传递给父组件的参数\n  const params = {}\n  \n  if (useCustomRounds.value) {\n    // 用户自定义轮数，传递 max_rounds 参数\n    params.maxRounds = customMaxRounds.value\n    addLog(`开始模拟，自定义轮数: ${customMaxRounds.value} 轮`)\n  } else {\n    // 用户选择保持自动生成的轮数，不传递 max_rounds 参数\n    addLog(`开始模拟，使用自动配置轮数: ${autoGeneratedRounds.value} 轮`)\n  }\n  \n  emit('next-step', params)\n}\n\nconst truncateBio = (bio) => {\n  if (bio.length > 80) {\n    return bio.substring(0, 80) + '...'\n  }\n  return bio\n}\n\nconst selectProfile = (profile) => {\n  selectedProfile.value = profile\n}\n\n// 自动开始准备模拟\nconst startPrepareSimulation = async () => {\n  if (!props.simulationId) {\n    addLog('错误：缺少 simulationId')\n    emit('update-status', 'error')\n    return\n  }\n  \n  // 标记第一步完成，开始第二步\n  phase.value = 1\n  addLog(`模拟实例已创建: ${props.simulationId}`)\n  addLog('正在准备模拟环境...')\n  emit('update-status', 'processing')\n  \n  try {\n    const res = await prepareSimulation({\n      simulation_id: props.simulationId,\n      use_llm_for_profiles: true,\n      parallel_profile_count: 5\n    })\n    \n    if (res.success && res.data) {\n      if (res.data.already_prepared) {\n        addLog('检测到已有完成的准备工作，直接使用')\n        await loadPreparedData()\n        return\n      }\n      \n      taskId.value = res.data.task_id\n      addLog(`准备任务已启动`)\n      addLog(`  └─ Task ID: ${res.data.task_id}`)\n      \n      // 立即设置预期Agent总数（从prepare接口返回值获取）\n      if (res.data.expected_entities_count) {\n        expectedTotal.value = res.data.expected_entities_count\n        addLog(`从Zep图谱读取到 ${res.data.expected_entities_count} 个实体`)\n        if (res.data.entity_types && res.data.entity_types.length > 0) {\n          addLog(`  └─ 实体类型: ${res.data.entity_types.join(', ')}`)\n        }\n      }\n      \n      addLog('开始轮询准备进度...')\n      // 开始轮询进度\n      startPolling()\n      // 开始实时获取 Profiles\n      startProfilesPolling()\n    } else {\n      addLog(`准备失败: ${res.error || '未知错误'}`)\n      emit('update-status', 'error')\n    }\n  } catch (err) {\n    addLog(`准备异常: ${err.message}`)\n    emit('update-status', 'error')\n  }\n}\n\nconst startPolling = () => {\n  pollTimer = setInterval(pollPrepareStatus, 2000)\n}\n\nconst stopPolling = () => {\n  if (pollTimer) {\n    clearInterval(pollTimer)\n    pollTimer = null\n  }\n}\n\nconst startProfilesPolling = () => {\n  profilesTimer = setInterval(fetchProfilesRealtime, 3000)\n}\n\nconst stopProfilesPolling = () => {\n  if (profilesTimer) {\n    clearInterval(profilesTimer)\n    profilesTimer = null\n  }\n}\n\nconst pollPrepareStatus = async () => {\n  if (!taskId.value && !props.simulationId) return\n  \n  try {\n    const res = await getPrepareStatus({\n      task_id: taskId.value,\n      simulation_id: props.simulationId\n    })\n    \n    if (res.success && res.data) {\n      const data = res.data\n      \n      // 更新进度\n      prepareProgress.value = data.progress || 0\n      progressMessage.value = data.message || ''\n      \n      // 解析阶段信息并输出详细日志\n      if (data.progress_detail) {\n        currentStage.value = data.progress_detail.current_stage_name || ''\n        \n        // 输出详细进度日志（避免重复）\n        const detail = data.progress_detail\n        const logKey = `${detail.current_stage}-${detail.current_item}-${detail.total_items}`\n        if (logKey !== lastLoggedMessage && detail.item_description) {\n          lastLoggedMessage = logKey\n          const stageInfo = `[${detail.stage_index}/${detail.total_stages}]`\n          if (detail.total_items > 0) {\n            addLog(`${stageInfo} ${detail.current_stage_name}: ${detail.current_item}/${detail.total_items} - ${detail.item_description}`)\n          } else {\n            addLog(`${stageInfo} ${detail.current_stage_name}: ${detail.item_description}`)\n          }\n        }\n      } else if (data.message) {\n        // 从消息中提取阶段\n        const match = data.message.match(/\\[(\\d+)\\/(\\d+)\\]\\s*([^:]+)/)\n        if (match) {\n          currentStage.value = match[3].trim()\n        }\n        // 输出消息日志（避免重复）\n        if (data.message !== lastLoggedMessage) {\n          lastLoggedMessage = data.message\n          addLog(data.message)\n        }\n      }\n      \n      // 检查是否完成\n      if (data.status === 'completed' || data.status === 'ready' || data.already_prepared) {\n        addLog('✓ 准备工作已完成')\n        stopPolling()\n        stopProfilesPolling()\n        await loadPreparedData()\n      } else if (data.status === 'failed') {\n        addLog(`✗ 准备失败: ${data.error || '未知错误'}`)\n        stopPolling()\n        stopProfilesPolling()\n      }\n    }\n  } catch (err) {\n    console.warn('轮询状态失败:', err)\n  }\n}\n\nconst fetchProfilesRealtime = async () => {\n  if (!props.simulationId) return\n  \n  try {\n    const res = await getSimulationProfilesRealtime(props.simulationId, 'reddit')\n    \n    if (res.success && res.data) {\n      const prevCount = profiles.value.length\n      profiles.value = res.data.profiles || []\n      // 只有当 API 返回有效值时才更新，避免覆盖已有的有效值\n      if (res.data.total_expected) {\n        expectedTotal.value = res.data.total_expected\n      }\n      \n      // 提取实体类型\n      const types = new Set()\n      profiles.value.forEach(p => {\n        if (p.entity_type) types.add(p.entity_type)\n      })\n      entityTypes.value = Array.from(types)\n      \n      // 输出 Profile 生成进度日志（仅当数量变化时）\n      const currentCount = profiles.value.length\n      if (currentCount > 0 && currentCount !== lastLoggedProfileCount) {\n        lastLoggedProfileCount = currentCount\n        const total = expectedTotal.value || '?'\n        const latestProfile = profiles.value[currentCount - 1]\n        const profileName = latestProfile?.name || latestProfile?.username || `Agent_${currentCount}`\n        if (currentCount === 1) {\n          addLog(`开始生成Agent人设...`)\n        }\n        addLog(`→ Agent人设 ${currentCount}/${total}: ${profileName} (${latestProfile?.profession || '未知职业'})`)\n        \n        // 如果全部生成完成\n        if (expectedTotal.value && currentCount >= expectedTotal.value) {\n          addLog(`✓ 全部 ${currentCount} 个Agent人设生成完成`)\n        }\n      }\n    }\n  } catch (err) {\n    console.warn('获取 Profiles 失败:', err)\n  }\n}\n\n// 配置轮询\nconst startConfigPolling = () => {\n  configTimer = setInterval(fetchConfigRealtime, 2000)\n}\n\nconst stopConfigPolling = () => {\n  if (configTimer) {\n    clearInterval(configTimer)\n    configTimer = null\n  }\n}\n\nconst fetchConfigRealtime = async () => {\n  if (!props.simulationId) return\n  \n  try {\n    const res = await getSimulationConfigRealtime(props.simulationId)\n    \n    if (res.success && res.data) {\n      const data = res.data\n      \n      // 输出配置生成阶段日志（避免重复）\n      if (data.generation_stage && data.generation_stage !== lastLoggedConfigStage) {\n        lastLoggedConfigStage = data.generation_stage\n        if (data.generation_stage === 'generating_profiles') {\n          addLog('正在生成Agent人设配置...')\n        } else if (data.generation_stage === 'generating_config') {\n          addLog('正在调用LLM生成模拟配置参数...')\n        }\n      }\n      \n      // 如果配置已生成\n      if (data.config_generated && data.config) {\n        simulationConfig.value = data.config\n        addLog('✓ 模拟配置生成完成')\n        \n        // 显示详细配置摘要\n        if (data.summary) {\n          addLog(`  ├─ Agent数量: ${data.summary.total_agents}个`)\n          addLog(`  ├─ 模拟时长: ${data.summary.simulation_hours}小时`)\n          addLog(`  ├─ 初始帖子: ${data.summary.initial_posts_count}条`)\n          addLog(`  ├─ 热点话题: ${data.summary.hot_topics_count}个`)\n          addLog(`  └─ 平台配置: Twitter ${data.summary.has_twitter_config ? '✓' : '✗'}, Reddit ${data.summary.has_reddit_config ? '✓' : '✗'}`)\n        }\n        \n        // 显示时间配置详情\n        if (data.config.time_config) {\n          const tc = data.config.time_config\n          addLog(`时间配置: 每轮${tc.minutes_per_round}分钟, 共${Math.floor((tc.total_simulation_hours * 60) / tc.minutes_per_round)}轮`)\n        }\n        \n        // 显示事件配置\n        if (data.config.event_config?.narrative_direction) {\n          const narrative = data.config.event_config.narrative_direction\n          addLog(`叙事方向: ${narrative.length > 50 ? narrative.substring(0, 50) + '...' : narrative}`)\n        }\n        \n        stopConfigPolling()\n        phase.value = 4\n        addLog('✓ 环境搭建完成，可以开始模拟')\n        emit('update-status', 'completed')\n      }\n    }\n  } catch (err) {\n    console.warn('获取 Config 失败:', err)\n  }\n}\n\nconst loadPreparedData = async () => {\n  phase.value = 2\n  addLog('正在加载已有配置数据...')\n\n  // 最后获取一次 Profiles\n  await fetchProfilesRealtime()\n  addLog(`已加载 ${profiles.value.length} 个Agent人设`)\n\n  // 获取配置（使用实时接口）\n  try {\n    const res = await getSimulationConfigRealtime(props.simulationId)\n    if (res.success && res.data) {\n      if (res.data.config_generated && res.data.config) {\n        simulationConfig.value = res.data.config\n        addLog('✓ 模拟配置加载成功')\n        \n        // 显示详细配置摘要\n        if (res.data.summary) {\n          addLog(`  ├─ Agent数量: ${res.data.summary.total_agents}个`)\n          addLog(`  ├─ 模拟时长: ${res.data.summary.simulation_hours}小时`)\n          addLog(`  └─ 初始帖子: ${res.data.summary.initial_posts_count}条`)\n        }\n        \n        addLog('✓ 环境搭建完成，可以开始模拟')\n        phase.value = 4\n        emit('update-status', 'completed')\n      } else {\n        // 配置尚未生成，开始轮询\n        addLog('配置生成中，开始轮询等待...')\n        startConfigPolling()\n      }\n    }\n  } catch (err) {\n    addLog(`加载配置失败: ${err.message}`)\n    emit('update-status', 'error')\n  }\n}\n\n// Scroll log to bottom\nconst logContent = ref(null)\nwatch(() => props.systemLogs?.length, () => {\n  nextTick(() => {\n    if (logContent.value) {\n      logContent.value.scrollTop = logContent.value.scrollHeight\n    }\n  })\n})\n\nonMounted(() => {\n  // 自动开始准备流程\n  if (props.simulationId) {\n    addLog('Step2 环境搭建初始化')\n    startPrepareSimulation()\n  }\n})\n\nonUnmounted(() => {\n  stopPolling()\n  stopProfilesPolling()\n  stopConfigPolling()\n})\n</script>\n\n<style scoped>\n.env-setup-panel {\n  height: 100%;\n  display: flex;\n  flex-direction: column;\n  background: #FAFAFA;\n  font-family: 'Space Grotesk', 'Noto Sans SC', system-ui, sans-serif;\n}\n\n.scroll-container {\n  flex: 1;\n  overflow-y: auto;\n  padding: 24px;\n  display: flex;\n  flex-direction: column;\n  gap: 20px;\n}\n\n/* Step Card */\n.step-card {\n  background: #FFF;\n  border-radius: 8px;\n  padding: 20px;\n  box-shadow: 0 2px 8px rgba(0,0,0,0.04);\n  border: 1px solid #EAEAEA;\n  transition: all 0.3s ease;\n  position: relative;\n}\n\n.step-card.active {\n  border-color: #FF5722;\n  box-shadow: 0 4px 12px rgba(255, 87, 34, 0.08);\n}\n\n.card-header {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  margin-bottom: 16px;\n}\n\n.step-info {\n  display: flex;\n  align-items: center;\n  gap: 12px;\n}\n\n.step-num {\n  font-family: 'JetBrains Mono', monospace;\n  font-size: 20px;\n  font-weight: 700;\n  color: #E0E0E0;\n}\n\n.step-card.active .step-num,\n.step-card.completed .step-num {\n  color: #000;\n}\n\n.step-title {\n  font-weight: 600;\n  font-size: 14px;\n  letter-spacing: 0.5px;\n}\n\n.badge {\n  font-size: 10px;\n  padding: 4px 8px;\n  border-radius: 4px;\n  font-weight: 600;\n  text-transform: uppercase;\n}\n\n.badge.success { background: #E8F5E9; color: #2E7D32; }\n.badge.processing { background: #FF5722; color: #FFF; }\n.badge.pending { background: #F5F5F5; color: #999; }\n.badge.accent { background: #E3F2FD; color: #1565C0; }\n\n.card-content {\n  /* No extra padding - uses step-card's padding */\n}\n\n.api-note {\n  font-family: 'JetBrains Mono', monospace;\n  font-size: 10px;\n  color: #999;\n  margin-bottom: 8px;\n}\n\n.description {\n  font-size: 12px;\n  color: #666;\n  line-height: 1.5;\n  margin-bottom: 16px;\n}\n\n/* Action Section */\n.action-section {\n  margin-top: 16px;\n}\n\n.action-btn {\n  display: inline-flex;\n  align-items: center;\n  gap: 8px;\n  padding: 12px 24px;\n  font-size: 14px;\n  font-weight: 600;\n  border: none;\n  border-radius: 6px;\n  cursor: pointer;\n  transition: all 0.2s ease;\n}\n\n.action-btn.primary {\n  background: #000;\n  color: #FFF;\n}\n\n.action-btn.primary:hover:not(:disabled) {\n  opacity: 0.8;\n}\n\n.action-btn.secondary {\n  background: #F5F5F5;\n  color: #333;\n}\n\n.action-btn.secondary:hover:not(:disabled) {\n  background: #E5E5E5;\n}\n\n.action-btn:disabled {\n  opacity: 0.5;\n  cursor: not-allowed;\n}\n\n.action-group {\n  display: flex;\n  gap: 12px;\n  margin-top: 16px;\n}\n\n.action-group.dual {\n  display: grid;\n  grid-template-columns: 1fr 1fr;\n}\n\n.action-group.dual .action-btn {\n  width: 100%;\n}\n\n/* Info Card */\n.info-card {\n  background: #F5F5F5;\n  border-radius: 6px;\n  padding: 16px;\n  margin-top: 16px;\n}\n\n.info-row {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  padding: 8px 0;\n  border-bottom: 1px dashed #E0E0E0;\n}\n\n.info-row:last-child {\n  border-bottom: none;\n}\n\n.info-label {\n  font-size: 12px;\n  color: #666;\n}\n\n.info-value {\n  font-size: 13px;\n  font-weight: 500;\n}\n\n.info-value.mono {\n  font-family: 'JetBrains Mono', monospace;\n  font-size: 12px;\n}\n\n/* Stats Grid */\n.stats-grid {\n  display: grid;\n  grid-template-columns: 1fr 1fr 1fr;\n  gap: 12px;\n  background: #F9F9F9;\n  padding: 16px;\n  border-radius: 6px;\n}\n\n.stat-card {\n  text-align: center;\n}\n\n.stat-value {\n  display: block;\n  font-size: 20px;\n  font-weight: 700;\n  color: #000;\n  font-family: 'JetBrains Mono', monospace;\n}\n\n.stat-label {\n  font-size: 9px;\n  color: #999;\n  text-transform: uppercase;\n  margin-top: 4px;\n  display: block;\n}\n\n/* Profiles Preview */\n.profiles-preview {\n  margin-top: 20px;\n  border-top: 1px solid #E5E5E5;\n  padding-top: 16px;\n}\n\n.preview-header {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  margin-bottom: 12px;\n}\n\n.preview-title {\n  font-size: 12px;\n  font-weight: 600;\n  color: #666;\n  text-transform: uppercase;\n  letter-spacing: 0.5px;\n}\n\n.profiles-list {\n  display: grid;\n  grid-template-columns: repeat(2, 1fr);\n  gap: 12px;\n  max-height: 320px;\n  overflow-y: auto;\n  padding-right: 4px;\n}\n\n.profiles-list::-webkit-scrollbar {\n  width: 4px;\n}\n\n.profiles-list::-webkit-scrollbar-thumb {\n  background: #DDD;\n  border-radius: 2px;\n}\n\n.profiles-list::-webkit-scrollbar-thumb:hover {\n  background: #CCC;\n}\n\n.profile-card {\n  background: #FAFAFA;\n  border: 1px solid #E5E5E5;\n  border-radius: 6px;\n  padding: 14px;\n  cursor: pointer;\n  transition: all 0.2s ease;\n}\n\n.profile-card:hover {\n  border-color: #999;\n  background: #FFF;\n}\n\n.profile-header {\n  display: flex;\n  align-items: baseline;\n  gap: 8px;\n  margin-bottom: 6px;\n}\n\n.profile-realname {\n  font-size: 14px;\n  font-weight: 700;\n  color: #000;\n}\n\n.profile-username {\n  font-family: 'JetBrains Mono', monospace;\n  font-size: 11px;\n  color: #999;\n}\n\n.profile-meta {\n  margin-bottom: 8px;\n}\n\n.profile-profession {\n  font-size: 11px;\n  color: #666;\n  background: #F0F0F0;\n  padding: 2px 8px;\n  border-radius: 3px;\n}\n\n.profile-bio {\n  font-size: 12px;\n  color: #444;\n  line-height: 1.6;\n  margin: 0 0 10px 0;\n  display: -webkit-box;\n  -webkit-line-clamp: 3;\n  -webkit-box-orient: vertical;\n  overflow: hidden;\n}\n\n.profile-topics {\n  display: flex;\n  flex-wrap: wrap;\n  gap: 6px;\n}\n\n.topic-tag {\n  font-size: 10px;\n  color: #1565C0;\n  background: #E3F2FD;\n  padding: 2px 8px;\n  border-radius: 10px;\n}\n\n.topic-more {\n  font-size: 10px;\n  color: #999;\n  padding: 2px 6px;\n}\n\n/* Config Preview */\n/* Config Detail Panel */\n.config-detail-panel {\n  margin-top: 16px;\n}\n\n.config-block {\n  margin-top: 16px;\n  border-top: 1px solid #E5E5E5;\n  padding-top: 12px;\n}\n\n.config-block:first-child {\n  margin-top: 0;\n  border-top: none;\n  padding-top: 0;\n}\n\n.config-block-header {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  margin-bottom: 12px;\n}\n\n.config-block-title {\n  font-size: 12px;\n  font-weight: 600;\n  color: #666;\n  text-transform: uppercase;\n  letter-spacing: 0.5px;\n}\n\n.config-block-badge {\n  font-family: 'JetBrains Mono', monospace;\n  font-size: 11px;\n  background: #F1F5F9;\n  color: #475569;\n  padding: 2px 8px;\n  border-radius: 10px;\n}\n\n/* Config Grid */\n.config-grid {\n  display: grid;\n  grid-template-columns: repeat(4, 1fr);\n  gap: 12px;\n}\n\n.config-item {\n  background: #F9F9F9;\n  padding: 12px 14px;\n  border-radius: 6px;\n  display: flex;\n  flex-direction: column;\n  gap: 4px;\n}\n\n.config-item-label {\n  font-size: 11px;\n  color: #94A3B8;\n}\n\n.config-item-value {\n  font-family: 'JetBrains Mono', monospace;\n  font-size: 16px;\n  font-weight: 600;\n  color: #1E293B;\n}\n\n/* Time Periods */\n.time-periods {\n  margin-top: 12px;\n  display: flex;\n  flex-direction: column;\n  gap: 8px;\n}\n\n.period-item {\n  display: flex;\n  align-items: center;\n  gap: 12px;\n  padding: 8px 12px;\n  background: #F9F9F9;\n  border-radius: 6px;\n}\n\n.period-label {\n  font-size: 12px;\n  font-weight: 500;\n  color: #64748B;\n  min-width: 70px;\n}\n\n.period-hours {\n  font-family: 'JetBrains Mono', monospace;\n  font-size: 11px;\n  color: #475569;\n  flex: 1;\n}\n\n.period-multiplier {\n  font-family: 'JetBrains Mono', monospace;\n  font-size: 11px;\n  font-weight: 600;\n  color: #6366F1;\n  background: #EEF2FF;\n  padding: 2px 6px;\n  border-radius: 4px;\n}\n\n/* Agents Cards */\n.agents-cards {\n  display: grid;\n  grid-template-columns: repeat(2, 1fr);\n  gap: 12px;\n  max-height: 400px;\n  overflow-y: auto;\n  padding-right: 4px;\n}\n\n.agents-cards::-webkit-scrollbar {\n  width: 4px;\n}\n\n.agents-cards::-webkit-scrollbar-thumb {\n  background: #DDD;\n  border-radius: 2px;\n}\n\n.agents-cards::-webkit-scrollbar-thumb:hover {\n  background: #CCC;\n}\n\n.agent-card {\n  background: #F9F9F9;\n  border: 1px solid #E5E5E5;\n  border-radius: 6px;\n  padding: 14px;\n  transition: all 0.2s ease;\n}\n\n.agent-card:hover {\n  border-color: #999;\n  background: #FFF;\n}\n\n/* Agent Card Header */\n.agent-card-header {\n  display: flex;\n  justify-content: space-between;\n  align-items: flex-start;\n  margin-bottom: 14px;\n  padding-bottom: 12px;\n  border-bottom: 1px solid #F1F5F9;\n}\n\n.agent-identity {\n  display: flex;\n  flex-direction: column;\n  gap: 2px;\n}\n\n.agent-id {\n  font-family: 'JetBrains Mono', monospace;\n  font-size: 10px;\n  color: #94A3B8;\n}\n\n.agent-name {\n  font-size: 14px;\n  font-weight: 600;\n  color: #1E293B;\n}\n\n.agent-tags {\n  display: flex;\n  gap: 6px;\n}\n\n.agent-type {\n  font-size: 10px;\n  color: #64748B;\n  background: #F1F5F9;\n  padding: 2px 8px;\n  border-radius: 4px;\n}\n\n.agent-stance {\n  font-size: 10px;\n  font-weight: 500;\n  text-transform: uppercase;\n  padding: 2px 8px;\n  border-radius: 4px;\n}\n\n.stance-neutral {\n  background: #F1F5F9;\n  color: #64748B;\n}\n\n.stance-supportive {\n  background: #DCFCE7;\n  color: #16A34A;\n}\n\n.stance-opposing {\n  background: #FEE2E2;\n  color: #DC2626;\n}\n\n.stance-observer {\n  background: #FEF3C7;\n  color: #D97706;\n}\n\n/* Agent Timeline */\n.agent-timeline {\n  margin-bottom: 14px;\n}\n\n.timeline-label {\n  display: block;\n  font-size: 10px;\n  color: #94A3B8;\n  margin-bottom: 6px;\n  text-transform: uppercase;\n  letter-spacing: 0.05em;\n}\n\n.mini-timeline {\n  display: flex;\n  gap: 2px;\n  height: 16px;\n  background: #F8FAFC;\n  border-radius: 4px;\n  padding: 3px;\n}\n\n.timeline-hour {\n  flex: 1;\n  background: #E2E8F0;\n  border-radius: 2px;\n  transition: all 0.2s;\n}\n\n.timeline-hour.active {\n  background: linear-gradient(180deg, #6366F1, #818CF8);\n}\n\n.timeline-marks {\n  display: flex;\n  justify-content: space-between;\n  margin-top: 4px;\n  font-family: 'JetBrains Mono', monospace;\n  font-size: 9px;\n  color: #94A3B8;\n}\n\n/* Agent Params */\n.agent-params {\n  display: flex;\n  flex-direction: column;\n  gap: 10px;\n}\n\n.param-group {\n  display: grid;\n  grid-template-columns: repeat(3, 1fr);\n  gap: 8px;\n}\n\n.param-item {\n  display: flex;\n  flex-direction: column;\n  gap: 2px;\n}\n\n.param-item .param-label {\n  font-size: 10px;\n  color: #94A3B8;\n}\n\n.param-item .param-value {\n  font-family: 'JetBrains Mono', monospace;\n  font-size: 12px;\n  font-weight: 600;\n  color: #475569;\n}\n\n.param-value.with-bar {\n  display: flex;\n  align-items: center;\n  gap: 6px;\n}\n\n.mini-bar {\n  height: 4px;\n  background: linear-gradient(90deg, #6366F1, #A855F7);\n  border-radius: 2px;\n  min-width: 4px;\n  max-width: 40px;\n}\n\n.param-value.positive {\n  color: #16A34A;\n}\n\n.param-value.negative {\n  color: #DC2626;\n}\n\n.param-value.neutral {\n  color: #64748B;\n}\n\n.param-value.highlight {\n  color: #6366F1;\n}\n\n/* Platforms Grid */\n.platforms-grid {\n  display: grid;\n  grid-template-columns: repeat(2, 1fr);\n  gap: 12px;\n}\n\n.platform-card {\n  background: #F9F9F9;\n  padding: 14px;\n  border-radius: 6px;\n}\n\n.platform-card-header {\n  margin-bottom: 10px;\n  padding-bottom: 8px;\n  border-bottom: 1px solid #E5E5E5;\n}\n\n.platform-name {\n  font-size: 13px;\n  font-weight: 600;\n  color: #333;\n}\n\n.platform-params {\n  display: flex;\n  flex-direction: column;\n  gap: 8px;\n}\n\n.param-row {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n}\n\n.param-label {\n  font-size: 12px;\n  color: #64748B;\n}\n\n.param-value {\n  font-family: 'JetBrains Mono', monospace;\n  font-size: 12px;\n  font-weight: 600;\n  color: #1E293B;\n}\n\n/* Reasoning Content */\n.reasoning-content {\n  display: flex;\n  flex-direction: column;\n  gap: 10px;\n}\n\n.reasoning-item {\n  padding: 12px 14px;\n  background: #F9F9F9;\n  border-radius: 6px;\n}\n\n.reasoning-text {\n  font-size: 13px;\n  color: #555;\n  line-height: 1.7;\n  margin: 0;\n}\n\n/* Profile Modal */\n.profile-modal-overlay {\n  position: fixed;\n  top: 0;\n  left: 0;\n  right: 0;\n  bottom: 0;\n  background: rgba(0, 0, 0, 0.6);\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  z-index: 1000;\n  backdrop-filter: blur(4px);\n}\n\n.profile-modal {\n  background: #FFF;\n  border-radius: 16px;\n  width: 90%;\n  max-width: 600px;\n  max-height: 85vh;\n  overflow: hidden;\n  display: flex;\n  flex-direction: column;\n  box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);\n}\n\n.modal-header {\n  display: flex;\n  justify-content: space-between;\n  align-items: flex-start;\n  padding: 24px;\n  background: #FFF;\n  border-bottom: 1px solid #F0F0F0;\n}\n\n.modal-header-info {\n  flex: 1;\n}\n\n.modal-name-row {\n  display: flex;\n  align-items: baseline;\n  gap: 10px;\n  margin-bottom: 8px;\n}\n\n.modal-realname {\n  font-size: 20px;\n  font-weight: 700;\n  color: #000;\n}\n\n.modal-username {\n  font-family: 'JetBrains Mono', monospace;\n  font-size: 13px;\n  color: #999;\n}\n\n.modal-profession {\n  font-size: 12px;\n  color: #666;\n  background: #F5F5F5;\n  padding: 4px 10px;\n  border-radius: 4px;\n  display: inline-block;\n  font-weight: 500;\n}\n\n.close-btn {\n  width: 32px;\n  height: 32px;\n  border: none;\n  background: none;\n  color: #999;\n  border-radius: 50%;\n  font-size: 24px;\n  cursor: pointer;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  line-height: 1;\n  transition: color 0.2s;\n  padding: 0;\n}\n\n.close-btn:hover {\n  color: #333;\n}\n\n.modal-body {\n  padding: 24px;\n  overflow-y: auto;\n  flex: 1;\n}\n\n/* 基本信息网格 */\n.modal-info-grid {\n  display: grid;\n  grid-template-columns: repeat(2, 1fr);\n  gap: 24px 16px;\n  margin-bottom: 32px;\n  padding: 0;\n  background: transparent;\n  border-radius: 0;\n}\n\n.info-item {\n  display: flex;\n  flex-direction: column;\n  gap: 4px;\n}\n\n.info-label {\n  font-size: 11px;\n  color: #999;\n  text-transform: uppercase;\n  letter-spacing: 0.5px;\n  font-weight: 600;\n}\n\n.info-value {\n  font-size: 15px;\n  font-weight: 600;\n  color: #333;\n}\n\n.info-value.mbti {\n  font-family: 'JetBrains Mono', monospace;\n  color: #FF5722;\n}\n\n/* 模块区域 */\n.modal-section {\n  margin-bottom: 28px;\n}\n\n.section-label {\n  display: block;\n  font-size: 11px;\n  font-weight: 600;\n  color: #999;\n  text-transform: uppercase;\n  letter-spacing: 0.5px;\n  margin-bottom: 12px;\n}\n\n.section-bio {\n  font-size: 14px;\n  color: #333;\n  line-height: 1.6;\n  margin: 0;\n  padding: 16px;\n  background: #F9F9F9;\n  border-radius: 6px;\n  border-left: 3px solid #E0E0E0;\n}\n\n/* 话题标签 */\n.topics-grid {\n  display: flex;\n  flex-wrap: wrap;\n  gap: 8px;\n}\n\n.topic-item {\n  font-size: 11px;\n  color: #1565C0;\n  background: #E3F2FD;\n  padding: 4px 10px;\n  border-radius: 12px;\n  transition: all 0.2s;\n  border: none;\n}\n\n.topic-item:hover {\n  background: #BBDEFB;\n  color: #0D47A1;\n}\n\n/* 详细人设 */\n.persona-dimensions {\n  display: grid;\n  grid-template-columns: repeat(2, 1fr);\n  gap: 12px;\n  margin-bottom: 16px;\n}\n\n.dimension-card {\n  background: #F8F9FA;\n  padding: 12px;\n  border-radius: 6px;\n  border-left: 3px solid #DDD;\n  transition: all 0.2s;\n}\n\n.dimension-card:hover {\n  background: #F0F0F0;\n  border-left-color: #999;\n}\n\n.dim-title {\n  display: block;\n  font-size: 12px;\n  font-weight: 700;\n  color: #333;\n  margin-bottom: 4px;\n}\n\n.dim-desc {\n  display: block;\n  font-size: 10px;\n  color: #888;\n  line-height: 1.4;\n}\n\n.persona-content {\n  max-height: none;\n  overflow: visible;\n  padding: 0;\n  background: transparent;\n  border: none;\n  border-radius: 0;\n}\n\n.persona-content::-webkit-scrollbar {\n  width: 4px;\n}\n\n.persona-content::-webkit-scrollbar-thumb {\n  background: #DDD;\n  border-radius: 2px;\n}\n\n.section-persona {\n  font-size: 13px;\n  color: #555;\n  line-height: 1.8;\n  margin: 0;\n  text-align: justify;\n}\n\n/* System Logs */\n.system-logs {\n  background: #000;\n  color: #DDD;\n  padding: 16px;\n  font-family: 'JetBrains Mono', monospace;\n  border-top: 1px solid #222;\n  flex-shrink: 0;\n}\n\n.log-header {\n  display: flex;\n  justify-content: space-between;\n  border-bottom: 1px solid #333;\n  padding-bottom: 8px;\n  margin-bottom: 8px;\n  font-size: 10px;\n  color: #888;\n}\n\n.log-content {\n  display: flex;\n  flex-direction: column;\n  gap: 4px;\n  height: 80px; /* Approx 4 lines visible */\n  overflow-y: auto;\n  padding-right: 4px;\n}\n\n.log-content::-webkit-scrollbar {\n  width: 4px;\n}\n\n.log-content::-webkit-scrollbar-thumb {\n  background: #333;\n  border-radius: 2px;\n}\n\n.log-line {\n  font-size: 11px;\n  display: flex;\n  gap: 12px;\n  line-height: 1.5;\n}\n\n.log-time {\n  color: #666;\n  min-width: 75px;\n}\n\n.log-msg {\n  color: #CCC;\n  word-break: break-all;\n}\n\n/* Spinner */\n.spinner-sm {\n  width: 16px;\n  height: 16px;\n  border: 2px solid #E5E5E5;\n  border-top-color: #FF5722;\n  border-radius: 50%;\n  animation: spin 0.8s linear infinite;\n}\n\n@keyframes spin {\n  to { transform: rotate(360deg); }\n}\n/* Orchestration Content */\n.orchestration-content {\n  display: flex;\n  flex-direction: column;\n  gap: 20px;\n  margin-top: 16px;\n}\n\n.box-label {\n  display: block;\n  font-size: 12px;\n  font-weight: 600;\n  color: #666;\n  text-transform: uppercase;\n  letter-spacing: 0.5px;\n  margin-bottom: 12px;\n}\n\n.narrative-box {\n  background: #FFFFFF;\n  padding: 20px 24px;\n  border-radius: 12px;\n  border: 1px solid #EEF2F6;\n  box-shadow: 0 4px 24px rgba(0,0,0,0.03);\n  transition: all 0.3s ease;\n}\n\n.narrative-box .box-label {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  color: #666;\n  font-size: 13px;\n  letter-spacing: 0.5px;\n  margin-bottom: 12px;\n  font-weight: 600;\n}\n\n.special-icon {\n  filter: drop-shadow(0 2px 4px rgba(255, 87, 34, 0.2));\n  transition: transform 0.6s cubic-bezier(0.34, 1.56, 0.64, 1);\n}\n\n.narrative-box:hover .special-icon {\n  transform: rotate(180deg);\n}\n\n.narrative-text {\n  font-family: 'Inter', 'Noto Sans SC', system-ui, sans-serif;\n  font-size: 14px;\n  color: #334155;\n  line-height: 1.8;\n  margin: 0;\n  text-align: justify;\n  letter-spacing: 0.01em;\n}\n\n.topics-section {\n  background: #FFF;\n}\n\n.hot-topics-grid {\n  display: flex;\n  flex-wrap: wrap;\n  gap: 8px;\n}\n\n.hot-topic-tag {\n  font-size: 12px;\n  color:rgba(255, 86, 34, 0.88);\n  background: #FFF3E0;\n  padding: 4px 10px;\n  border-radius: 12px;\n  font-weight: 500;\n}\n\n.hot-topic-more {\n  font-size: 11px;\n  color: #999;\n  padding: 4px 6px;\n}\n\n.initial-posts-section {\n  border-top: 1px solid #EAEAEA;\n  padding-top: 16px;\n}\n\n.posts-timeline {\n  display: flex;\n  flex-direction: column;\n  gap: 16px;\n  padding-left: 8px;\n  border-left: 2px solid #F0F0F0;\n  margin-top: 12px;\n}\n\n.timeline-item {\n  position: relative;\n  padding-left: 20px;\n}\n\n.timeline-marker {\n  position: absolute;\n  left: 0;\n  top: 14px;\n  width: 12px;\n  height: 2px;\n  background: #DDD;\n}\n\n.timeline-content {\n  background: #F9F9F9;\n  padding: 12px;\n  border-radius: 6px;\n  border: 1px solid #EEE;\n}\n\n.post-header {\n  display: flex;\n  justify-content: space-between;\n  margin-bottom: 6px;\n}\n\n.post-role {\n  font-size: 11px;\n  font-weight: 700;\n  color: #333;\n  text-transform: uppercase;\n}\n\n.post-agent-info {\n  display: flex;\n  align-items: center;\n  gap: 6px;\n}\n\n.post-id,\n.post-username {\n  font-family: 'JetBrains Mono', monospace;\n  font-size: 10px;\n  color: #666;\n  line-height: 1;\n  vertical-align: baseline;\n}\n\n.post-username {\n  margin-right: 6px;\n}\n\n.post-text {\n  font-size: 12px;\n  color: #555;\n  line-height: 1.5;\n  margin: 0;\n}\n\n/* 模拟轮数配置样式 */\n.rounds-config-section {\n  margin: 24px 0;\n  padding-top: 24px;\n  border-top: 1px solid #EAEAEA;\n}\n\n.rounds-header {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  margin-bottom: 20px;\n}\n\n.header-left {\n  display: flex;\n  flex-direction: column;\n  gap: 4px;\n}\n\n.section-title {\n  font-size: 14px;\n  font-weight: 600;\n  color: #1E293B;\n}\n\n.section-desc {\n  font-size: 12px;\n  color: #94A3B8;\n}\n\n.desc-highlight {\n  font-family: 'JetBrains Mono', monospace;\n  font-weight: 600;\n  color: #1E293B;\n  background: #F1F5F9;\n  padding: 1px 6px;\n  border-radius: 4px;\n  margin: 0 2px;\n}\n\n/* Switch Control */\n.switch-control {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  cursor: pointer;\n  padding: 4px 8px 4px 4px;\n  border-radius: 20px;\n  transition: background 0.2s;\n}\n\n.switch-control:hover {\n  background: #F8FAFC;\n}\n\n.switch-control input {\n  display: none;\n}\n\n.switch-track {\n  width: 36px;\n  height: 20px;\n  background: #E2E8F0;\n  border-radius: 10px;\n  position: relative;\n  transition: all 0.3s cubic-bezier(0.4, 0.0, 0.2, 1);\n}\n\n.switch-track::after {\n  content: '';\n  position: absolute;\n  left: 2px;\n  top: 2px;\n  width: 16px;\n  height: 16px;\n  background: #FFF;\n  border-radius: 50%;\n  box-shadow: 0 1px 3px rgba(0,0,0,0.1);\n  transition: transform 0.3s cubic-bezier(0.4, 0.0, 0.2, 1);\n}\n\n.switch-control input:checked + .switch-track {\n  background: #000;\n}\n\n.switch-control input:checked + .switch-track::after {\n  transform: translateX(16px);\n}\n\n.switch-label {\n  font-size: 12px;\n  font-weight: 500;\n  color: #64748B;\n}\n\n.switch-control input:checked ~ .switch-label {\n  color: #1E293B;\n}\n\n/* Slider Content */\n.rounds-content {\n  animation: fadeIn 0.3s ease;\n}\n\n.slider-display {\n  display: flex;\n  justify-content: space-between;\n  align-items: flex-end;\n  margin-bottom: 16px;\n}\n\n.slider-main-value {\n  display: flex;\n  align-items: baseline;\n  gap: 4px;\n}\n\n.val-num {\n  font-family: 'JetBrains Mono', monospace;\n  font-size: 24px;\n  font-weight: 700;\n  color: #000;\n}\n\n.val-unit {\n  font-size: 12px;\n  color: #666;\n  font-weight: 500;\n}\n\n.slider-meta-info {\n  font-family: 'JetBrains Mono', monospace;\n  font-size: 11px;\n  color: #64748B;\n  background: #F1F5F9;\n  padding: 4px 8px;\n  border-radius: 4px;\n}\n\n.range-wrapper {\n  position: relative;\n  padding: 0 2px;\n}\n\n.minimal-slider {\n  -webkit-appearance: none;\n  width: 100%;\n  height: 4px;\n  background: #E2E8F0;\n  border-radius: 2px;\n  outline: none;\n  background-image: linear-gradient(#000, #000);\n  background-size: var(--percent, 0%) 100%;\n  background-repeat: no-repeat;\n  cursor: pointer;\n}\n\n.minimal-slider::-webkit-slider-thumb {\n  -webkit-appearance: none;\n  width: 16px;\n  height: 16px;\n  border-radius: 50%;\n  background: #FFF;\n  border: 2px solid #000;\n  cursor: pointer;\n  box-shadow: 0 1px 4px rgba(0,0,0,0.1);\n  transition: transform 0.1s;\n  margin-top: -6px; /* Center thumb */\n}\n\n.minimal-slider::-webkit-slider-thumb:hover {\n  transform: scale(1.1);\n}\n\n.minimal-slider::-webkit-slider-runnable-track {\n  height: 4px;\n  border-radius: 2px;\n}\n\n.range-marks {\n  display: flex;\n  justify-content: space-between;\n  margin-top: 8px;\n  font-family: 'JetBrains Mono', monospace;\n  font-size: 10px;\n  color: #94A3B8;\n  position: relative;\n}\n\n.mark-recommend {\n  cursor: pointer;\n  transition: color 0.2s;\n  position: relative;\n}\n\n.mark-recommend:hover {\n  color: #000;\n}\n\n.mark-recommend.active {\n  color: #000;\n  font-weight: 600;\n}\n\n.mark-recommend::after {\n  content: '';\n  position: absolute;\n  top: -12px;\n  left: 50%;\n  transform: translateX(-50%);\n  width: 1px;\n  height: 4px;\n  background: #CBD5E1;\n}\n\n/* Auto Info */\n.auto-info-card {\n  display: flex;\n  align-items: center;\n  gap: 24px;\n  background: #F8FAFC;\n  padding: 16px 20px;\n  border-radius: 8px;\n}\n\n.auto-value {\n  display: flex;\n  flex-direction: row;\n  align-items: baseline;\n  gap: 4px;\n  padding-right: 24px;\n  border-right: 1px solid #E2E8F0;\n}\n\n.auto-content {\n  flex: 1;\n  display: flex;\n  flex-direction: column;\n  gap: 8px;\n  justify-content: center;\n}\n\n.auto-meta-row {\n  display: flex;\n  align-items: center;\n}\n\n.duration-badge {\n  display: inline-flex;\n  align-items: center;\n  gap: 5px;\n  font-family: 'JetBrains Mono', monospace;\n  font-size: 11px;\n  font-weight: 500;\n  color: #64748B;\n  background: #FFFFFF;\n  border: 1px solid #E2E8F0;\n  padding: 3px 8px;\n  border-radius: 6px;\n  box-shadow: 0 1px 2px rgba(0,0,0,0.02);\n}\n\n.auto-desc {\n  display: flex;\n  flex-direction: column;\n  gap: 2px;\n}\n\n.auto-desc p {\n  margin: 0;\n  font-size: 13px;\n  color: #64748B;\n  line-height: 1.5;\n}\n\n.highlight-tip {\n  margin-top: 4px !important;\n  font-size: 12px !important;\n  color: #000 !important;\n  font-weight: 500;\n  cursor: pointer;\n}\n\n.highlight-tip:hover {\n  text-decoration: underline;\n}\n\n@keyframes fadeIn {\n  from { opacity: 0; transform: translateY(4px); }\n  to { opacity: 1; transform: translateY(0); }\n}\n\n.fade-enter-active,\n.fade-leave-active {\n  transition: opacity 0.2s ease;\n}\n\n.fade-enter-from,\n.fade-leave-to {\n  opacity: 0;\n}\n\n/* Modal Transition */\n.modal-enter-active,\n.modal-leave-active {\n  transition: opacity 0.3s ease;\n}\n\n.modal-enter-from,\n.modal-leave-to {\n  opacity: 0;\n}\n\n.modal-enter-active .profile-modal {\n  transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);\n}\n\n.modal-leave-active .profile-modal {\n  transition: all 0.3s ease-in;\n}\n\n.modal-enter-from .profile-modal,\n.modal-leave-to .profile-modal {\n  transform: scale(0.95) translateY(10px);\n  opacity: 0;\n}\n</style>\n"
  },
  {
    "path": "frontend/src/components/Step3Simulation.vue",
    "content": "<template>\n  <div class=\"simulation-panel\">\n    <!-- Top Control Bar -->\n    <div class=\"control-bar\">\n      <div class=\"status-group\">\n        <!-- Twitter 平台进度 -->\n        <div class=\"platform-status twitter\" :class=\"{ active: runStatus.twitter_running, completed: runStatus.twitter_completed }\">\n          <div class=\"platform-header\">\n            <svg class=\"platform-icon\" viewBox=\"0 0 24 24\" width=\"14\" height=\"14\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n              <circle cx=\"12\" cy=\"12\" r=\"10\"></circle><line x1=\"2\" y1=\"12\" x2=\"22\" y2=\"12\"></line><path d=\"M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z\"></path>\n            </svg>\n            <span class=\"platform-name\">Info Plaza</span>\n            <span v-if=\"runStatus.twitter_completed\" class=\"status-badge\">\n              <svg viewBox=\"0 0 24 24\" width=\"12\" height=\"12\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"3\">\n                <polyline points=\"20 6 9 17 4 12\"></polyline>\n              </svg>\n            </span>\n          </div>\n          <div class=\"platform-stats\">\n            <span class=\"stat\">\n              <span class=\"stat-label\">ROUND</span>\n              <span class=\"stat-value mono\">{{ runStatus.twitter_current_round || 0 }}<span class=\"stat-total\">/{{ runStatus.total_rounds || maxRounds || '-' }}</span></span>\n            </span>\n            <span class=\"stat\">\n              <span class=\"stat-label\">Elapsed Time</span>\n              <span class=\"stat-value mono\">{{ twitterElapsedTime }}</span>\n            </span>\n            <span class=\"stat\">\n              <span class=\"stat-label\">ACTS</span>\n              <span class=\"stat-value mono\">{{ runStatus.twitter_actions_count || 0 }}</span>\n            </span>\n          </div>\n          <!-- 可用动作提示 -->\n          <div class=\"actions-tooltip\">\n            <div class=\"tooltip-title\">Available Actions</div>\n            <div class=\"tooltip-actions\">\n              <span class=\"tooltip-action\">POST</span>\n              <span class=\"tooltip-action\">LIKE</span>\n              <span class=\"tooltip-action\">REPOST</span>\n              <span class=\"tooltip-action\">QUOTE</span>\n              <span class=\"tooltip-action\">FOLLOW</span>\n              <span class=\"tooltip-action\">IDLE</span>\n            </div>\n          </div>\n        </div>\n        \n        <!-- Reddit 平台进度 -->\n        <div class=\"platform-status reddit\" :class=\"{ active: runStatus.reddit_running, completed: runStatus.reddit_completed }\">\n          <div class=\"platform-header\">\n            <svg class=\"platform-icon\" viewBox=\"0 0 24 24\" width=\"14\" height=\"14\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n              <path d=\"M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z\"></path>\n            </svg>\n            <span class=\"platform-name\">Topic Community</span>\n            <span v-if=\"runStatus.reddit_completed\" class=\"status-badge\">\n              <svg viewBox=\"0 0 24 24\" width=\"12\" height=\"12\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"3\">\n                <polyline points=\"20 6 9 17 4 12\"></polyline>\n              </svg>\n            </span>\n          </div>\n          <div class=\"platform-stats\">\n            <span class=\"stat\">\n              <span class=\"stat-label\">ROUND</span>\n              <span class=\"stat-value mono\">{{ runStatus.reddit_current_round || 0 }}<span class=\"stat-total\">/{{ runStatus.total_rounds || maxRounds || '-' }}</span></span>\n            </span>\n            <span class=\"stat\">\n              <span class=\"stat-label\">Elapsed Time</span>\n              <span class=\"stat-value mono\">{{ redditElapsedTime }}</span>\n            </span>\n            <span class=\"stat\">\n              <span class=\"stat-label\">ACTS</span>\n              <span class=\"stat-value mono\">{{ runStatus.reddit_actions_count || 0 }}</span>\n            </span>\n          </div>\n          <!-- 可用动作提示 -->\n          <div class=\"actions-tooltip\">\n            <div class=\"tooltip-title\">Available Actions</div>\n            <div class=\"tooltip-actions\">\n              <span class=\"tooltip-action\">POST</span>\n              <span class=\"tooltip-action\">COMMENT</span>\n              <span class=\"tooltip-action\">LIKE</span>\n              <span class=\"tooltip-action\">DISLIKE</span>\n              <span class=\"tooltip-action\">SEARCH</span>\n              <span class=\"tooltip-action\">TREND</span>\n              <span class=\"tooltip-action\">FOLLOW</span>\n              <span class=\"tooltip-action\">MUTE</span>\n              <span class=\"tooltip-action\">REFRESH</span>\n              <span class=\"tooltip-action\">IDLE</span>\n            </div>\n          </div>\n        </div>\n      </div>\n\n      <div class=\"action-controls\">\n        <button \n          class=\"action-btn primary\"\n          :disabled=\"phase !== 2 || isGeneratingReport\"\n          @click=\"handleNextStep\"\n        >\n          <span v-if=\"isGeneratingReport\" class=\"loading-spinner-small\"></span>\n          {{ isGeneratingReport ? '启动中...' : '开始生成结果报告' }} \n          <span v-if=\"!isGeneratingReport\" class=\"arrow-icon\">→</span>\n        </button>\n      </div>\n    </div>\n\n    <!-- Main Content: Dual Timeline -->\n    <div class=\"main-content-area\" ref=\"scrollContainer\">\n      <!-- Timeline Header -->\n      <div class=\"timeline-header\" v-if=\"allActions.length > 0\">\n        <div class=\"timeline-stats\">\n          <span class=\"total-count\">TOTAL EVENTS: <span class=\"mono\">{{ allActions.length }}</span></span>\n          <span class=\"platform-breakdown\">\n            <span class=\"breakdown-item twitter\">\n              <svg class=\"mini-icon\" viewBox=\"0 0 24 24\" width=\"12\" height=\"12\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><circle cx=\"12\" cy=\"12\" r=\"10\"></circle><line x1=\"2\" y1=\"12\" x2=\"22\" y2=\"12\"></line><path d=\"M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z\"></path></svg>\n              <span class=\"mono\">{{ twitterActionsCount }}</span>\n            </span>\n            <span class=\"breakdown-divider\">/</span>\n            <span class=\"breakdown-item reddit\">\n              <svg class=\"mini-icon\" viewBox=\"0 0 24 24\" width=\"12\" height=\"12\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z\"></path></svg>\n              <span class=\"mono\">{{ redditActionsCount }}</span>\n            </span>\n          </span>\n        </div>\n      </div>\n      \n      <!-- Timeline Feed -->\n      <div class=\"timeline-feed\">\n        <div class=\"timeline-axis\"></div>\n        \n        <TransitionGroup name=\"timeline-item\">\n          <div \n            v-for=\"action in chronologicalActions\" \n            :key=\"action._uniqueId || action.id || `${action.timestamp}-${action.agent_id}`\" \n            class=\"timeline-item\"\n            :class=\"action.platform\"\n          >\n            <div class=\"timeline-marker\">\n              <div class=\"marker-dot\"></div>\n            </div>\n            \n            <div class=\"timeline-card\">\n              <div class=\"card-header\">\n                <div class=\"agent-info\">\n                  <div class=\"avatar-placeholder\">{{ (action.agent_name || 'A')[0] }}</div>\n                  <span class=\"agent-name\">{{ action.agent_name }}</span>\n                </div>\n                \n                <div class=\"header-meta\">\n                  <div class=\"platform-indicator\">\n                    <svg v-if=\"action.platform === 'twitter'\" viewBox=\"0 0 24 24\" width=\"12\" height=\"12\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><circle cx=\"12\" cy=\"12\" r=\"10\"></circle><line x1=\"2\" y1=\"12\" x2=\"22\" y2=\"12\"></line><path d=\"M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z\"></path></svg>\n                    <svg v-else viewBox=\"0 0 24 24\" width=\"12\" height=\"12\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z\"></path></svg>\n                  </div>\n                  <div class=\"action-badge\" :class=\"getActionTypeClass(action.action_type)\">\n                    {{ getActionTypeLabel(action.action_type) }}\n                  </div>\n                </div>\n              </div>\n              \n              <div class=\"card-body\">\n                <!-- CREATE_POST: 发布帖子 -->\n                <div v-if=\"action.action_type === 'CREATE_POST' && action.action_args?.content\" class=\"content-text main-text\">\n                  {{ action.action_args.content }}\n                </div>\n\n                <!-- QUOTE_POST: 引用帖子 -->\n                <template v-if=\"action.action_type === 'QUOTE_POST'\">\n                  <div v-if=\"action.action_args?.quote_content\" class=\"content-text\">\n                    {{ action.action_args.quote_content }}\n                  </div>\n                  <div v-if=\"action.action_args?.original_content\" class=\"quoted-block\">\n                    <div class=\"quote-header\">\n                      <svg class=\"icon-small\" viewBox=\"0 0 24 24\" width=\"12\" height=\"12\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71\"></path><path d=\"M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71\"></path></svg>\n                      <span class=\"quote-label\">@{{ action.action_args.original_author_name || 'User' }}</span>\n                    </div>\n                    <div class=\"quote-text\">\n                      {{ truncateContent(action.action_args.original_content, 150) }}\n                    </div>\n                  </div>\n                </template>\n\n                <!-- REPOST: 转发帖子 -->\n                <template v-if=\"action.action_type === 'REPOST'\">\n                  <div class=\"repost-info\">\n                    <svg class=\"icon-small\" viewBox=\"0 0 24 24\" width=\"14\" height=\"14\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><polyline points=\"17 1 21 5 17 9\"></polyline><path d=\"M3 11V9a4 4 0 0 1 4-4h14\"></path><polyline points=\"7 23 3 19 7 15\"></polyline><path d=\"M21 13v2a4 4 0 0 1-4 4H3\"></path></svg>\n                    <span class=\"repost-label\">Reposted from @{{ action.action_args?.original_author_name || 'User' }}</span>\n                  </div>\n                  <div v-if=\"action.action_args?.original_content\" class=\"repost-content\">\n                    {{ truncateContent(action.action_args.original_content, 200) }}\n                  </div>\n                </template>\n\n                <!-- LIKE_POST: 点赞帖子 -->\n                <template v-if=\"action.action_type === 'LIKE_POST'\">\n                  <div class=\"like-info\">\n                    <svg class=\"icon-small filled\" viewBox=\"0 0 24 24\" width=\"14\" height=\"14\" fill=\"currentColor\"><path d=\"M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z\"></path></svg>\n                    <span class=\"like-label\">Liked @{{ action.action_args?.post_author_name || 'User' }}'s post</span>\n                  </div>\n                  <div v-if=\"action.action_args?.post_content\" class=\"liked-content\">\n                    \"{{ truncateContent(action.action_args.post_content, 120) }}\"\n                  </div>\n                </template>\n\n                <!-- CREATE_COMMENT: 发表评论 -->\n                <template v-if=\"action.action_type === 'CREATE_COMMENT'\">\n                  <div v-if=\"action.action_args?.content\" class=\"content-text\">\n                    {{ action.action_args.content }}\n                  </div>\n                  <div v-if=\"action.action_args?.post_id\" class=\"comment-context\">\n                    <svg class=\"icon-small\" viewBox=\"0 0 24 24\" width=\"12\" height=\"12\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z\"></path></svg>\n                    <span>Reply to post #{{ action.action_args.post_id }}</span>\n                  </div>\n                </template>\n\n                <!-- SEARCH_POSTS: 搜索帖子 -->\n                <template v-if=\"action.action_type === 'SEARCH_POSTS'\">\n                  <div class=\"search-info\">\n                    <svg class=\"icon-small\" viewBox=\"0 0 24 24\" width=\"14\" height=\"14\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><circle cx=\"11\" cy=\"11\" r=\"8\"></circle><line x1=\"21\" y1=\"21\" x2=\"16.65\" y2=\"16.65\"></line></svg>\n                    <span class=\"search-label\">Search Query:</span>\n                    <span class=\"search-query\">\"{{ action.action_args?.query || '' }}\"</span>\n                  </div>\n                </template>\n\n                <!-- FOLLOW: 关注用户 -->\n                <template v-if=\"action.action_type === 'FOLLOW'\">\n                  <div class=\"follow-info\">\n                    <svg class=\"icon-small\" viewBox=\"0 0 24 24\" width=\"14\" height=\"14\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2\"></path><circle cx=\"8.5\" cy=\"7\" r=\"4\"></circle><line x1=\"20\" y1=\"8\" x2=\"20\" y2=\"14\"></line><line x1=\"23\" y1=\"11\" x2=\"17\" y2=\"11\"></line></svg>\n                    <span class=\"follow-label\">Followed @{{ action.action_args?.target_user || action.action_args?.user_id || 'User' }}</span>\n                  </div>\n                </template>\n\n                <!-- UPVOTE / DOWNVOTE -->\n                <template v-if=\"action.action_type === 'UPVOTE_POST' || action.action_type === 'DOWNVOTE_POST'\">\n                  <div class=\"vote-info\">\n                    <svg v-if=\"action.action_type === 'UPVOTE_POST'\" class=\"icon-small\" viewBox=\"0 0 24 24\" width=\"14\" height=\"14\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><polyline points=\"18 15 12 9 6 15\"></polyline></svg>\n                    <svg v-else class=\"icon-small\" viewBox=\"0 0 24 24\" width=\"14\" height=\"14\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><polyline points=\"6 9 12 15 18 9\"></polyline></svg>\n                    <span class=\"vote-label\">{{ action.action_type === 'UPVOTE_POST' ? 'Upvoted' : 'Downvoted' }} Post</span>\n                  </div>\n                  <div v-if=\"action.action_args?.post_content\" class=\"voted-content\">\n                    \"{{ truncateContent(action.action_args.post_content, 120) }}\"\n                  </div>\n                </template>\n\n                <!-- DO_NOTHING: 无操作（静默） -->\n                <template v-if=\"action.action_type === 'DO_NOTHING'\">\n                  <div class=\"idle-info\">\n                    <svg class=\"icon-small\" viewBox=\"0 0 24 24\" width=\"14\" height=\"14\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><circle cx=\"12\" cy=\"12\" r=\"10\"></circle><line x1=\"12\" y1=\"8\" x2=\"12\" y2=\"12\"></line><line x1=\"12\" y1=\"16\" x2=\"12.01\" y2=\"16\"></line></svg>\n                    <span class=\"idle-label\">Action Skipped</span>\n                  </div>\n                </template>\n\n                <!-- 通用回退：未知类型或有 content 但未被上述处理 -->\n                <div v-if=\"!['CREATE_POST', 'QUOTE_POST', 'REPOST', 'LIKE_POST', 'CREATE_COMMENT', 'SEARCH_POSTS', 'FOLLOW', 'UPVOTE_POST', 'DOWNVOTE_POST', 'DO_NOTHING'].includes(action.action_type) && action.action_args?.content\" class=\"content-text\">\n                  {{ action.action_args.content }}\n                </div>\n              </div>\n\n              <div class=\"card-footer\">\n                <span class=\"time-tag\">R{{ action.round_num }} • {{ formatActionTime(action.timestamp) }}</span>\n                <!-- Platform tag removed as it is in header now -->\n              </div>\n            </div>\n          </div>\n        </TransitionGroup>\n\n        <div v-if=\"allActions.length === 0\" class=\"waiting-state\">\n          <div class=\"pulse-ring\"></div>\n          <span>Waiting for agent actions...</span>\n        </div>\n      </div>\n    </div>\n\n    <!-- Bottom Info / Logs -->\n    <div class=\"system-logs\">\n      <div class=\"log-header\">\n        <span class=\"log-title\">SIMULATION MONITOR</span>\n        <span class=\"log-id\">{{ simulationId || 'NO_SIMULATION' }}</span>\n      </div>\n      <div class=\"log-content\" ref=\"logContent\">\n        <div class=\"log-line\" v-for=\"(log, idx) in systemLogs\" :key=\"idx\">\n          <span class=\"log-time\">{{ log.time }}</span>\n          <span class=\"log-msg\">{{ log.msg }}</span>\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'\nimport { useRouter } from 'vue-router'\nimport { \n  startSimulation, \n  stopSimulation,\n  getRunStatus, \n  getRunStatusDetail\n} from '../api/simulation'\nimport { generateReport } from '../api/report'\n\nconst props = defineProps({\n  simulationId: String,\n  maxRounds: Number, // 从Step2传入的最大轮数\n  minutesPerRound: {\n    type: Number,\n    default: 30 // 默认每轮30分钟\n  },\n  projectData: Object,\n  graphData: Object,\n  systemLogs: Array\n})\n\nconst emit = defineEmits(['go-back', 'next-step', 'add-log', 'update-status'])\n\nconst router = useRouter()\n\n// State\nconst isGeneratingReport = ref(false)\nconst phase = ref(0) // 0: 未开始, 1: 运行中, 2: 已完成\nconst isStarting = ref(false)\nconst isStopping = ref(false)\nconst startError = ref(null)\nconst runStatus = ref({})\nconst allActions = ref([]) // 所有动作（增量累积）\nconst actionIds = ref(new Set()) // 用于去重的动作ID集合\nconst scrollContainer = ref(null)\n\n// Computed\n// 按时间顺序显示动作（最新的在最后面，即底部）\nconst chronologicalActions = computed(() => {\n  return allActions.value\n})\n\n// 各平台动作计数\nconst twitterActionsCount = computed(() => {\n  return allActions.value.filter(a => a.platform === 'twitter').length\n})\n\nconst redditActionsCount = computed(() => {\n  return allActions.value.filter(a => a.platform === 'reddit').length\n})\n\n// 格式化模拟流逝时间（根据轮次和每轮分钟数计算）\nconst formatElapsedTime = (currentRound) => {\n  if (!currentRound || currentRound <= 0) return '0h 0m'\n  const totalMinutes = currentRound * props.minutesPerRound\n  const hours = Math.floor(totalMinutes / 60)\n  const minutes = totalMinutes % 60\n  return `${hours}h ${minutes}m`\n}\n\n// Twitter平台的模拟流逝时间\nconst twitterElapsedTime = computed(() => {\n  return formatElapsedTime(runStatus.value.twitter_current_round || 0)\n})\n\n// Reddit平台的模拟流逝时间\nconst redditElapsedTime = computed(() => {\n  return formatElapsedTime(runStatus.value.reddit_current_round || 0)\n})\n\n// Methods\nconst addLog = (msg) => {\n  emit('add-log', msg)\n}\n\n// 重置所有状态（用于重新启动模拟）\nconst resetAllState = () => {\n  phase.value = 0\n  runStatus.value = {}\n  allActions.value = []\n  actionIds.value = new Set()\n  prevTwitterRound.value = 0\n  prevRedditRound.value = 0\n  startError.value = null\n  isStarting.value = false\n  isStopping.value = false\n  stopPolling()  // 停止之前可能存在的轮询\n}\n\n// 启动模拟\nconst doStartSimulation = async () => {\n  if (!props.simulationId) {\n    addLog('错误：缺少 simulationId')\n    return\n  }\n  \n  // 先重置所有状态，确保不会受到上一次模拟的影响\n  resetAllState()\n  \n  isStarting.value = true\n  startError.value = null\n  addLog('正在启动双平台并行模拟...')\n  emit('update-status', 'processing')\n  \n  try {\n    const params = {\n      simulation_id: props.simulationId,\n      platform: 'parallel',\n      force: true,  // 强制重新开始\n      enable_graph_memory_update: true  // 开启动态图谱更新\n    }\n    \n    if (props.maxRounds) {\n      params.max_rounds = props.maxRounds\n      addLog(`设置最大模拟轮数: ${props.maxRounds}`)\n    }\n    \n    addLog('已开启动态图谱更新模式')\n    \n    const res = await startSimulation(params)\n    \n    if (res.success && res.data) {\n      if (res.data.force_restarted) {\n        addLog('✓ 已清理旧的模拟日志，重新开始模拟')\n      }\n      addLog('✓ 模拟引擎启动成功')\n      addLog(`  ├─ PID: ${res.data.process_pid || '-'}`)\n      \n      phase.value = 1\n      runStatus.value = res.data\n      \n      startStatusPolling()\n      startDetailPolling()\n    } else {\n      startError.value = res.error || '启动失败'\n      addLog(`✗ 启动失败: ${res.error || '未知错误'}`)\n      emit('update-status', 'error')\n    }\n  } catch (err) {\n    startError.value = err.message\n    addLog(`✗ 启动异常: ${err.message}`)\n    emit('update-status', 'error')\n  } finally {\n    isStarting.value = false\n  }\n}\n\n// 停止模拟\nconst handleStopSimulation = async () => {\n  if (!props.simulationId) return\n  \n  isStopping.value = true\n  addLog('正在停止模拟...')\n  \n  try {\n    const res = await stopSimulation({ simulation_id: props.simulationId })\n    \n    if (res.success) {\n      addLog('✓ 模拟已停止')\n      phase.value = 2\n      stopPolling()\n      emit('update-status', 'completed')\n    } else {\n      addLog(`停止失败: ${res.error || '未知错误'}`)\n    }\n  } catch (err) {\n    addLog(`停止异常: ${err.message}`)\n  } finally {\n    isStopping.value = false\n  }\n}\n\n// 轮询状态\nlet statusTimer = null\nlet detailTimer = null\n\nconst startStatusPolling = () => {\n  statusTimer = setInterval(fetchRunStatus, 2000)\n}\n\nconst startDetailPolling = () => {\n  detailTimer = setInterval(fetchRunStatusDetail, 3000)\n}\n\nconst stopPolling = () => {\n  if (statusTimer) {\n    clearInterval(statusTimer)\n    statusTimer = null\n  }\n  if (detailTimer) {\n    clearInterval(detailTimer)\n    detailTimer = null\n  }\n}\n\n// 追踪各平台的上一次轮次，用于检测变化并输出日志\nconst prevTwitterRound = ref(0)\nconst prevRedditRound = ref(0)\n\nconst fetchRunStatus = async () => {\n  if (!props.simulationId) return\n  \n  try {\n    const res = await getRunStatus(props.simulationId)\n    \n    if (res.success && res.data) {\n      const data = res.data\n      \n      runStatus.value = data\n      \n      // 分别检测各平台的轮次变化并输出日志\n      if (data.twitter_current_round > prevTwitterRound.value) {\n        addLog(`[Plaza] R${data.twitter_current_round}/${data.total_rounds} | T:${data.twitter_simulated_hours || 0}h | A:${data.twitter_actions_count}`)\n        prevTwitterRound.value = data.twitter_current_round\n      }\n      \n      if (data.reddit_current_round > prevRedditRound.value) {\n        addLog(`[Community] R${data.reddit_current_round}/${data.total_rounds} | T:${data.reddit_simulated_hours || 0}h | A:${data.reddit_actions_count}`)\n        prevRedditRound.value = data.reddit_current_round\n      }\n      \n      // 检测模拟是否已完成（通过 runner_status 或平台完成状态判断）\n      const isCompleted = data.runner_status === 'completed' || data.runner_status === 'stopped'\n      \n      // 额外检查：如果后端还没来得及更新 runner_status，但平台已经报告完成\n      // 通过检测 twitter_completed 和 reddit_completed 状态判断\n      const platformsCompleted = checkPlatformsCompleted(data)\n      \n      if (isCompleted || platformsCompleted) {\n        if (platformsCompleted && !isCompleted) {\n          addLog('✓ 检测到所有平台模拟已结束')\n        }\n        addLog('✓ 模拟已完成')\n        phase.value = 2\n        stopPolling()\n        emit('update-status', 'completed')\n      }\n    }\n  } catch (err) {\n    console.warn('获取运行状态失败:', err)\n  }\n}\n\n// 检查所有启用的平台是否已完成\nconst checkPlatformsCompleted = (data) => {\n  // 如果没有任何平台数据，返回 false\n  if (!data) return false\n  \n  // 检查各平台的完成状态\n  const twitterCompleted = data.twitter_completed === true\n  const redditCompleted = data.reddit_completed === true\n  \n  // 如果至少有一个平台完成了，检查是否所有启用的平台都完成了\n  // 通过 actions_count 判断平台是否被启用（如果 count > 0 或 running 曾为 true）\n  const twitterEnabled = (data.twitter_actions_count > 0) || data.twitter_running || twitterCompleted\n  const redditEnabled = (data.reddit_actions_count > 0) || data.reddit_running || redditCompleted\n  \n  // 如果没有任何平台被启用，返回 false\n  if (!twitterEnabled && !redditEnabled) return false\n  \n  // 检查所有启用的平台是否都已完成\n  if (twitterEnabled && !twitterCompleted) return false\n  if (redditEnabled && !redditCompleted) return false\n  \n  return true\n}\n\nconst fetchRunStatusDetail = async () => {\n  if (!props.simulationId) return\n  \n  try {\n    const res = await getRunStatusDetail(props.simulationId)\n    \n    if (res.success && res.data) {\n      // 使用 all_actions 获取完整的动作列表\n      const serverActions = res.data.all_actions || []\n      \n      // 增量添加新动作（去重）\n      let newActionsAdded = 0\n      serverActions.forEach(action => {\n        // 生成唯一ID\n        const actionId = action.id || `${action.timestamp}-${action.platform}-${action.agent_id}-${action.action_type}`\n        \n        if (!actionIds.value.has(actionId)) {\n          actionIds.value.add(actionId)\n          allActions.value.push({\n            ...action,\n            _uniqueId: actionId\n          })\n          newActionsAdded++\n        }\n      })\n      \n      // 不自动滚动，让用户自由查看时间轴\n      // 新动作会在底部追加\n    }\n  } catch (err) {\n    console.warn('获取详细状态失败:', err)\n  }\n}\n\n// Helpers\nconst getActionTypeLabel = (type) => {\n  const labels = {\n    'CREATE_POST': 'POST',\n    'REPOST': 'REPOST',\n    'LIKE_POST': 'LIKE',\n    'CREATE_COMMENT': 'COMMENT',\n    'LIKE_COMMENT': 'LIKE',\n    'DO_NOTHING': 'IDLE',\n    'FOLLOW': 'FOLLOW',\n    'SEARCH_POSTS': 'SEARCH',\n    'QUOTE_POST': 'QUOTE',\n    'UPVOTE_POST': 'UPVOTE',\n    'DOWNVOTE_POST': 'DOWNVOTE'\n  }\n  return labels[type] || type || 'UNKNOWN'\n}\n\nconst getActionTypeClass = (type) => {\n  const classes = {\n    'CREATE_POST': 'badge-post',\n    'REPOST': 'badge-action',\n    'LIKE_POST': 'badge-action',\n    'CREATE_COMMENT': 'badge-comment',\n    'LIKE_COMMENT': 'badge-action',\n    'QUOTE_POST': 'badge-post',\n    'FOLLOW': 'badge-meta',\n    'SEARCH_POSTS': 'badge-meta',\n    'UPVOTE_POST': 'badge-action',\n    'DOWNVOTE_POST': 'badge-action',\n    'DO_NOTHING': 'badge-idle'\n  }\n  return classes[type] || 'badge-default'\n}\n\nconst truncateContent = (content, maxLength = 100) => {\n  if (!content) return ''\n  if (content.length > maxLength) return content.substring(0, maxLength) + '...'\n  return content\n}\n\nconst formatActionTime = (timestamp) => {\n  if (!timestamp) return ''\n  try {\n    return new Date(timestamp).toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' })\n  } catch {\n    return ''\n  }\n}\n\nconst handleNextStep = async () => {\n  if (!props.simulationId) {\n    addLog('错误：缺少 simulationId')\n    return\n  }\n  \n  if (isGeneratingReport.value) {\n    addLog('报告生成请求已发送，请稍候...')\n    return\n  }\n  \n  isGeneratingReport.value = true\n  addLog('正在启动报告生成...')\n  \n  try {\n    const res = await generateReport({\n      simulation_id: props.simulationId,\n      force_regenerate: true\n    })\n    \n    if (res.success && res.data) {\n      const reportId = res.data.report_id\n      addLog(`✓ 报告生成任务已启动: ${reportId}`)\n      \n      // 跳转到报告页面\n      router.push({ name: 'Report', params: { reportId } })\n    } else {\n      addLog(`✗ 启动报告生成失败: ${res.error || '未知错误'}`)\n      isGeneratingReport.value = false\n    }\n  } catch (err) {\n    addLog(`✗ 启动报告生成异常: ${err.message}`)\n    isGeneratingReport.value = false\n  }\n}\n\n// Scroll log to bottom\nconst logContent = ref(null)\nwatch(() => props.systemLogs?.length, () => {\n  nextTick(() => {\n    if (logContent.value) {\n      logContent.value.scrollTop = logContent.value.scrollHeight\n    }\n  })\n})\n\nonMounted(() => {\n  addLog('Step3 模拟运行初始化')\n  if (props.simulationId) {\n    doStartSimulation()\n  }\n})\n\nonUnmounted(() => {\n  stopPolling()\n})\n</script>\n\n<style scoped>\n.simulation-panel {\n  height: 100%;\n  display: flex;\n  flex-direction: column;\n  background: #FFFFFF;\n  font-family: 'Space Grotesk', 'Noto Sans SC', system-ui, sans-serif;\n  overflow: hidden;\n}\n\n/* --- Control Bar --- */\n.control-bar {\n  background: #FFF;\n  padding: 12px 24px;\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  border-bottom: 1px solid #EAEAEA;\n  z-index: 10;\n  height: 64px;\n}\n\n.status-group {\n  display: flex;\n  gap: 12px;\n}\n\n/* Platform Status Cards */\n.platform-status {\n  display: flex;\n  flex-direction: column;\n  gap: 4px;\n  padding: 6px 12px;\n  border-radius: 4px;\n  background: #FAFAFA;\n  border: 1px solid #EAEAEA;\n  opacity: 0.7;\n  transition: all 0.3s;\n  min-width: 140px;\n  position: relative;\n  cursor: pointer;\n}\n\n.platform-status.active {\n  opacity: 1;\n  border-color: #333;\n  background: #FFF;\n}\n\n.platform-status.completed {\n  opacity: 1;\n  border-color: #1A936F;\n  background: #F2FAF6;\n}\n\n/* Actions Tooltip */\n.actions-tooltip {\n  position: absolute;\n  top: 100%;\n  left: 50%;\n  transform: translateX(-50%);\n  margin-top: 8px;\n  padding: 10px 14px;\n  background: #000;\n  color: #FFF;\n  border-radius: 4px;\n  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);\n  opacity: 0;\n  visibility: hidden;\n  transition: all 0.2s ease;\n  z-index: 100;\n  min-width: 180px;\n  pointer-events: none;\n}\n\n.actions-tooltip::before {\n  content: '';\n  position: absolute;\n  top: -6px;\n  left: 50%;\n  transform: translateX(-50%);\n  border-left: 6px solid transparent;\n  border-right: 6px solid transparent;\n  border-bottom: 6px solid #000;\n}\n\n.platform-status:hover .actions-tooltip {\n  opacity: 1;\n  visibility: visible;\n}\n\n.tooltip-title {\n  font-size: 10px;\n  font-weight: 600;\n  color: #999;\n  text-transform: uppercase;\n  letter-spacing: 0.08em;\n  margin-bottom: 8px;\n}\n\n.tooltip-actions {\n  display: flex;\n  flex-wrap: wrap;\n  gap: 6px;\n}\n\n.tooltip-action {\n  font-size: 10px;\n  font-weight: 600;\n  padding: 3px 8px;\n  background: rgba(255, 255, 255, 0.15);\n  border-radius: 2px;\n  color: #FFF;\n  letter-spacing: 0.03em;\n}\n\n.platform-header {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  margin-bottom: 2px;\n}\n\n.platform-name {\n  font-size: 11px;\n  font-weight: 700;\n  color: #000;\n  text-transform: uppercase;\n  letter-spacing: 0.05em;\n}\n\n.platform-status.twitter .platform-icon { color: #000; }\n.platform-status.reddit .platform-icon { color: #000; }\n\n.platform-stats {\n  display: flex;\n  gap: 10px;\n}\n\n.stat {\n  display: flex;\n  align-items: baseline;\n  gap: 3px;\n}\n\n.stat-label {\n  font-size: 8px;\n  color: #999;\n  font-weight: 600;\n  text-transform: uppercase;\n  letter-spacing: 0.05em;\n}\n\n.stat-value {\n  font-size: 11px;\n  font-weight: 600;\n  color: #333;\n}\n\n.stat-total, .stat-unit {\n  font-size: 9px;\n  color: #999;\n  font-weight: 400;\n}\n\n.status-badge {\n  margin-left: auto;\n  color: #1A936F;\n  display: flex;\n  align-items: center;\n}\n\n/* Action Button */\n.action-btn {\n  display: inline-flex;\n  align-items: center;\n  gap: 8px;\n  padding: 10px 20px;\n  font-size: 13px;\n  font-weight: 600;\n  border: none;\n  border-radius: 4px;\n  cursor: pointer;\n  transition: all 0.2s ease;\n  text-transform: uppercase;\n  letter-spacing: 0.05em;\n}\n\n.action-btn.primary {\n  background: #000;\n  color: #FFF;\n}\n\n.action-btn.primary:hover:not(:disabled) {\n  background: #333;\n}\n\n.action-btn:disabled {\n  opacity: 0.3;\n  cursor: not-allowed;\n}\n\n/* --- Main Content Area --- */\n.main-content-area {\n  flex: 1;\n  overflow-y: auto;\n  position: relative;\n  background: #FFF;\n}\n\n/* Timeline Header */\n.timeline-header {\n  position: sticky;\n  top: 0;\n  background: rgba(255, 255, 255, 0.9);\n  backdrop-filter: blur(8px);\n  padding: 12px 24px;\n  border-bottom: 1px solid #EAEAEA;\n  z-index: 5;\n  display: flex;\n  justify-content: center;\n}\n\n.timeline-stats {\n  display: flex;\n  align-items: center;\n  gap: 16px;\n  font-size: 11px;\n  color: #666;\n  background: #F5F5F5;\n  padding: 4px 12px;\n  border-radius: 20px;\n}\n\n.total-count {\n  font-weight: 600;\n  color: #333;\n}\n\n.platform-breakdown {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n}\n\n.breakdown-item {\n  display: flex;\n  align-items: center;\n  gap: 4px;\n}\n\n.breakdown-divider { color: #DDD; }\n.breakdown-item.twitter { color: #000; }\n.breakdown-item.reddit { color: #000; }\n\n/* --- Timeline Feed --- */\n.timeline-feed {\n  padding: 24px 0;\n  position: relative;\n  min-height: 100%;\n  max-width: 900px;\n  margin: 0 auto;\n}\n\n.timeline-axis {\n  position: absolute;\n  left: 50%;\n  top: 0;\n  bottom: 0;\n  width: 1px;\n  background: #EAEAEA; /* Cleaner line */\n  transform: translateX(-50%);\n}\n\n.timeline-item {\n  display: flex;\n  justify-content: center;\n  margin-bottom: 32px;\n  position: relative;\n  width: 100%;\n}\n\n.timeline-marker {\n  position: absolute;\n  left: 50%;\n  top: 24px;\n  width: 10px;\n  height: 10px;\n  background: #FFF;\n  border: 1px solid #CCC;\n  border-radius: 50%;\n  transform: translateX(-50%);\n  z-index: 2;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n}\n\n.marker-dot {\n  width: 4px;\n  height: 4px;\n  background: #CCC;\n  border-radius: 50%;\n}\n\n.timeline-item.twitter .marker-dot { background: #000; }\n.timeline-item.reddit .marker-dot { background: #000; }\n.timeline-item.twitter .timeline-marker { border-color: #000; }\n.timeline-item.reddit .timeline-marker { border-color: #000; }\n\n/* Card Layout */\n.timeline-card {\n  width: calc(100% - 48px);\n  background: #FFF;\n  border-radius: 2px;\n  padding: 16px 20px;\n  border: 1px solid #EAEAEA;\n  box-shadow: 0 2px 10px rgba(0,0,0,0.02);\n  position: relative;\n  transition: all 0.2s;\n}\n\n.timeline-card:hover {\n  box-shadow: 0 4px 12px rgba(0,0,0,0.05);\n  border-color: #DDD;\n}\n\n/* Left side (Twitter) */\n.timeline-item.twitter {\n  justify-content: flex-start;\n  padding-right: 50%;\n}\n.timeline-item.twitter .timeline-card {\n  margin-left: auto;\n  margin-right: 32px; /* Gap from axis */\n}\n\n/* Right side (Reddit) */\n.timeline-item.reddit {\n  justify-content: flex-end;\n  padding-left: 50%;\n}\n.timeline-item.reddit .timeline-card {\n  margin-right: auto;\n  margin-left: 32px; /* Gap from axis */\n}\n\n/* Card Content Styles */\n.card-header {\n  display: flex;\n  justify-content: space-between;\n  align-items: flex-start;\n  margin-bottom: 12px;\n  padding-bottom: 12px;\n  border-bottom: 1px solid #F5F5F5;\n}\n\n.agent-info {\n  display: flex;\n  align-items: center;\n  gap: 10px;\n}\n\n.avatar-placeholder {\n  width: 24px;\n  height: 24px;\n  background: #000;\n  color: #FFF;\n  border-radius: 50%;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  font-size: 12px;\n  font-weight: 700;\n  text-transform: uppercase;\n}\n\n.agent-name {\n  font-size: 13px;\n  font-weight: 600;\n  color: #000;\n}\n\n.header-meta {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n}\n\n.platform-indicator {\n  color: #999;\n  display: flex;\n  align-items: center;\n}\n\n.action-badge {\n  font-size: 9px;\n  padding: 2px 6px;\n  border-radius: 2px;\n  font-weight: 600;\n  text-transform: uppercase;\n  letter-spacing: 0.05em;\n  border: 1px solid transparent;\n}\n\n/* Monochromatic Badges */\n.badge-post { background: #F0F0F0; color: #333; border-color: #E0E0E0; }\n.badge-comment { background: #F0F0F0; color: #666; border-color: #E0E0E0; }\n.badge-action { background: #FFF; color: #666; border: 1px solid #E0E0E0; }\n.badge-meta { background: #FAFAFA; color: #999; border: 1px dashed #DDD; }\n.badge-idle { opacity: 0.5; }\n\n.content-text {\n  font-size: 13px;\n  line-height: 1.6;\n  color: #333;\n  margin-bottom: 10px;\n}\n\n.content-text.main-text {\n  font-size: 14px;\n  color: #000;\n}\n\n/* Info Blocks (Quote, Repost, etc) */\n.quoted-block, .repost-content {\n  background: #F9F9F9;\n  border: 1px solid #EEE;\n  padding: 10px 12px;\n  border-radius: 2px;\n  margin-top: 8px;\n  font-size: 12px;\n  color: #555;\n}\n\n.quote-header, .repost-info, .like-info, .search-info, .follow-info, .vote-info, .idle-info, .comment-context {\n  display: flex;\n  align-items: center;\n  gap: 6px;\n  margin-bottom: 6px;\n  font-size: 11px;\n  color: #666;\n}\n\n.icon-small {\n  color: #999;\n}\n.icon-small.filled {\n  color: #999; /* Keep icons neutral unless highlighted */\n}\n\n.search-query {\n  font-family: 'JetBrains Mono', monospace;\n  background: #F0F0F0;\n  padding: 0 4px;\n  border-radius: 2px;\n}\n\n.card-footer {\n  margin-top: 12px;\n  display: flex;\n  justify-content: flex-end;\n  font-size: 10px;\n  color: #BBB;\n  font-family: 'JetBrains Mono', monospace;\n}\n\n/* Waiting State */\n.waiting-state {\n  position: absolute;\n  top: 50%;\n  left: 50%;\n  transform: translate(-50%, -50%);\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  gap: 16px;\n  color: #CCC;\n  font-size: 12px;\n  text-transform: uppercase;\n  letter-spacing: 0.1em;\n}\n\n.pulse-ring {\n  width: 32px;\n  height: 32px;\n  border-radius: 50%;\n  border: 1px solid #EAEAEA;\n  animation: ripple 2s infinite;\n}\n\n@keyframes ripple {\n  0% { transform: scale(0.8); opacity: 1; border-color: #CCC; }\n  100% { transform: scale(2.5); opacity: 0; border-color: #EAEAEA; }\n}\n\n/* Animation */\n.timeline-item-enter-active,\n.timeline-item-leave-active {\n  transition: all 0.4s cubic-bezier(0.165, 0.84, 0.44, 1);\n}\n\n.timeline-item-enter-from {\n  opacity: 0;\n  transform: translateY(20px);\n}\n\n.timeline-item-leave-to {\n  opacity: 0;\n}\n\n/* Logs */\n.system-logs {\n  background: #000;\n  color: #DDD;\n  padding: 16px;\n  font-family: 'JetBrains Mono', monospace;\n  border-top: 1px solid #222;\n  flex-shrink: 0;\n}\n\n.log-header {\n  display: flex;\n  justify-content: space-between;\n  border-bottom: 1px solid #333;\n  padding-bottom: 8px;\n  margin-bottom: 8px;\n  font-size: 10px;\n  color: #666;\n}\n\n.log-content {\n  display: flex;\n  flex-direction: column;\n  gap: 4px;\n  height: 100px;\n  overflow-y: auto;\n  padding-right: 4px;\n}\n\n.log-content::-webkit-scrollbar { width: 4px; }\n.log-content::-webkit-scrollbar-thumb { background: #333; border-radius: 2px; }\n\n.log-line {\n  font-size: 11px;\n  display: flex;\n  gap: 12px;\n  line-height: 1.5;\n}\n\n.log-time { color: #555; min-width: 75px; }\n.log-msg { color: #BBB; word-break: break-all; }\n.mono { font-family: 'JetBrains Mono', monospace; }\n\n/* Loading spinner for button */\n.loading-spinner-small {\n  display: inline-block;\n  width: 14px;\n  height: 14px;\n  border: 2px solid rgba(255, 255, 255, 0.3);\n  border-top-color: #FFF;\n  border-radius: 50%;\n  animation: spin 0.8s linear infinite;\n  margin-right: 6px;\n}\n</style>"
  },
  {
    "path": "frontend/src/components/Step4Report.vue",
    "content": "<template>\n  <div class=\"report-panel\">\n    <!-- Main Split Layout -->\n    <div class=\"main-split-layout\">\n      <!-- LEFT PANEL: Report Style -->\n      <div class=\"left-panel report-style\" ref=\"leftPanel\">\n        <div v-if=\"reportOutline\" class=\"report-content-wrapper\">\n          <!-- Report Header -->\n          <div class=\"report-header-block\">\n            <div class=\"report-meta\">\n              <span class=\"report-tag\">Prediction Report</span>\n              <span class=\"report-id\">ID: {{ reportId || 'REF-2024-X92' }}</span>\n            </div>\n            <h1 class=\"main-title\">{{ reportOutline.title }}</h1>\n            <p class=\"sub-title\">{{ reportOutline.summary }}</p>\n            <div class=\"header-divider\"></div>\n          </div>\n\n          <!-- Sections List -->\n          <div class=\"sections-list\">\n            <div \n              v-for=\"(section, idx) in reportOutline.sections\" \n              :key=\"idx\"\n              class=\"report-section-item\"\n              :class=\"{ \n                'is-active': currentSectionIndex === idx + 1,\n                'is-completed': isSectionCompleted(idx + 1),\n                'is-pending': !isSectionCompleted(idx + 1) && currentSectionIndex !== idx + 1\n              }\"\n            >\n              <div class=\"section-header-row\" @click=\"toggleSectionCollapse(idx)\" :class=\"{ 'clickable': isSectionCompleted(idx + 1) }\">\n                <span class=\"section-number\">{{ String(idx + 1).padStart(2, '0') }}</span>\n                <h3 class=\"section-title\">{{ section.title }}</h3>\n                <svg \n                  v-if=\"isSectionCompleted(idx + 1)\" \n                  class=\"collapse-icon\" \n                  :class=\"{ 'is-collapsed': collapsedSections.has(idx) }\"\n                  viewBox=\"0 0 24 24\" \n                  width=\"20\" \n                  height=\"20\" \n                  fill=\"none\" \n                  stroke=\"currentColor\" \n                  stroke-width=\"2\"\n                >\n                  <polyline points=\"6 9 12 15 18 9\"></polyline>\n                </svg>\n              </div>\n              \n              <div class=\"section-body\" v-show=\"!collapsedSections.has(idx)\">\n                <!-- Completed Content -->\n                <div v-if=\"generatedSections[idx + 1]\" class=\"generated-content\" v-html=\"renderMarkdown(generatedSections[idx + 1])\"></div>\n                \n                <!-- Loading State -->\n                <div v-else-if=\"currentSectionIndex === idx + 1\" class=\"loading-state\">\n                  <div class=\"loading-icon\">\n                    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\">\n                      <circle cx=\"12\" cy=\"12\" r=\"10\" stroke-width=\"4\" stroke=\"#E5E7EB\"></circle>\n                      <path d=\"M12 2a10 10 0 0 1 10 10\" stroke-width=\"4\" stroke=\"#4B5563\" stroke-linecap=\"round\"></path>\n                    </svg>\n                  </div>\n                  <span class=\"loading-text\">正在生成{{ section.title }}...</span>\n                </div>\n              </div>\n            </div>\n          </div>\n        </div>\n\n        <!-- Waiting State -->\n        <div v-if=\"!reportOutline\" class=\"waiting-placeholder\">\n          <div class=\"waiting-animation\">\n            <div class=\"waiting-ring\"></div>\n            <div class=\"waiting-ring\"></div>\n            <div class=\"waiting-ring\"></div>\n          </div>\n          <span class=\"waiting-text\">Waiting for Report Agent...</span>\n        </div>\n      </div>\n\n      <!-- RIGHT PANEL: Workflow Timeline -->\n      <div class=\"right-panel\" ref=\"rightPanel\">\n        <div class=\"panel-header\" :class=\"`panel-header--${activeStep.status}`\" v-if=\"!isComplete\">\n          <span class=\"header-dot\" v-if=\"activeStep.status === 'active'\"></span>\n          <span class=\"header-index mono\">{{ activeStep.noLabel }}</span>\n          <span class=\"header-title\">{{ activeStep.title }}</span>\n          <span class=\"header-meta mono\" v-if=\"activeStep.meta\">{{ activeStep.meta }}</span>\n        </div>\n\n        <!-- Workflow Overview (flat, status-based palette) -->\n        <div class=\"workflow-overview\" v-if=\"agentLogs.length > 0 || reportOutline\">\n          <div class=\"workflow-metrics\">\n            <div class=\"metric\">\n              <span class=\"metric-label\">Sections</span>\n              <span class=\"metric-value mono\">{{ completedSections }}/{{ totalSections }}</span>\n            </div>\n            <div class=\"metric\">\n              <span class=\"metric-label\">Elapsed</span>\n              <span class=\"metric-value mono\">{{ formatElapsedTime }}</span>\n            </div>\n            <div class=\"metric\">\n              <span class=\"metric-label\">Tools</span>\n              <span class=\"metric-value mono\">{{ totalToolCalls }}</span>\n            </div>\n            <div class=\"metric metric-right\">\n              <span class=\"metric-pill\" :class=\"`pill--${statusClass}`\">{{ statusText }}</span>\n            </div>\n          </div>\n\n          <div class=\"workflow-steps\" v-if=\"workflowSteps.length > 0\">\n            <div\n              v-for=\"(step, sidx) in workflowSteps\"\n              :key=\"step.key\"\n              class=\"wf-step\"\n              :class=\"`wf-step--${step.status}`\"\n            >\n              <div class=\"wf-step-connector\">\n                <div class=\"wf-step-dot\"></div>\n                <div class=\"wf-step-line\" v-if=\"sidx < workflowSteps.length - 1\"></div>\n              </div>\n\n              <div class=\"wf-step-content\">\n                <div class=\"wf-step-title-row\">\n                  <span class=\"wf-step-index mono\">{{ step.noLabel }}</span>\n                  <span class=\"wf-step-title\">{{ step.title }}</span>\n                  <span class=\"wf-step-meta mono\" v-if=\"step.meta\">{{ step.meta }}</span>\n                </div>\n              </div>\n            </div>\n          </div>\n\n          <!-- Next Step Button - 在完成后显示 -->\n          <button v-if=\"isComplete\" class=\"next-step-btn\" @click=\"goToInteraction\">\n            <span>进入深度互动</span>\n            <svg viewBox=\"0 0 24 24\" width=\"16\" height=\"16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n              <line x1=\"5\" y1=\"12\" x2=\"19\" y2=\"12\"></line>\n              <polyline points=\"12 5 19 12 12 19\"></polyline>\n            </svg>\n          </button>\n\n          <div class=\"workflow-divider\"></div>\n        </div>\n\n        <div class=\"workflow-timeline\">\n          <TransitionGroup name=\"timeline-item\">\n            <div \n              v-for=\"(log, idx) in displayLogs\" \n              :key=\"log.timestamp + '-' + idx\"\n              class=\"timeline-item\"\n              :class=\"getTimelineItemClass(log, idx, displayLogs.length)\"\n            >\n              <!-- Timeline Connector -->\n              <div class=\"timeline-connector\">\n                <div class=\"connector-dot\" :class=\"getConnectorClass(log, idx, displayLogs.length)\"></div>\n                <div class=\"connector-line\" v-if=\"idx < displayLogs.length - 1\"></div>\n              </div>\n              \n              <!-- Timeline Content -->\n              <div class=\"timeline-content\">\n                <div class=\"timeline-header\">\n                  <span class=\"action-label\">{{ getActionLabel(log.action) }}</span>\n                  <span class=\"action-time\">{{ formatTime(log.timestamp) }}</span>\n                </div>\n                \n                <!-- Action Body - Different for each type -->\n                <div class=\"timeline-body\" :class=\"{ 'collapsed': isLogCollapsed(log) }\" @click=\"toggleLogExpand(log)\">\n                  \n                  <!-- Report Start -->\n                  <template v-if=\"log.action === 'report_start'\">\n                    <div class=\"info-row\">\n                      <span class=\"info-key\">Simulation</span>\n                      <span class=\"info-val mono\">{{ log.details?.simulation_id }}</span>\n                    </div>\n                    <div class=\"info-row\" v-if=\"log.details?.simulation_requirement\">\n                      <span class=\"info-key\">Requirement</span>\n                      <span class=\"info-val\">{{ log.details.simulation_requirement }}</span>\n                    </div>\n                  </template>\n\n                  <!-- Planning -->\n                  <template v-if=\"log.action === 'planning_start'\">\n                    <div class=\"status-message planning\">{{ log.details?.message }}</div>\n                  </template>\n                  <template v-if=\"log.action === 'planning_complete'\">\n                    <div class=\"status-message success\">{{ log.details?.message }}</div>\n                    <div class=\"outline-badge\" v-if=\"log.details?.outline\">\n                      {{ log.details.outline.sections?.length || 0 }} sections planned\n                    </div>\n                  </template>\n\n                  <!-- Section Start -->\n                  <template v-if=\"log.action === 'section_start'\">\n                    <div class=\"section-tag\">\n                      <span class=\"tag-num\">#{{ log.section_index }}</span>\n                      <span class=\"tag-title\">{{ log.section_title }}</span>\n                    </div>\n                  </template>\n                  \n                  <!-- Section Content Generated (内容生成完成，但整个章节可能还没完成) -->\n                  <template v-if=\"log.action === 'section_content'\">\n                    <div class=\"section-tag content-ready\">\n                      <svg viewBox=\"0 0 24 24\" width=\"14\" height=\"14\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n                        <path d=\"M12 20h9\"></path>\n                        <path d=\"M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z\"></path>\n                      </svg>\n                      <span class=\"tag-title\">{{ log.section_title }}</span>\n                    </div>\n                  </template>\n\n                  <!-- Section Complete (章节生成完成) -->\n                  <template v-if=\"log.action === 'section_complete'\">\n                    <div class=\"section-tag completed\">\n                      <svg viewBox=\"0 0 24 24\" width=\"14\" height=\"14\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n                        <polyline points=\"20 6 9 17 4 12\"></polyline>\n                      </svg>\n                      <span class=\"tag-title\">{{ log.section_title }}</span>\n                    </div>\n                  </template>\n\n                  <!-- Tool Call -->\n                  <template v-if=\"log.action === 'tool_call'\">\n                    <div class=\"tool-badge\" :class=\"'tool-' + getToolColor(log.details?.tool_name)\">\n                      <!-- Deep Insight - Lightbulb -->\n                      <svg v-if=\"getToolIcon(log.details?.tool_name) === 'lightbulb'\" class=\"tool-icon\" viewBox=\"0 0 24 24\" width=\"14\" height=\"14\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n                        <path d=\"M9 18h6M10 22h4M12 2a7 7 0 0 0-4 12.5V17a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1v-2.5A7 7 0 0 0 12 2z\"></path>\n                      </svg>\n                      <!-- Panorama Search - Globe -->\n                      <svg v-else-if=\"getToolIcon(log.details?.tool_name) === 'globe'\" class=\"tool-icon\" viewBox=\"0 0 24 24\" width=\"14\" height=\"14\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n                        <circle cx=\"12\" cy=\"12\" r=\"10\"></circle>\n                        <path d=\"M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z\"></path>\n                      </svg>\n                      <!-- Agent Interview - Users -->\n                      <svg v-else-if=\"getToolIcon(log.details?.tool_name) === 'users'\" class=\"tool-icon\" viewBox=\"0 0 24 24\" width=\"14\" height=\"14\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n                        <path d=\"M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2\"></path>\n                        <circle cx=\"9\" cy=\"7\" r=\"4\"></circle>\n                        <path d=\"M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75\"></path>\n                      </svg>\n                      <!-- Quick Search - Zap -->\n                      <svg v-else-if=\"getToolIcon(log.details?.tool_name) === 'zap'\" class=\"tool-icon\" viewBox=\"0 0 24 24\" width=\"14\" height=\"14\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n                        <polygon points=\"13 2 3 14 12 14 11 22 21 10 12 10 13 2\"></polygon>\n                      </svg>\n                      <!-- Graph Stats - Chart -->\n                      <svg v-else-if=\"getToolIcon(log.details?.tool_name) === 'chart'\" class=\"tool-icon\" viewBox=\"0 0 24 24\" width=\"14\" height=\"14\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n                        <line x1=\"18\" y1=\"20\" x2=\"18\" y2=\"10\"></line>\n                        <line x1=\"12\" y1=\"20\" x2=\"12\" y2=\"4\"></line>\n                        <line x1=\"6\" y1=\"20\" x2=\"6\" y2=\"14\"></line>\n                      </svg>\n                      <!-- Entity Query - Database -->\n                      <svg v-else-if=\"getToolIcon(log.details?.tool_name) === 'database'\" class=\"tool-icon\" viewBox=\"0 0 24 24\" width=\"14\" height=\"14\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n                        <ellipse cx=\"12\" cy=\"5\" rx=\"9\" ry=\"3\"></ellipse>\n                        <path d=\"M21 12c0 1.66-4 3-9 3s-9-1.34-9-3\"></path>\n                        <path d=\"M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5\"></path>\n                      </svg>\n                      <!-- Default - Tool -->\n                      <svg v-else class=\"tool-icon\" viewBox=\"0 0 24 24\" width=\"14\" height=\"14\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n                        <path d=\"M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z\"></path>\n                      </svg>\n                      {{ getToolDisplayName(log.details?.tool_name) }}\n                    </div>\n                    <div v-if=\"log.details?.parameters && expandedLogs.has(log.timestamp)\" class=\"tool-params\">\n                      <pre>{{ formatParams(log.details.parameters) }}</pre>\n                    </div>\n                  </template>\n\n                  <!-- Tool Result -->\n                  <template v-if=\"log.action === 'tool_result'\">\n                    <div class=\"result-wrapper\" :class=\"'result-' + log.details?.tool_name\">\n                      <!-- Hide result-meta for tools that show stats in their own header -->\n                      <div v-if=\"!['interview_agents', 'insight_forge', 'panorama_search', 'quick_search'].includes(log.details?.tool_name)\" class=\"result-meta\">\n                        <span class=\"result-tool\">{{ getToolDisplayName(log.details?.tool_name) }}</span>\n                        <span class=\"result-size\">{{ formatResultSize(log.details?.result_length) }}</span>\n                      </div>\n                      \n                      <!-- Structured Result Display -->\n                      <div v-if=\"!showRawResult[log.timestamp]\" class=\"result-structured\">\n                        <!-- Interview Agents - Special Display -->\n                        <template v-if=\"log.details?.tool_name === 'interview_agents'\">\n                          <InterviewDisplay :result=\"parseInterview(log.details.result)\" :result-length=\"log.details?.result_length\" />\n                        </template>\n                        \n                        <!-- Insight Forge -->\n                        <template v-else-if=\"log.details?.tool_name === 'insight_forge'\">\n                          <InsightDisplay :result=\"parseInsightForge(log.details.result)\" :result-length=\"log.details?.result_length\" />\n                        </template>\n                        \n                        <!-- Panorama Search -->\n                        <template v-else-if=\"log.details?.tool_name === 'panorama_search'\">\n                          <PanoramaDisplay :result=\"parsePanorama(log.details.result)\" :result-length=\"log.details?.result_length\" />\n                        </template>\n                        \n                        <!-- Quick Search -->\n                        <template v-else-if=\"log.details?.tool_name === 'quick_search'\">\n                          <QuickSearchDisplay :result=\"parseQuickSearch(log.details.result)\" :result-length=\"log.details?.result_length\" />\n                        </template>\n                        \n                        <!-- Default -->\n                        <template v-else>\n                          <pre class=\"raw-preview\">{{ truncateText(log.details?.result, 300) }}</pre>\n                        </template>\n                      </div>\n                      \n                      <!-- Raw Result -->\n                      <div v-else class=\"result-raw\">\n                        <pre>{{ log.details?.result }}</pre>\n                      </div>\n                    </div>\n                  </template>\n\n                  <!-- LLM Response -->\n                  <template v-if=\"log.action === 'llm_response'\">\n                    <div class=\"llm-meta\">\n                      <span class=\"meta-tag\">Iteration {{ log.details?.iteration }}</span>\n                      <span class=\"meta-tag\" :class=\"{ active: log.details?.has_tool_calls }\">\n                        Tools: {{ log.details?.has_tool_calls ? 'Yes' : 'No' }}\n                      </span>\n                      <span class=\"meta-tag\" :class=\"{ active: log.details?.has_final_answer, 'final-answer': log.details?.has_final_answer }\">\n                        Final: {{ log.details?.has_final_answer ? 'Yes' : 'No' }}\n                      </span>\n                    </div>\n                    <!-- 当是最终答案时，显示特殊提示 -->\n                    <div v-if=\"log.details?.has_final_answer\" class=\"final-answer-hint\">\n                      <svg viewBox=\"0 0 24 24\" width=\"14\" height=\"14\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n                        <polyline points=\"20 6 9 17 4 12\"></polyline>\n                      </svg>\n                      <span>Section \"{{ log.section_title }}\" content generated</span>\n                    </div>\n                    <div v-if=\"expandedLogs.has(log.timestamp) && log.details?.response\" class=\"llm-content\">\n                      <pre>{{ log.details.response }}</pre>\n                    </div>\n                  </template>\n\n                  <!-- Report Complete -->\n                  <template v-if=\"log.action === 'report_complete'\">\n                    <div class=\"complete-banner\">\n                      <svg viewBox=\"0 0 24 24\" width=\"20\" height=\"20\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n                        <path d=\"M22 11.08V12a10 10 0 1 1-5.93-9.14\"></path>\n                        <polyline points=\"22 4 12 14.01 9 11.01\"></polyline>\n                      </svg>\n                      <span>Report Generation Complete</span>\n                    </div>\n                  </template>\n                </div>\n\n                <!-- Footer: Elapsed Time + Action Buttons -->\n                <div class=\"timeline-footer\" v-if=\"log.elapsed_seconds || (log.action === 'tool_call' && log.details?.parameters) || log.action === 'tool_result' || (log.action === 'llm_response' && log.details?.response)\">\n                  <span v-if=\"log.elapsed_seconds\" class=\"elapsed-badge\">+{{ log.elapsed_seconds.toFixed(1) }}s</span>\n                  <span v-else class=\"elapsed-placeholder\"></span>\n                  \n                  <div class=\"footer-actions\">\n                    <!-- Tool Call: Show/Hide Params -->\n                    <button v-if=\"log.action === 'tool_call' && log.details?.parameters\" class=\"action-btn\" @click.stop=\"toggleLogExpand(log)\">\n                      {{ expandedLogs.has(log.timestamp) ? 'Hide Params' : 'Show Params' }}\n                    </button>\n                    \n                    <!-- Tool Result: Raw/Structured View -->\n                    <button v-if=\"log.action === 'tool_result'\" class=\"action-btn\" @click.stop=\"toggleRawResult(log.timestamp, $event)\">\n                      {{ showRawResult[log.timestamp] ? 'Structured View' : 'Raw Output' }}\n                    </button>\n                    \n                    <!-- LLM Response: Show/Hide Response -->\n                    <button v-if=\"log.action === 'llm_response' && log.details?.response\" class=\"action-btn\" @click.stop=\"toggleLogExpand(log)\">\n                      {{ expandedLogs.has(log.timestamp) ? 'Hide Response' : 'Show Response' }}\n                    </button>\n                  </div>\n                </div>\n              </div>\n            </div>\n          </TransitionGroup>\n\n          <!-- Empty State -->\n          <div v-if=\"agentLogs.length === 0 && !isComplete\" class=\"workflow-empty\">\n            <div class=\"empty-pulse\"></div>\n            <span>Waiting for agent activity...</span>\n          </div>\n        </div>\n      </div>\n    </div>\n\n    <!-- Bottom Console Logs -->\n    <div class=\"console-logs\">\n      <div class=\"log-header\">\n        <span class=\"log-title\">CONSOLE OUTPUT</span>\n        <span class=\"log-id\">{{ reportId || 'NO_REPORT' }}</span>\n      </div>\n      <div class=\"log-content\" ref=\"logContent\">\n        <div class=\"log-line\" v-for=\"(log, idx) in consoleLogs\" :key=\"idx\">\n          <span class=\"log-msg\" :class=\"getLogLevelClass(log)\">{{ log }}</span>\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { ref, computed, watch, onMounted, onUnmounted, nextTick, h, reactive } from 'vue'\nimport { useRouter } from 'vue-router'\nimport { getAgentLog, getConsoleLog } from '../api/report'\n\nconst router = useRouter()\n\nconst props = defineProps({\n  reportId: String,\n  simulationId: String,\n  systemLogs: Array\n})\n\nconst emit = defineEmits(['add-log', 'update-status'])\n\n// Navigation\nconst goToInteraction = () => {\n  if (props.reportId) {\n    router.push({ name: 'Interaction', params: { reportId: props.reportId } })\n  }\n}\n\n// State\nconst agentLogs = ref([])\nconst consoleLogs = ref([])\nconst agentLogLine = ref(0)\nconst consoleLogLine = ref(0)\nconst reportOutline = ref(null)\nconst currentSectionIndex = ref(null)\nconst generatedSections = ref({})\nconst expandedContent = ref(new Set())\nconst expandedLogs = ref(new Set())\nconst collapsedSections = ref(new Set())\nconst isComplete = ref(false)\nconst startTime = ref(null)\nconst leftPanel = ref(null)\nconst rightPanel = ref(null)\nconst logContent = ref(null)\nconst showRawResult = reactive({})\n\n// Toggle functions\nconst toggleRawResult = (timestamp, event) => {\n  // 保存按钮相对于视口的位置\n  const button = event?.target\n  const buttonRect = button?.getBoundingClientRect()\n  const buttonTopBeforeToggle = buttonRect?.top\n  \n  // 切换状态\n  showRawResult[timestamp] = !showRawResult[timestamp]\n  \n  // 等待 DOM 更新后，调整滚动位置以保持按钮在相同位置\n  if (button && buttonTopBeforeToggle !== undefined && rightPanel.value) {\n    nextTick(() => {\n      const newButtonRect = button.getBoundingClientRect()\n      const buttonTopAfterToggle = newButtonRect.top\n      const scrollDelta = buttonTopAfterToggle - buttonTopBeforeToggle\n      \n      // 调整滚动位置\n      rightPanel.value.scrollTop += scrollDelta\n    })\n  }\n}\n\nconst toggleSectionContent = (idx) => {\n  if (!generatedSections.value[idx + 1]) return\n  const newSet = new Set(expandedContent.value)\n  if (newSet.has(idx)) {\n    newSet.delete(idx)\n  } else {\n    newSet.add(idx)\n  }\n  expandedContent.value = newSet\n}\n\nconst toggleSectionCollapse = (idx) => {\n  // 只有已完成的章节才能折叠\n  if (!generatedSections.value[idx + 1]) return\n  const newSet = new Set(collapsedSections.value)\n  if (newSet.has(idx)) {\n    newSet.delete(idx)\n  } else {\n    newSet.add(idx)\n  }\n  collapsedSections.value = newSet\n}\n\nconst toggleLogExpand = (log) => {\n  const newSet = new Set(expandedLogs.value)\n  if (newSet.has(log.timestamp)) {\n    newSet.delete(log.timestamp)\n  } else {\n    newSet.add(log.timestamp)\n  }\n  expandedLogs.value = newSet\n}\n\nconst isLogCollapsed = (log) => {\n  if (['tool_call', 'tool_result', 'llm_response'].includes(log.action)) {\n    return !expandedLogs.value.has(log.timestamp)\n  }\n  return false\n}\n\n// Tool configurations with display names and colors\nconst toolConfig = {\n  'insight_forge': {\n    name: 'Deep Insight',\n    color: 'purple',\n    icon: 'lightbulb' // 灯泡图标 - 代表洞察\n  },\n  'panorama_search': {\n    name: 'Panorama Search',\n    color: 'blue',\n    icon: 'globe' // 地球图标 - 代表全景搜索\n  },\n  'interview_agents': {\n    name: 'Agent Interview',\n    color: 'green',\n    icon: 'users' // 用户图标 - 代表对话\n  },\n  'quick_search': {\n    name: 'Quick Search',\n    color: 'orange',\n    icon: 'zap' // 闪电图标 - 代表快速\n  },\n  'get_graph_statistics': {\n    name: 'Graph Stats',\n    color: 'cyan',\n    icon: 'chart' // 图表图标 - 代表统计\n  },\n  'get_entities_by_type': {\n    name: 'Entity Query',\n    color: 'pink',\n    icon: 'database' // 数据库图标 - 代表实体\n  }\n}\n\nconst getToolDisplayName = (toolName) => {\n  return toolConfig[toolName]?.name || toolName\n}\n\nconst getToolColor = (toolName) => {\n  return toolConfig[toolName]?.color || 'gray'\n}\n\nconst getToolIcon = (toolName) => {\n  return toolConfig[toolName]?.icon || 'tool'\n}\n\n// Parse functions\nconst parseInsightForge = (text) => {\n  const result = {\n    query: '',\n    simulationRequirement: '',\n    stats: { facts: 0, entities: 0, relationships: 0 },\n    subQueries: [],\n    facts: [],\n    entities: [],\n    relations: []\n  }\n  \n  try {\n    // 提取分析问题\n    const queryMatch = text.match(/分析问题:\\s*(.+?)(?:\\n|$)/)\n    if (queryMatch) result.query = queryMatch[1].trim()\n    \n    // 提取预测场景\n    const reqMatch = text.match(/预测场景:\\s*(.+?)(?:\\n|$)/)\n    if (reqMatch) result.simulationRequirement = reqMatch[1].trim()\n    \n    // 提取统计数据 - 匹配\"相关预测事实: X条\"格式\n    const factMatch = text.match(/相关预测事实:\\s*(\\d+)/)\n    const entityMatch = text.match(/涉及实体:\\s*(\\d+)/)\n    const relMatch = text.match(/关系链:\\s*(\\d+)/)\n    if (factMatch) result.stats.facts = parseInt(factMatch[1])\n    if (entityMatch) result.stats.entities = parseInt(entityMatch[1])\n    if (relMatch) result.stats.relationships = parseInt(relMatch[1])\n    \n    // 提取子问题 - 完整提取，不限制数量\n    const subQSection = text.match(/### 分析的子问题\\n([\\s\\S]*?)(?=\\n###|$)/)\n    if (subQSection) {\n      const lines = subQSection[1].split('\\n').filter(l => l.match(/^\\d+\\./))\n      result.subQueries = lines.map(l => l.replace(/^\\d+\\.\\s*/, '').trim()).filter(Boolean)\n    }\n    \n    // 提取关键事实 - 完整提取，不限制数量\n    const factsSection = text.match(/### 【关键事实】[\\s\\S]*?\\n([\\s\\S]*?)(?=\\n###|$)/)\n    if (factsSection) {\n      const lines = factsSection[1].split('\\n').filter(l => l.match(/^\\d+\\./))\n      result.facts = lines.map(l => {\n        const match = l.match(/^\\d+\\.\\s*\"?(.+?)\"?\\s*$/)\n        return match ? match[1].replace(/^\"|\"$/g, '').trim() : l.replace(/^\\d+\\.\\s*/, '').trim()\n      }).filter(Boolean)\n    }\n    \n    // 提取核心实体 - 完整提取，包含摘要和相关事实数\n    const entitySection = text.match(/### 【核心实体】\\n([\\s\\S]*?)(?=\\n###|$)/)\n    if (entitySection) {\n      const entityText = entitySection[1]\n      // 按 \"- **\" 分割实体块\n      const entityBlocks = entityText.split(/\\n(?=- \\*\\*)/).filter(b => b.trim().startsWith('- **'))\n      result.entities = entityBlocks.map(block => {\n        const nameMatch = block.match(/^-\\s*\\*\\*(.+?)\\*\\*\\s*\\((.+?)\\)/)\n        const summaryMatch = block.match(/摘要:\\s*\"?(.+?)\"?(?:\\n|$)/)\n        const relatedMatch = block.match(/相关事实:\\s*(\\d+)/)\n        return {\n          name: nameMatch ? nameMatch[1].trim() : '',\n          type: nameMatch ? nameMatch[2].trim() : '',\n          summary: summaryMatch ? summaryMatch[1].trim() : '',\n          relatedFactsCount: relatedMatch ? parseInt(relatedMatch[1]) : 0\n        }\n      }).filter(e => e.name)\n    }\n    \n    // 提取关系链 - 完整提取，不限制数量\n    const relSection = text.match(/### 【关系链】\\n([\\s\\S]*?)(?=\\n###|$)/)\n    if (relSection) {\n      const lines = relSection[1].split('\\n').filter(l => l.trim().startsWith('-'))\n      result.relations = lines.map(l => {\n        const match = l.match(/^-\\s*(.+?)\\s*--\\[(.+?)\\]-->\\s*(.+)$/)\n        if (match) {\n          return { source: match[1].trim(), relation: match[2].trim(), target: match[3].trim() }\n        }\n        return null\n      }).filter(Boolean)\n    }\n  } catch (e) {\n    console.warn('Parse insight_forge failed:', e)\n  }\n  \n  return result\n}\n\nconst parsePanorama = (text) => {\n  const result = {\n    query: '',\n    stats: { nodes: 0, edges: 0, activeFacts: 0, historicalFacts: 0 },\n    activeFacts: [],\n    historicalFacts: [],\n    entities: []\n  }\n  \n  try {\n    // 提取查询\n    const queryMatch = text.match(/查询:\\s*(.+?)(?:\\n|$)/)\n    if (queryMatch) result.query = queryMatch[1].trim()\n    \n    // 提取统计数据\n    const nodesMatch = text.match(/总节点数:\\s*(\\d+)/)\n    const edgesMatch = text.match(/总边数:\\s*(\\d+)/)\n    const activeMatch = text.match(/当前有效事实:\\s*(\\d+)/)\n    const histMatch = text.match(/历史\\/过期事实:\\s*(\\d+)/)\n    if (nodesMatch) result.stats.nodes = parseInt(nodesMatch[1])\n    if (edgesMatch) result.stats.edges = parseInt(edgesMatch[1])\n    if (activeMatch) result.stats.activeFacts = parseInt(activeMatch[1])\n    if (histMatch) result.stats.historicalFacts = parseInt(histMatch[1])\n    \n    // 提取当前有效事实 - 完整提取，不限制数量\n    const activeSection = text.match(/### 【当前有效事实】[\\s\\S]*?\\n([\\s\\S]*?)(?=\\n###|$)/)\n    if (activeSection) {\n      const lines = activeSection[1].split('\\n').filter(l => l.match(/^\\d+\\./))\n      result.activeFacts = lines.map(l => {\n        // 移除编号和引号\n        const factText = l.replace(/^\\d+\\.\\s*/, '').replace(/^\"|\"$/g, '').trim()\n        return factText\n      }).filter(Boolean)\n    }\n    \n    // 提取历史/过期事实 - 完整提取，不限制数量\n    const histSection = text.match(/### 【历史\\/过期事实】[\\s\\S]*?\\n([\\s\\S]*?)(?=\\n###|$)/)\n    if (histSection) {\n      const lines = histSection[1].split('\\n').filter(l => l.match(/^\\d+\\./))\n      result.historicalFacts = lines.map(l => {\n        const factText = l.replace(/^\\d+\\.\\s*/, '').replace(/^\"|\"$/g, '').trim()\n        return factText\n      }).filter(Boolean)\n    }\n    \n    // 提取涉及实体 - 完整提取，不限制数量\n    const entitySection = text.match(/### 【涉及实体】\\n([\\s\\S]*?)(?=\\n###|$)/)\n    if (entitySection) {\n      const lines = entitySection[1].split('\\n').filter(l => l.trim().startsWith('-'))\n      result.entities = lines.map(l => {\n        const match = l.match(/^-\\s*\\*\\*(.+?)\\*\\*\\s*\\((.+?)\\)/)\n        if (match) return { name: match[1].trim(), type: match[2].trim() }\n        return null\n      }).filter(Boolean)\n    }\n  } catch (e) {\n    console.warn('Parse panorama failed:', e)\n  }\n  \n  return result\n}\n\nconst parseInterview = (text) => {\n  const result = {\n    topic: '',\n    agentCount: '',\n    successCount: 0,\n    totalCount: 0,\n    selectionReason: '',\n    interviews: [],\n    summary: ''\n  }\n  \n  try {\n    // 提取采访主题\n    const topicMatch = text.match(/\\*\\*采访主题:\\*\\*\\s*(.+?)(?:\\n|$)/)\n    if (topicMatch) result.topic = topicMatch[1].trim()\n    \n    // 提取采访人数（如 \"5 / 9 位模拟Agent\"）\n    const countMatch = text.match(/\\*\\*采访人数:\\*\\*\\s*(\\d+)\\s*\\/\\s*(\\d+)/)\n    if (countMatch) {\n      result.successCount = parseInt(countMatch[1])\n      result.totalCount = parseInt(countMatch[2])\n      result.agentCount = `${countMatch[1]} / ${countMatch[2]}`\n    }\n    \n    // 提取采访对象选择理由\n    const reasonMatch = text.match(/### 采访对象选择理由\\n([\\s\\S]*?)(?=\\n---\\n|\\n### 采访实录)/)\n    if (reasonMatch) {\n      result.selectionReason = reasonMatch[1].trim()\n    }\n    \n    // 解析每个人的选择理由\n    const parseIndividualReasons = (reasonText) => {\n      const reasons = {}\n      if (!reasonText) return reasons\n      \n      const lines = reasonText.split(/\\n+/)\n      let currentName = null\n      let currentReason = []\n      \n      for (const line of lines) {\n        let headerMatch = null\n        let name = null\n        let reasonStart = null\n        \n        // 格式1: 数字. **名字（index=X）**：理由\n        // 例如: 1. **校友_345（index=1）**：作为武大校友...\n        headerMatch = line.match(/^\\d+\\.\\s*\\*\\*([^*（(]+)(?:[（(]index\\s*=?\\s*\\d+[)）])?\\*\\*[：:]\\s*(.*)/)\n        if (headerMatch) {\n          name = headerMatch[1].trim()\n          reasonStart = headerMatch[2]\n        }\n        \n        // 格式2: - 选择名字（index X）：理由\n        // 例如: - 选择家长_601（index 0）：作为家长群体代表...\n        if (!headerMatch) {\n          headerMatch = line.match(/^-\\s*选择([^（(]+)(?:[（(]index\\s*=?\\s*\\d+[)）])?[：:]\\s*(.*)/)\n          if (headerMatch) {\n            name = headerMatch[1].trim()\n            reasonStart = headerMatch[2]\n          }\n        }\n        \n        // 格式3: - **名字（index X）**：理由\n        // 例如: - **家长_601（index 0）**：作为家长群体代表...\n        if (!headerMatch) {\n          headerMatch = line.match(/^-\\s*\\*\\*([^*（(]+)(?:[（(]index\\s*=?\\s*\\d+[)）])?\\*\\*[：:]\\s*(.*)/)\n          if (headerMatch) {\n            name = headerMatch[1].trim()\n            reasonStart = headerMatch[2]\n          }\n        }\n        \n        if (name) {\n          // 保存上一个人的理由\n          if (currentName && currentReason.length > 0) {\n            reasons[currentName] = currentReason.join(' ').trim()\n          }\n          // 开始新的人\n          currentName = name\n          currentReason = reasonStart ? [reasonStart.trim()] : []\n        } else if (currentName && line.trim() && !line.match(/^未选|^综上|^最终选择/)) {\n          // 理由的续行（排除结尾总结段落）\n          currentReason.push(line.trim())\n        }\n      }\n      \n      // 保存最后一个人的理由\n      if (currentName && currentReason.length > 0) {\n        reasons[currentName] = currentReason.join(' ').trim()\n      }\n      \n      return reasons\n    }\n    \n    const individualReasons = parseIndividualReasons(result.selectionReason)\n    \n    // 提取每个采访记录\n    const interviewBlocks = text.split(/#### 采访 #\\d+:/).slice(1)\n    \n    interviewBlocks.forEach((block, index) => {\n      const interview = {\n        num: index + 1,\n        title: '',\n        name: '',\n        role: '',\n        bio: '',\n        selectionReason: '',\n        questions: [],\n        twitterAnswer: '',\n        redditAnswer: '',\n        quotes: []\n      }\n      \n      // 提取标题（如 \"学生\"、\"教育从业者\" 等）\n      const titleMatch = block.match(/^(.+?)\\n/)\n      if (titleMatch) interview.title = titleMatch[1].trim()\n      \n      // 提取姓名和角色\n      const nameRoleMatch = block.match(/\\*\\*(.+?)\\*\\*\\s*\\((.+?)\\)/)\n      if (nameRoleMatch) {\n        interview.name = nameRoleMatch[1].trim()\n        interview.role = nameRoleMatch[2].trim()\n        // 设置该人的选择理由\n        interview.selectionReason = individualReasons[interview.name] || ''\n      }\n      \n      // 提取简介\n      const bioMatch = block.match(/_简介:\\s*([\\s\\S]*?)_\\n/)\n      if (bioMatch) {\n        interview.bio = bioMatch[1].trim().replace(/\\.\\.\\.$/, '...')\n      }\n      \n      // 提取问题列表\n      const qMatch = block.match(/\\*\\*Q:\\*\\*\\s*([\\s\\S]*?)(?=\\n\\n\\*\\*A:\\*\\*|\\*\\*A:\\*\\*)/)\n      if (qMatch) {\n        const qText = qMatch[1].trim()\n        // 按数字编号分割问题\n        const questions = qText.split(/\\n\\d+\\.\\s+/).filter(q => q.trim())\n        if (questions.length > 0) {\n          // 如果第一个问题前面有\"1.\"，需要特殊处理\n          const firstQ = qText.match(/^1\\.\\s+(.+)/)\n          if (firstQ) {\n            interview.questions = [firstQ[1].trim(), ...questions.slice(1).map(q => q.trim())]\n          } else {\n            interview.questions = questions.map(q => q.trim())\n          }\n        }\n      }\n      \n      // 提取回答 - 分Twitter和Reddit\n      const answerMatch = block.match(/\\*\\*A:\\*\\*\\s*([\\s\\S]*?)(?=\\*\\*关键引言|$)/)\n      if (answerMatch) {\n        const answerText = answerMatch[1].trim()\n        \n        // 分离Twitter和Reddit回答\n        const twitterMatch = answerText.match(/【Twitter平台回答】\\n?([\\s\\S]*?)(?=【Reddit平台回答】|$)/)\n        const redditMatch = answerText.match(/【Reddit平台回答】\\n?([\\s\\S]*?)$/)\n        \n        if (twitterMatch) {\n          interview.twitterAnswer = twitterMatch[1].trim()\n        }\n        if (redditMatch) {\n          interview.redditAnswer = redditMatch[1].trim()\n        }\n        \n        // 平台回退逻辑（兼容旧格式：只有一个平台标记的情况）\n        if (!twitterMatch && redditMatch) {\n          // 只有 Reddit 回答，仅在非占位文本时复制为默认显示\n          if (interview.redditAnswer && interview.redditAnswer !== '（该平台未获得回复）') {\n            interview.twitterAnswer = interview.redditAnswer\n          }\n        } else if (twitterMatch && !redditMatch) {\n          if (interview.twitterAnswer && interview.twitterAnswer !== '（该平台未获得回复）') {\n            interview.redditAnswer = interview.twitterAnswer\n          }\n        } else if (!twitterMatch && !redditMatch) {\n          // 没有分平台标记（极旧格式），整体作为回答\n          interview.twitterAnswer = answerText\n        }\n      }\n      \n      // 提取关键引言（兼容多种引号格式）\n      const quotesMatch = block.match(/\\*\\*关键引言:\\*\\*\\n([\\s\\S]*?)(?=\\n---|\\n####|$)/)\n      if (quotesMatch) {\n        const quotesText = quotesMatch[1]\n        // 优先匹配 > \"text\" 格式\n        let quoteMatches = quotesText.match(/> \"([^\"]+)\"/g)\n        // 回退：匹配 > \"text\" 或 > \\u201Ctext\\u201D（中文引号）\n        if (!quoteMatches) {\n          quoteMatches = quotesText.match(/> [\\u201C\"\"]([^\\u201D\"\"]+)[\\u201D\"\"]/g)\n        }\n        if (quoteMatches) {\n          interview.quotes = quoteMatches\n            .map(q => q.replace(/^> [\\u201C\"\"]|[\\u201D\"\"]$/g, '').trim())\n            .filter(q => q)\n        }\n      }\n      \n      if (interview.name || interview.title) {\n        result.interviews.push(interview)\n      }\n    })\n    \n    // 提取采访摘要\n    const summaryMatch = text.match(/### 采访摘要与核心观点\\n([\\s\\S]*?)$/)\n    if (summaryMatch) {\n      result.summary = summaryMatch[1].trim()\n    }\n  } catch (e) {\n    console.warn('Parse interview failed:', e)\n  }\n  \n  return result\n}\n\nconst parseQuickSearch = (text) => {\n  const result = {\n    query: '',\n    count: 0,\n    facts: [],\n    edges: [],\n    nodes: []\n  }\n  \n  try {\n    // 提取搜索查询\n    const queryMatch = text.match(/搜索查询:\\s*(.+?)(?:\\n|$)/)\n    if (queryMatch) result.query = queryMatch[1].trim()\n    \n    // 提取结果数量\n    const countMatch = text.match(/找到\\s*(\\d+)\\s*条/)\n    if (countMatch) result.count = parseInt(countMatch[1])\n    \n    // 提取相关事实 - 完整提取，不限制数量\n    const factsSection = text.match(/### 相关事实:\\n([\\s\\S]*)$/)\n    if (factsSection) {\n      const lines = factsSection[1].split('\\n').filter(l => l.match(/^\\d+\\./))\n      result.facts = lines.map(l => l.replace(/^\\d+\\.\\s*/, '').trim()).filter(Boolean)\n    }\n    \n    // 尝试提取边信息（如果有）\n    const edgesSection = text.match(/### 相关边:\\n([\\s\\S]*?)(?=\\n###|$)/)\n    if (edgesSection) {\n      const lines = edgesSection[1].split('\\n').filter(l => l.trim().startsWith('-'))\n      result.edges = lines.map(l => {\n        const match = l.match(/^-\\s*(.+?)\\s*--\\[(.+?)\\]-->\\s*(.+)$/)\n        if (match) {\n          return { source: match[1].trim(), relation: match[2].trim(), target: match[3].trim() }\n        }\n        return null\n      }).filter(Boolean)\n    }\n    \n    // 尝试提取节点信息（如果有）\n    const nodesSection = text.match(/### 相关节点:\\n([\\s\\S]*?)(?=\\n###|$)/)\n    if (nodesSection) {\n      const lines = nodesSection[1].split('\\n').filter(l => l.trim().startsWith('-'))\n      result.nodes = lines.map(l => {\n        const match = l.match(/^-\\s*\\*\\*(.+?)\\*\\*\\s*\\((.+?)\\)/)\n        if (match) return { name: match[1].trim(), type: match[2].trim() }\n        const simpleMatch = l.match(/^-\\s*(.+)$/)\n        if (simpleMatch) return { name: simpleMatch[1].trim(), type: '' }\n        return null\n      }).filter(Boolean)\n    }\n  } catch (e) {\n    console.warn('Parse quick_search failed:', e)\n  }\n  \n  return result\n}\n\n// ========== Sub Components ==========\n\n// Insight Display Component - Enhanced with full data rendering (Interview-like style)\nconst InsightDisplay = {\n  props: ['result', 'resultLength'],\n  setup(props) {\n    const activeTab = ref('facts') // 'facts', 'entities', 'relations', 'subqueries'\n    const expandedFacts = ref(false)\n    const expandedEntities = ref(false)\n    const expandedRelations = ref(false)\n    const INITIAL_SHOW_COUNT = 5\n    \n    // Format result size for display\n    const formatSize = (length) => {\n      if (!length) return ''\n      if (length >= 1000) {\n        return `${(length / 1000).toFixed(1)}k chars`\n      }\n      return `${length} chars`\n    }\n    \n    return () => h('div', { class: 'insight-display' }, [\n      // Header Section - like interview header\n      h('div', { class: 'insight-header' }, [\n        h('div', { class: 'header-main' }, [\n          h('div', { class: 'header-title' }, 'Deep Insight'),\n          h('div', { class: 'header-stats' }, [\n            h('span', { class: 'stat-item' }, [\n              h('span', { class: 'stat-value' }, props.result.stats.facts || props.result.facts.length),\n              h('span', { class: 'stat-label' }, 'Facts')\n            ]),\n            h('span', { class: 'stat-divider' }, '/'),\n            h('span', { class: 'stat-item' }, [\n              h('span', { class: 'stat-value' }, props.result.stats.entities || props.result.entities.length),\n              h('span', { class: 'stat-label' }, 'Entities')\n            ]),\n            h('span', { class: 'stat-divider' }, '/'),\n            h('span', { class: 'stat-item' }, [\n              h('span', { class: 'stat-value' }, props.result.stats.relationships || props.result.relations.length),\n              h('span', { class: 'stat-label' }, 'Relations')\n            ]),\n            props.resultLength && h('span', { class: 'stat-divider' }, '·'),\n            props.resultLength && h('span', { class: 'stat-size' }, formatSize(props.resultLength))\n          ])\n        ]),\n        props.result.query && h('div', { class: 'header-topic' }, props.result.query),\n        props.result.simulationRequirement && h('div', { class: 'header-scenario' }, [\n          h('span', { class: 'scenario-label' }, '预测场景: '),\n          h('span', { class: 'scenario-text' }, props.result.simulationRequirement)\n        ])\n      ]),\n      \n      // Tab Navigation\n      h('div', { class: 'insight-tabs' }, [\n        h('button', {\n          class: ['insight-tab', { active: activeTab.value === 'facts' }],\n          onClick: () => { activeTab.value = 'facts' }\n        }, [\n          h('span', { class: 'tab-label' }, `当前关键记忆 (${props.result.facts.length})`)\n        ]),\n        h('button', {\n          class: ['insight-tab', { active: activeTab.value === 'entities' }],\n          onClick: () => { activeTab.value = 'entities' }\n        }, [\n          h('span', { class: 'tab-label' }, `核心实体 (${props.result.entities.length})`)\n        ]),\n        h('button', {\n          class: ['insight-tab', { active: activeTab.value === 'relations' }],\n          onClick: () => { activeTab.value = 'relations' }\n        }, [\n          h('span', { class: 'tab-label' }, `关系链 (${props.result.relations.length})`)\n        ]),\n        props.result.subQueries.length > 0 && h('button', {\n          class: ['insight-tab', { active: activeTab.value === 'subqueries' }],\n          onClick: () => { activeTab.value = 'subqueries' }\n        }, [\n          h('span', { class: 'tab-label' }, `子问题 (${props.result.subQueries.length})`)\n        ])\n      ]),\n      \n      // Tab Content\n      h('div', { class: 'insight-content' }, [\n        // Facts Tab\n        activeTab.value === 'facts' && props.result.facts.length > 0 && h('div', { class: 'facts-panel' }, [\n          h('div', { class: 'panel-header' }, [\n            h('span', { class: 'panel-title' }, '时序记忆中所关联的最新关键事实'),\n            h('span', { class: 'panel-count' }, `共 ${props.result.facts.length} 条`)\n          ]),\n          h('div', { class: 'facts-list' },\n            (expandedFacts.value ? props.result.facts : props.result.facts.slice(0, INITIAL_SHOW_COUNT)).map((fact, i) => \n              h('div', { class: 'fact-item', key: i }, [\n                h('span', { class: 'fact-number' }, i + 1),\n                h('div', { class: 'fact-content' }, fact)\n              ])\n            )\n          ),\n          props.result.facts.length > INITIAL_SHOW_COUNT && h('button', {\n            class: 'expand-btn',\n            onClick: () => { expandedFacts.value = !expandedFacts.value }\n          }, expandedFacts.value ? `收起 ▲` : `展开全部 ${props.result.facts.length} 条 ▼`)\n        ]),\n        \n        // Entities Tab\n        activeTab.value === 'entities' && props.result.entities.length > 0 && h('div', { class: 'entities-panel' }, [\n          h('div', { class: 'panel-header' }, [\n            h('span', { class: 'panel-title' }, '核心实体'),\n            h('span', { class: 'panel-count' }, `共 ${props.result.entities.length} 个`)\n          ]),\n          h('div', { class: 'entities-grid' },\n            (expandedEntities.value ? props.result.entities : props.result.entities.slice(0, 12)).map((entity, i) => \n              h('div', { class: 'entity-tag', key: i, title: entity.summary || '' }, [\n                h('span', { class: 'entity-name' }, entity.name),\n                h('span', { class: 'entity-type' }, entity.type),\n                entity.relatedFactsCount > 0 && h('span', { class: 'entity-fact-count' }, `${entity.relatedFactsCount}条`)\n              ])\n            )\n          ),\n          props.result.entities.length > 12 && h('button', {\n            class: 'expand-btn',\n            onClick: () => { expandedEntities.value = !expandedEntities.value }\n          }, expandedEntities.value ? `收起 ▲` : `展开全部 ${props.result.entities.length} 个 ▼`)\n        ]),\n        \n        // Relations Tab\n        activeTab.value === 'relations' && props.result.relations.length > 0 && h('div', { class: 'relations-panel' }, [\n          h('div', { class: 'panel-header' }, [\n            h('span', { class: 'panel-title' }, '关系链'),\n            h('span', { class: 'panel-count' }, `共 ${props.result.relations.length} 条`)\n          ]),\n          h('div', { class: 'relations-list' },\n            (expandedRelations.value ? props.result.relations : props.result.relations.slice(0, INITIAL_SHOW_COUNT)).map((rel, i) => \n              h('div', { class: 'relation-item', key: i }, [\n                h('span', { class: 'rel-source' }, rel.source),\n                h('span', { class: 'rel-arrow' }, [\n                  h('span', { class: 'rel-line' }),\n                  h('span', { class: 'rel-label' }, rel.relation),\n                  h('span', { class: 'rel-line' })\n                ]),\n                h('span', { class: 'rel-target' }, rel.target)\n              ])\n            )\n          ),\n          props.result.relations.length > INITIAL_SHOW_COUNT && h('button', {\n            class: 'expand-btn',\n            onClick: () => { expandedRelations.value = !expandedRelations.value }\n          }, expandedRelations.value ? `收起 ▲` : `展开全部 ${props.result.relations.length} 条 ▼`)\n        ]),\n        \n        // Sub-queries Tab\n        activeTab.value === 'subqueries' && props.result.subQueries.length > 0 && h('div', { class: 'subqueries-panel' }, [\n          h('div', { class: 'panel-header' }, [\n            h('span', { class: 'panel-title' }, '漂移查询生成分析子问题'),\n            h('span', { class: 'panel-count' }, `共 ${props.result.subQueries.length} 个`)\n          ]),\n          h('div', { class: 'subqueries-list' },\n            props.result.subQueries.map((sq, i) => \n              h('div', { class: 'subquery-item', key: i }, [\n                h('span', { class: 'subquery-number' }, `Q${i + 1}`),\n                h('div', { class: 'subquery-text' }, sq)\n              ])\n            )\n          )\n        ]),\n        \n        // Empty state\n        activeTab.value === 'facts' && props.result.facts.length === 0 && h('div', { class: 'empty-state' }, '暂无当前关键记忆'),\n        activeTab.value === 'entities' && props.result.entities.length === 0 && h('div', { class: 'empty-state' }, '暂无核心实体'),\n        activeTab.value === 'relations' && props.result.relations.length === 0 && h('div', { class: 'empty-state' }, '暂无关系链')\n      ])\n    ])\n  }\n}\n\n// Panorama Display Component - Enhanced with Active/Historical tabs\nconst PanoramaDisplay = {\n  props: ['result', 'resultLength'],\n  setup(props) {\n    const activeTab = ref('active') // 'active', 'historical', 'entities'\n    const expandedActive = ref(false)\n    const expandedHistorical = ref(false)\n    const expandedEntities = ref(false)\n    const INITIAL_SHOW_COUNT = 5\n    \n    // Format result size for display\n    const formatSize = (length) => {\n      if (!length) return ''\n      if (length >= 1000) {\n        return `${(length / 1000).toFixed(1)}k chars`\n      }\n      return `${length} chars`\n    }\n    \n    return () => h('div', { class: 'panorama-display' }, [\n      // Header Section\n      h('div', { class: 'panorama-header' }, [\n        h('div', { class: 'header-main' }, [\n          h('div', { class: 'header-title' }, 'Panorama Search'),\n          h('div', { class: 'header-stats' }, [\n            h('span', { class: 'stat-item' }, [\n              h('span', { class: 'stat-value' }, props.result.stats.nodes),\n              h('span', { class: 'stat-label' }, 'Nodes')\n            ]),\n            h('span', { class: 'stat-divider' }, '/'),\n            h('span', { class: 'stat-item' }, [\n              h('span', { class: 'stat-value' }, props.result.stats.edges),\n              h('span', { class: 'stat-label' }, 'Edges')\n            ]),\n            props.resultLength && h('span', { class: 'stat-divider' }, '·'),\n            props.resultLength && h('span', { class: 'stat-size' }, formatSize(props.resultLength))\n          ])\n        ]),\n        props.result.query && h('div', { class: 'header-topic' }, props.result.query)\n      ]),\n      \n      // Tab Navigation\n      h('div', { class: 'panorama-tabs' }, [\n        h('button', {\n          class: ['panorama-tab', { active: activeTab.value === 'active' }],\n          onClick: () => { activeTab.value = 'active' }\n        }, [\n          h('span', { class: 'tab-label' }, `当前有效记忆 (${props.result.activeFacts.length})`)\n        ]),\n        h('button', {\n          class: ['panorama-tab', { active: activeTab.value === 'historical' }],\n          onClick: () => { activeTab.value = 'historical' }\n        }, [\n          h('span', { class: 'tab-label' }, `历史记忆 (${props.result.historicalFacts.length})`)\n        ]),\n        h('button', {\n          class: ['panorama-tab', { active: activeTab.value === 'entities' }],\n          onClick: () => { activeTab.value = 'entities' }\n        }, [\n          h('span', { class: 'tab-label' }, `涉及实体 (${props.result.entities.length})`)\n        ])\n      ]),\n      \n      // Tab Content\n      h('div', { class: 'panorama-content' }, [\n        // Active Facts Tab\n        activeTab.value === 'active' && h('div', { class: 'facts-panel active-facts' }, [\n          h('div', { class: 'panel-header' }, [\n            h('span', { class: 'panel-title' }, '当前有效记忆'),\n            h('span', { class: 'panel-count' }, `共 ${props.result.activeFacts.length} 条`)\n          ]),\n          props.result.activeFacts.length > 0 ? h('div', { class: 'facts-list' },\n            (expandedActive.value ? props.result.activeFacts : props.result.activeFacts.slice(0, INITIAL_SHOW_COUNT)).map((fact, i) => \n              h('div', { class: 'fact-item active', key: i }, [\n                h('span', { class: 'fact-number' }, i + 1),\n                h('div', { class: 'fact-content' }, fact)\n              ])\n            )\n          ) : h('div', { class: 'empty-state' }, '暂无当前有效记忆'),\n          props.result.activeFacts.length > INITIAL_SHOW_COUNT && h('button', {\n            class: 'expand-btn',\n            onClick: () => { expandedActive.value = !expandedActive.value }\n          }, expandedActive.value ? `收起 ▲` : `展开全部 ${props.result.activeFacts.length} 条 ▼`)\n        ]),\n        \n        // Historical Facts Tab\n        activeTab.value === 'historical' && h('div', { class: 'facts-panel historical-facts' }, [\n          h('div', { class: 'panel-header' }, [\n            h('span', { class: 'panel-title' }, '历史记忆'),\n            h('span', { class: 'panel-count' }, `共 ${props.result.historicalFacts.length} 条`)\n          ]),\n          props.result.historicalFacts.length > 0 ? h('div', { class: 'facts-list' },\n            (expandedHistorical.value ? props.result.historicalFacts : props.result.historicalFacts.slice(0, INITIAL_SHOW_COUNT)).map((fact, i) => \n              h('div', { class: 'fact-item historical', key: i }, [\n                h('span', { class: 'fact-number' }, i + 1),\n                h('div', { class: 'fact-content' }, [\n                  // 尝试提取时间信息 [time - time]\n                  (() => {\n                    const timeMatch = fact.match(/^\\[(.+?)\\]\\s*(.*)$/)\n                    if (timeMatch) {\n                      return [\n                        h('span', { class: 'fact-time' }, timeMatch[1]),\n                        h('span', { class: 'fact-text' }, timeMatch[2])\n                      ]\n                    }\n                    return h('span', { class: 'fact-text' }, fact)\n                  })()\n                ])\n              ])\n            )\n          ) : h('div', { class: 'empty-state' }, '暂无历史记忆'),\n          props.result.historicalFacts.length > INITIAL_SHOW_COUNT && h('button', {\n            class: 'expand-btn',\n            onClick: () => { expandedHistorical.value = !expandedHistorical.value }\n          }, expandedHistorical.value ? `收起 ▲` : `展开全部 ${props.result.historicalFacts.length} 条 ▼`)\n        ]),\n        \n        // Entities Tab\n        activeTab.value === 'entities' && h('div', { class: 'entities-panel' }, [\n          h('div', { class: 'panel-header' }, [\n            h('span', { class: 'panel-title' }, '涉及实体'),\n            h('span', { class: 'panel-count' }, `共 ${props.result.entities.length} 个`)\n          ]),\n          props.result.entities.length > 0 ? h('div', { class: 'entities-grid' },\n            (expandedEntities.value ? props.result.entities : props.result.entities.slice(0, 8)).map((entity, i) => \n              h('div', { class: 'entity-tag', key: i }, [\n                h('span', { class: 'entity-name' }, entity.name),\n                entity.type && h('span', { class: 'entity-type' }, entity.type)\n              ])\n            )\n          ) : h('div', { class: 'empty-state' }, '暂无涉及实体'),\n          props.result.entities.length > 8 && h('button', {\n            class: 'expand-btn',\n            onClick: () => { expandedEntities.value = !expandedEntities.value }\n          }, expandedEntities.value ? `收起 ▲` : `展开全部 ${props.result.entities.length} 个 ▼`)\n        ])\n      ])\n    ])\n  }\n}\n\n// Interview Display Component - Conversation Style (Q&A Format)\nconst InterviewDisplay = {\n  props: ['result', 'resultLength'],\n  setup(props) {\n    // Format result size for display\n    const formatSize = (length) => {\n      if (!length) return ''\n      if (length >= 1000) {\n        return `${(length / 1000).toFixed(1)}k chars`\n      }\n      return `${length} chars`\n    }\n    \n    // Clean quote text - remove leading list numbers to avoid double numbering\n    const cleanQuoteText = (text) => {\n      if (!text) return ''\n      // Remove leading patterns like \"1. \", \"2. \", \"1、\", \"（1）\", \"(1)\" etc.\n      return text.replace(/^\\s*\\d+[\\.\\、\\)）]\\s*/, '').trim()\n    }\n    \n    const activeIndex = ref(0)\n    const expandedAnswers = ref(new Set())\n    // 为每个问题-回答对维护独立的平台选择状态\n    const platformTabs = reactive({}) // { 'agentIdx-qIdx': 'twitter' | 'reddit' }\n    \n    // 获取某个问题的当前平台选择\n    const getPlatformTab = (agentIdx, qIdx) => {\n      const key = `${agentIdx}-${qIdx}`\n      return platformTabs[key] || 'twitter'\n    }\n    \n    // 设置某个问题的平台选择\n    const setPlatformTab = (agentIdx, qIdx, platform) => {\n      const key = `${agentIdx}-${qIdx}`\n      platformTabs[key] = platform\n    }\n    \n    const toggleAnswer = (key) => {\n      const newSet = new Set(expandedAnswers.value)\n      if (newSet.has(key)) {\n        newSet.delete(key)\n      } else {\n        newSet.add(key)\n      }\n      expandedAnswers.value = newSet\n    }\n    \n    const formatAnswer = (text, expanded) => {\n      if (!text) return ''\n      if (expanded || text.length <= 400) return text\n      return text.substring(0, 400) + '...'\n    }\n    \n    // 检查是否为平台占位文本\n    const isPlaceholderText = (text) => {\n      if (!text) return true\n      const t = text.trim()\n      return t === '（该平台未获得回复）' || t === '(该平台未获得回复)' || t === '[无回复]'\n    }\n\n    // 尝试按问题编号分割回答\n    const splitAnswerByQuestions = (answerText, questionCount) => {\n      if (!answerText || questionCount <= 0) return [answerText]\n      if (isPlaceholderText(answerText)) return ['']\n\n      // 支持两种编号格式：\n      // 1. \"问题X：\" 或 \"问题X:\" （中文格式，后端新格式）\n      // 2. \"1. \" 或 \"\\n1. \" （数字+点，旧格式兼容）\n      let matches = []\n      let match\n\n      // 优先尝试 \"问题X：\" 格式\n      const cnPattern = /(?:^|[\\r\\n]+)问题(\\d+)[：:]\\s*/g\n      while ((match = cnPattern.exec(answerText)) !== null) {\n        matches.push({\n          num: parseInt(match[1]),\n          index: match.index,\n          fullMatch: match[0]\n        })\n      }\n\n      // 如果没匹配到，回退到 \"数字.\" 格式\n      if (matches.length === 0) {\n        const numPattern = /(?:^|[\\r\\n]+)(\\d+)\\.\\s+/g\n        while ((match = numPattern.exec(answerText)) !== null) {\n          matches.push({\n            num: parseInt(match[1]),\n            index: match.index,\n            fullMatch: match[0]\n          })\n        }\n      }\n\n      // 如果没有找到编号或只找到一个，返回整体\n      if (matches.length <= 1) {\n        const cleaned = answerText\n          .replace(/^问题\\d+[：:]\\s*/, '')\n          .replace(/^\\d+\\.\\s+/, '')\n          .trim()\n        return [cleaned || answerText]\n      }\n\n      // 按编号提取各部分\n      const parts = []\n      for (let i = 0; i < matches.length; i++) {\n        const current = matches[i]\n        const next = matches[i + 1]\n\n        const startIdx = current.index + current.fullMatch.length\n        const endIdx = next ? next.index : answerText.length\n\n        let part = answerText.substring(startIdx, endIdx).trim()\n        part = part.replace(/[\\r\\n]+$/, '').trim()\n        parts.push(part)\n      }\n\n      if (parts.length > 0 && parts.some(p => p)) {\n        return parts\n      }\n\n      return [answerText]\n    }\n    \n    // 获取某个问题对应的回答\n    const getAnswerForQuestion = (interview, qIdx, platform) => {\n      const answer = platform === 'twitter' ? interview.twitterAnswer : (interview.redditAnswer || interview.twitterAnswer)\n      if (!answer || isPlaceholderText(answer)) return answer || ''\n\n      const questionCount = interview.questions?.length || 1\n      const answers = splitAnswerByQuestions(answer, questionCount)\n\n      // 分割成功且索引有效\n      if (answers.length > 1 && qIdx < answers.length) {\n        return answers[qIdx] || ''\n      }\n\n      // 分割失败：第一个问题返回完整回答，其余返回空\n      return qIdx === 0 ? answer : ''\n    }\n    \n    // 检查某个问题是否有双平台回答（过滤占位文本）\n    const hasMultiplePlatforms = (interview, qIdx) => {\n      if (!interview.twitterAnswer || !interview.redditAnswer) return false\n      const twitterAnswer = getAnswerForQuestion(interview, qIdx, 'twitter')\n      const redditAnswer = getAnswerForQuestion(interview, qIdx, 'reddit')\n      // 两个平台都有真实回答（非占位文本）且内容不同\n      return !isPlaceholderText(twitterAnswer) && !isPlaceholderText(redditAnswer) && twitterAnswer !== redditAnswer\n    }\n    \n    return () => h('div', { class: 'interview-display' }, [\n      // Header Section\n      h('div', { class: 'interview-header' }, [\n        h('div', { class: 'header-main' }, [\n          h('div', { class: 'header-title' }, 'Agent Interview'),\n          h('div', { class: 'header-stats' }, [\n            h('span', { class: 'stat-item' }, [\n              h('span', { class: 'stat-value' }, props.result.successCount || props.result.interviews.length),\n              h('span', { class: 'stat-label' }, 'Interviewed')\n            ]),\n            props.result.totalCount > 0 && h('span', { class: 'stat-divider' }, '/'),\n            props.result.totalCount > 0 && h('span', { class: 'stat-item' }, [\n              h('span', { class: 'stat-value' }, props.result.totalCount),\n              h('span', { class: 'stat-label' }, 'Total')\n            ]),\n            props.resultLength && h('span', { class: 'stat-divider' }, '·'),\n            props.resultLength && h('span', { class: 'stat-size' }, formatSize(props.resultLength))\n          ])\n        ]),\n        props.result.topic && h('div', { class: 'header-topic' }, props.result.topic)\n      ]),\n      \n      // Agent Selector Tabs\n      props.result.interviews.length > 0 && h('div', { class: 'agent-tabs' }, \n        props.result.interviews.map((interview, i) => h('button', {\n          class: ['agent-tab', { active: activeIndex.value === i }],\n          key: i,\n          onClick: () => { activeIndex.value = i }\n        }, [\n          h('span', { class: 'tab-avatar' }, interview.name ? interview.name.charAt(0) : (i + 1)),\n          h('span', { class: 'tab-name' }, interview.title || interview.name || `Agent ${i + 1}`)\n        ]))\n      ),\n      \n      // Active Interview Detail\n      props.result.interviews.length > 0 && h('div', { class: 'interview-detail' }, [\n        // Agent Profile Card\n        h('div', { class: 'agent-profile' }, [\n          h('div', { class: 'profile-avatar' }, props.result.interviews[activeIndex.value]?.name?.charAt(0) || 'A'),\n          h('div', { class: 'profile-info' }, [\n            h('div', { class: 'profile-name' }, props.result.interviews[activeIndex.value]?.name || 'Agent'),\n            h('div', { class: 'profile-role' }, props.result.interviews[activeIndex.value]?.role || ''),\n            props.result.interviews[activeIndex.value]?.bio && h('div', { class: 'profile-bio' }, props.result.interviews[activeIndex.value].bio)\n          ])\n        ]),\n        \n        // Selection Reason - 选择理由\n        props.result.interviews[activeIndex.value]?.selectionReason && h('div', { class: 'selection-reason' }, [\n          h('div', { class: 'reason-label' }, '选择理由'),\n          h('div', { class: 'reason-content' }, props.result.interviews[activeIndex.value].selectionReason)\n        ]),\n        \n        // Q&A Conversation Thread - 一问一答样式\n        h('div', { class: 'qa-thread' }, \n          (props.result.interviews[activeIndex.value]?.questions?.length > 0 \n            ? props.result.interviews[activeIndex.value].questions \n            : [props.result.interviews[activeIndex.value]?.question || 'No question available']\n          ).map((question, qIdx) => {\n            const interview = props.result.interviews[activeIndex.value]\n            const currentPlatform = getPlatformTab(activeIndex.value, qIdx)\n            const answerText = getAnswerForQuestion(interview, qIdx, currentPlatform)\n            const hasDualPlatform = hasMultiplePlatforms(interview, qIdx)\n            const expandKey = `${activeIndex.value}-${qIdx}`\n            const isExpanded = expandedAnswers.value.has(expandKey)\n            const isPlaceholder = isPlaceholderText(answerText)\n\n            return h('div', { class: 'qa-pair', key: qIdx }, [\n              // Question Block\n              h('div', { class: 'qa-question' }, [\n                h('div', { class: 'qa-badge q-badge' }, `Q${qIdx + 1}`),\n                h('div', { class: 'qa-content' }, [\n                  h('div', { class: 'qa-sender' }, 'Interviewer'),\n                  h('div', { class: 'qa-text' }, question)\n                ])\n              ]),\n\n              // Answer Block\n              answerText && h('div', { class: ['qa-answer', { 'answer-placeholder': isPlaceholder }] }, [\n                h('div', { class: 'qa-badge a-badge' }, `A${qIdx + 1}`),\n                h('div', { class: 'qa-content' }, [\n                  h('div', { class: 'qa-answer-header' }, [\n                    h('div', { class: 'qa-sender' }, interview?.name || 'Agent'),\n                    // 双平台切换按钮（仅在有真实双平台回答时显示）\n                    hasDualPlatform && h('div', { class: 'platform-switch' }, [\n                      h('button', {\n                        class: ['platform-btn', { active: currentPlatform === 'twitter' }],\n                        onClick: (e) => { e.stopPropagation(); setPlatformTab(activeIndex.value, qIdx, 'twitter') }\n                      }, [\n                        h('svg', { class: 'platform-icon', viewBox: '0 0 24 24', width: 12, height: 12, fill: 'none', stroke: 'currentColor', 'stroke-width': 2 }, [\n                          h('circle', { cx: '12', cy: '12', r: '10' }),\n                          h('line', { x1: '2', y1: '12', x2: '22', y2: '12' }),\n                          h('path', { d: 'M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z' })\n                        ]),\n                        h('span', {}, '世界1')\n                      ]),\n                      h('button', {\n                        class: ['platform-btn', { active: currentPlatform === 'reddit' }],\n                        onClick: (e) => { e.stopPropagation(); setPlatformTab(activeIndex.value, qIdx, 'reddit') }\n                      }, [\n                        h('svg', { class: 'platform-icon', viewBox: '0 0 24 24', width: 12, height: 12, fill: 'none', stroke: 'currentColor', 'stroke-width': 2 }, [\n                          h('path', { d: 'M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z' })\n                        ]),\n                        h('span', {}, '世界2')\n                      ])\n                    ])\n                  ]),\n                  h('div', {\n                    class: ['qa-text', 'answer-text', { 'placeholder-text': isPlaceholder }],\n                    innerHTML: isPlaceholder\n                      ? answerText\n                      : formatAnswer(answerText, isExpanded)\n                          .replace(/\\*\\*(.+?)\\*\\*/g, '<strong>$1</strong>')\n                          .replace(/\\n/g, '<br>')\n                  }),\n                  // Expand/Collapse Button（占位文本不显示）\n                  !isPlaceholder && answerText.length > 400 && h('button', {\n                    class: 'expand-answer-btn',\n                    onClick: () => toggleAnswer(expandKey)\n                  }, isExpanded ? 'Show Less' : 'Show More')\n                ])\n              ])\n            ])\n          })\n        ),\n        \n        // Key Quotes Section\n        props.result.interviews[activeIndex.value]?.quotes?.length > 0 && h('div', { class: 'quotes-section' }, [\n          h('div', { class: 'quotes-header' }, 'Key Quotes'),\n          h('div', { class: 'quotes-list' },\n            props.result.interviews[activeIndex.value].quotes.slice(0, 3).map((quote, qi) => {\n              const cleanedQuote = cleanQuoteText(quote)\n              const displayQuote = cleanedQuote.length > 200 ? cleanedQuote.substring(0, 200) + '...' : cleanedQuote\n              return h('blockquote', { \n                key: qi, \n                class: 'quote-item',\n                innerHTML: renderMarkdown(displayQuote)\n              })\n            })\n          )\n        ])\n      ]),\n\n      // Summary Section (Collapsible)\n      props.result.summary && h('div', { class: 'summary-section' }, [\n        h('div', { class: 'summary-header' }, 'Interview Summary'),\n        h('div', { \n          class: 'summary-content',\n          innerHTML: renderMarkdown(props.result.summary.length > 500 ? props.result.summary.substring(0, 500) + '...' : props.result.summary)\n        })\n      ])\n    ])\n  }\n}\n\n// Quick Search Display Component - Enhanced with full data rendering\nconst QuickSearchDisplay = {\n  props: ['result', 'resultLength'],\n  setup(props) {\n    const activeTab = ref('facts') // 'facts', 'edges', 'nodes'\n    const expandedFacts = ref(false)\n    const INITIAL_SHOW_COUNT = 5\n    \n    // Check if there are edges or nodes to show tabs\n    const hasEdges = computed(() => props.result.edges && props.result.edges.length > 0)\n    const hasNodes = computed(() => props.result.nodes && props.result.nodes.length > 0)\n    const showTabs = computed(() => hasEdges.value || hasNodes.value)\n    \n    // Format result size for display\n    const formatSize = (length) => {\n      if (!length) return ''\n      if (length >= 1000) {\n        return `${(length / 1000).toFixed(1)}k chars`\n      }\n      return `${length} chars`\n    }\n    \n    return () => h('div', { class: 'quick-search-display' }, [\n      // Header Section\n      h('div', { class: 'quicksearch-header' }, [\n        h('div', { class: 'header-main' }, [\n          h('div', { class: 'header-title' }, 'Quick Search'),\n          h('div', { class: 'header-stats' }, [\n            h('span', { class: 'stat-item' }, [\n              h('span', { class: 'stat-value' }, props.result.count || props.result.facts.length),\n              h('span', { class: 'stat-label' }, 'Results')\n            ]),\n            props.resultLength && h('span', { class: 'stat-divider' }, '·'),\n            props.resultLength && h('span', { class: 'stat-size' }, formatSize(props.resultLength))\n          ])\n        ]),\n        props.result.query && h('div', { class: 'header-query' }, [\n          h('span', { class: 'query-label' }, '搜索: '),\n          h('span', { class: 'query-text' }, props.result.query)\n        ])\n      ]),\n      \n      // Tab Navigation (only show if there are edges or nodes)\n      showTabs.value && h('div', { class: 'quicksearch-tabs' }, [\n        h('button', {\n          class: ['quicksearch-tab', { active: activeTab.value === 'facts' }],\n          onClick: () => { activeTab.value = 'facts' }\n        }, [\n          h('span', { class: 'tab-label' }, `事实 (${props.result.facts.length})`)\n        ]),\n        hasEdges.value && h('button', {\n          class: ['quicksearch-tab', { active: activeTab.value === 'edges' }],\n          onClick: () => { activeTab.value = 'edges' }\n        }, [\n          h('span', { class: 'tab-label' }, `关系 (${props.result.edges.length})`)\n        ]),\n        hasNodes.value && h('button', {\n          class: ['quicksearch-tab', { active: activeTab.value === 'nodes' }],\n          onClick: () => { activeTab.value = 'nodes' }\n        }, [\n          h('span', { class: 'tab-label' }, `节点 (${props.result.nodes.length})`)\n        ])\n      ]),\n      \n      // Content Area\n      h('div', { class: ['quicksearch-content', { 'no-tabs': !showTabs.value }] }, [\n        // Facts (always show if no tabs, or when facts tab is active)\n        ((!showTabs.value) || activeTab.value === 'facts') && h('div', { class: 'facts-panel' }, [\n          !showTabs.value && h('div', { class: 'panel-header' }, [\n            h('span', { class: 'panel-title' }, '搜索结果'),\n            h('span', { class: 'panel-count' }, `共 ${props.result.facts.length} 条`)\n          ]),\n          props.result.facts.length > 0 ? h('div', { class: 'facts-list' },\n            (expandedFacts.value ? props.result.facts : props.result.facts.slice(0, INITIAL_SHOW_COUNT)).map((fact, i) => \n              h('div', { class: 'fact-item', key: i }, [\n                h('span', { class: 'fact-number' }, i + 1),\n                h('div', { class: 'fact-content' }, fact)\n              ])\n            )\n          ) : h('div', { class: 'empty-state' }, '未找到相关结果'),\n          props.result.facts.length > INITIAL_SHOW_COUNT && h('button', {\n            class: 'expand-btn',\n            onClick: () => { expandedFacts.value = !expandedFacts.value }\n          }, expandedFacts.value ? `收起 ▲` : `展开全部 ${props.result.facts.length} 条 ▼`)\n        ]),\n        \n        // Edges Tab\n        activeTab.value === 'edges' && hasEdges.value && h('div', { class: 'edges-panel' }, [\n          h('div', { class: 'panel-header' }, [\n            h('span', { class: 'panel-title' }, '相关关系'),\n            h('span', { class: 'panel-count' }, `共 ${props.result.edges.length} 条`)\n          ]),\n          h('div', { class: 'edges-list' },\n            props.result.edges.map((edge, i) => \n              h('div', { class: 'edge-item', key: i }, [\n                h('span', { class: 'edge-source' }, edge.source),\n                h('span', { class: 'edge-arrow' }, [\n                  h('span', { class: 'edge-line' }),\n                  h('span', { class: 'edge-label' }, edge.relation),\n                  h('span', { class: 'edge-line' })\n                ]),\n                h('span', { class: 'edge-target' }, edge.target)\n              ])\n            )\n          )\n        ]),\n        \n        // Nodes Tab\n        activeTab.value === 'nodes' && hasNodes.value && h('div', { class: 'nodes-panel' }, [\n          h('div', { class: 'panel-header' }, [\n            h('span', { class: 'panel-title' }, '相关节点'),\n            h('span', { class: 'panel-count' }, `共 ${props.result.nodes.length} 个`)\n          ]),\n          h('div', { class: 'nodes-grid' },\n            props.result.nodes.map((node, i) => \n              h('div', { class: 'node-tag', key: i }, [\n                h('span', { class: 'node-name' }, node.name),\n                node.type && h('span', { class: 'node-type' }, node.type)\n              ])\n            )\n          )\n        ])\n      ])\n    ])\n  }\n}\n\n// Computed\nconst statusClass = computed(() => {\n  if (isComplete.value) return 'completed'\n  if (agentLogs.value.length > 0) return 'processing'\n  return 'pending'\n})\n\nconst statusText = computed(() => {\n  if (isComplete.value) return 'Completed'\n  if (agentLogs.value.length > 0) return 'Generating...'\n  return 'Waiting'\n})\n\nconst totalSections = computed(() => {\n  return reportOutline.value?.sections?.length || 0\n})\n\nconst completedSections = computed(() => {\n  return Object.keys(generatedSections.value).length\n})\n\nconst progressPercent = computed(() => {\n  if (totalSections.value === 0) return 0\n  return Math.round((completedSections.value / totalSections.value) * 100)\n})\n\nconst totalToolCalls = computed(() => {\n  return agentLogs.value.filter(l => l.action === 'tool_call').length\n})\n\nconst formatElapsedTime = computed(() => {\n  if (!startTime.value) return '0s'\n  const lastLog = agentLogs.value[agentLogs.value.length - 1]\n  const elapsed = lastLog?.elapsed_seconds || 0\n  if (elapsed < 60) return `${Math.round(elapsed)}s`\n  const mins = Math.floor(elapsed / 60)\n  const secs = Math.round(elapsed % 60)\n  return `${mins}m ${secs}s`\n})\n\nconst displayLogs = computed(() => {\n  return agentLogs.value\n})\n\n// Workflow steps overview (status-based, no nested cards)\nconst activeSectionIndex = computed(() => {\n  if (isComplete.value) return null\n  if (currentSectionIndex.value) return currentSectionIndex.value\n  if (totalSections.value > 0 && completedSections.value < totalSections.value) return completedSections.value + 1\n  return null\n})\n\nconst isPlanningDone = computed(() => {\n  return !!reportOutline.value?.sections?.length || agentLogs.value.some(l => l.action === 'planning_complete')\n})\n\nconst isPlanningStarted = computed(() => {\n  return agentLogs.value.some(l => l.action === 'planning_start' || l.action === 'report_start')\n})\n\nconst isFinalizing = computed(() => {\n  return !isComplete.value && isPlanningDone.value && totalSections.value > 0 && completedSections.value >= totalSections.value\n})\n\n// 当前活跃的步骤（用于顶部显示）\nconst activeStep = computed(() => {\n  const steps = workflowSteps.value\n  // 找到当前 active 的步骤\n  const active = steps.find(s => s.status === 'active')\n  if (active) return active\n  \n  // 如果没有 active，返回最后一个 done 的步骤\n  const doneSteps = steps.filter(s => s.status === 'done')\n  if (doneSteps.length > 0) return doneSteps[doneSteps.length - 1]\n  \n  // 否则返回第一个步骤\n  return steps[0] || { noLabel: '--', title: '等待开始', status: 'todo', meta: '' }\n})\n\nconst workflowSteps = computed(() => {\n  const steps = []\n\n  // Planning / Outline\n  const planningStatus = isPlanningDone.value ? 'done' : (isPlanningStarted.value ? 'active' : 'todo')\n  steps.push({\n    key: 'planning',\n    noLabel: 'PL',\n    title: 'Planning / Outline',\n    status: planningStatus,\n    meta: planningStatus === 'active' ? 'IN PROGRESS' : ''\n  })\n\n  // Sections (if outline exists)\n  const sections = reportOutline.value?.sections || []\n  sections.forEach((section, i) => {\n    const idx = i + 1\n    const status = (isComplete.value || !!generatedSections.value[idx])\n      ? 'done'\n      : (activeSectionIndex.value === idx ? 'active' : 'todo')\n\n    steps.push({\n      key: `section-${idx}`,\n      noLabel: String(idx).padStart(2, '0'),\n      title: section.title,\n      status,\n      meta: status === 'active' ? 'IN PROGRESS' : ''\n    })\n  })\n\n  // Complete\n  const completeStatus = isComplete.value ? 'done' : (isFinalizing.value ? 'active' : 'todo')\n  steps.push({\n    key: 'complete',\n    noLabel: 'OK',\n    title: 'Complete',\n    status: completeStatus,\n    meta: completeStatus === 'active' ? 'FINALIZING' : ''\n  })\n\n  return steps\n})\n\n// Methods\nconst addLog = (msg) => {\n  emit('add-log', msg)\n}\n\nconst isSectionCompleted = (sectionIndex) => {\n  return !!generatedSections.value[sectionIndex]\n}\n\nconst formatTime = (timestamp) => {\n  if (!timestamp) return ''\n  try {\n    return new Date(timestamp).toLocaleTimeString('en-US', { \n      hour12: false, \n      hour: '2-digit', \n      minute: '2-digit', \n      second: '2-digit' \n    })\n  } catch {\n    return ''\n  }\n}\n\nconst formatParams = (params) => {\n  if (!params) return ''\n  try {\n    return JSON.stringify(params, null, 2)\n  } catch {\n    return String(params)\n  }\n}\n\nconst formatResultSize = (length) => {\n  if (!length) return ''\n  if (length < 1000) return `${length} chars`\n  return `${(length / 1000).toFixed(1)}k chars`\n}\n\nconst truncateText = (text, maxLen) => {\n  if (!text) return ''\n  if (text.length <= maxLen) return text\n  return text.substring(0, maxLen) + '...'\n}\n\nconst renderMarkdown = (content) => {\n  if (!content) return ''\n  \n  // 去掉开头的二级标题（## xxx），因为章节标题已在外层显示\n  let processedContent = content.replace(/^##\\s+.+\\n+/, '')\n  \n  // 处理代码块\n  let html = processedContent.replace(/```(\\w*)\\n([\\s\\S]*?)```/g, '<pre class=\"code-block\"><code>$2</code></pre>')\n  \n  // 处理行内代码\n  html = html.replace(/`([^`]+)`/g, '<code class=\"inline-code\">$1</code>')\n  \n  // 处理标题\n  html = html.replace(/^#### (.+)$/gm, '<h5 class=\"md-h5\">$1</h5>')\n  html = html.replace(/^### (.+)$/gm, '<h4 class=\"md-h4\">$1</h4>')\n  html = html.replace(/^## (.+)$/gm, '<h3 class=\"md-h3\">$1</h3>')\n  html = html.replace(/^# (.+)$/gm, '<h2 class=\"md-h2\">$1</h2>')\n  \n  // 处理引用块\n  html = html.replace(/^> (.+)$/gm, '<blockquote class=\"md-quote\">$1</blockquote>')\n  \n  // 处理列表 - 支持子列表\n  html = html.replace(/^(\\s*)- (.+)$/gm, (match, indent, text) => {\n    const level = Math.floor(indent.length / 2)\n    return `<li class=\"md-li\" data-level=\"${level}\">${text}</li>`\n  })\n  html = html.replace(/^(\\s*)(\\d+)\\. (.+)$/gm, (match, indent, num, text) => {\n    const level = Math.floor(indent.length / 2)\n    return `<li class=\"md-oli\" data-level=\"${level}\">${text}</li>`\n  })\n\n  // 包装无序列表\n  html = html.replace(/(<li class=\"md-li\"[^>]*>.*?<\\/li>\\s*)+/g, '<ul class=\"md-ul\">$&</ul>')\n  // 包装有序列表\n  html = html.replace(/(<li class=\"md-oli\"[^>]*>.*?<\\/li>\\s*)+/g, '<ol class=\"md-ol\">$&</ol>')\n\n  // 清理列表项之间的所有空白\n  html = html.replace(/<\\/li>\\s+<li/g, '</li><li')\n  // 清理列表开始标签后的空白\n  html = html.replace(/<ul class=\"md-ul\">\\s+/g, '<ul class=\"md-ul\">')\n  html = html.replace(/<ol class=\"md-ol\">\\s+/g, '<ol class=\"md-ol\">')\n  // 清理列表结束标签前的空白\n  html = html.replace(/\\s+<\\/ul>/g, '</ul>')\n  html = html.replace(/\\s+<\\/ol>/g, '</ol>')\n  \n  // 处理粗体和斜体\n  html = html.replace(/\\*\\*(.+?)\\*\\*/g, '<strong>$1</strong>')\n  html = html.replace(/\\*(.+?)\\*/g, '<em>$1</em>')\n  html = html.replace(/_(.+?)_/g, '<em>$1</em>')\n  \n  // 处理分隔线\n  html = html.replace(/^---$/gm, '<hr class=\"md-hr\">')\n  \n  // 处理换行 - 空行变成段落分隔，单换行变成 <br>\n  html = html.replace(/\\n\\n/g, '</p><p class=\"md-p\">')\n  html = html.replace(/\\n/g, '<br>')\n  \n  // 包装在段落中\n  html = '<p class=\"md-p\">' + html + '</p>'\n  \n  // 清理空段落\n  html = html.replace(/<p class=\"md-p\"><\\/p>/g, '')\n  html = html.replace(/<p class=\"md-p\">(<h[2-5])/g, '$1')\n  html = html.replace(/(<\\/h[2-5]>)<\\/p>/g, '$1')\n  html = html.replace(/<p class=\"md-p\">(<ul|<ol|<blockquote|<pre|<hr)/g, '$1')\n  html = html.replace(/(<\\/ul>|<\\/ol>|<\\/blockquote>|<\\/pre>)<\\/p>/g, '$1')\n  // 清理块级元素前后的 <br> 标签\n  html = html.replace(/<br>\\s*(<ul|<ol|<blockquote)/g, '$1')\n  html = html.replace(/(<\\/ul>|<\\/ol>|<\\/blockquote>)\\s*<br>/g, '$1')\n  // 清理 <p><br> 紧跟块级元素的情况（多余空行导致）\n  html = html.replace(/<p class=\"md-p\">(<br>\\s*)+(<ul|<ol|<blockquote|<pre|<hr)/g, '$2')\n  // 清理连续的 <br> 标签\n  html = html.replace(/(<br>\\s*){2,}/g, '<br>')\n  // 清理块级元素后紧跟的段落开始标签前的 <br>\n  html = html.replace(/(<\\/ol>|<\\/ul>|<\\/blockquote>)<br>(<p|<div)/g, '$1$2')\n\n  // 修复非连续有序列表的编号：当单项 <ol> 被段落内容隔开时，保持编号递增\n  const tokens = html.split(/(<ol class=\"md-ol\">(?:<li class=\"md-oli\"[^>]*>[\\s\\S]*?<\\/li>)+<\\/ol>)/g)\n  let olCounter = 0\n  let inSequence = false\n  for (let i = 0; i < tokens.length; i++) {\n    if (tokens[i].startsWith('<ol class=\"md-ol\">')) {\n      const liCount = (tokens[i].match(/<li class=\"md-oli\"/g) || []).length\n      if (liCount === 1) {\n        olCounter++\n        if (olCounter > 1) {\n          tokens[i] = tokens[i].replace('<ol class=\"md-ol\">', `<ol class=\"md-ol\" start=\"${olCounter}\">`)\n        }\n        inSequence = true\n      } else {\n        olCounter = 0\n        inSequence = false\n      }\n    } else if (inSequence) {\n      if (/<h[2-5]/.test(tokens[i])) {\n        olCounter = 0\n        inSequence = false\n      }\n    }\n  }\n  html = tokens.join('')\n\n  return html\n}\n\nconst getTimelineItemClass = (log, idx, total) => {\n  const isLatest = idx === total - 1 && !isComplete.value\n  const isMilestone = log.action === 'section_complete' || log.action === 'report_complete'\n  return {\n    'node--active': isLatest,\n    'node--done': !isLatest && isMilestone,\n    'node--muted': !isLatest && !isMilestone,\n    'node--tool': log.action === 'tool_call' || log.action === 'tool_result'\n  }\n}\n\nconst getConnectorClass = (log, idx, total) => {\n  const isLatest = idx === total - 1 && !isComplete.value\n  if (isLatest) return 'dot-active'\n  if (log.action === 'section_complete' || log.action === 'report_complete') return 'dot-done'\n  return 'dot-muted'\n}\n\nconst getActionLabel = (action) => {\n  const labels = {\n    'report_start': 'Report Started',\n    'planning_start': 'Planning',\n    'planning_complete': 'Plan Complete',\n    'section_start': 'Section Start',\n    'section_content': 'Content Ready',\n    'section_complete': 'Section Done',\n    'tool_call': 'Tool Call',\n    'tool_result': 'Tool Result',\n    'llm_response': 'LLM Response',\n    'report_complete': 'Complete'\n  }\n  return labels[action] || action\n}\n\nconst getLogLevelClass = (log) => {\n  if (log.includes('ERROR') || log.includes('错误')) return 'error'\n  if (log.includes('WARNING') || log.includes('警告')) return 'warning'\n  // INFO 使用默认颜色，不标记为 success\n  return ''\n}\n\n// Polling\nlet agentLogTimer = null\nlet consoleLogTimer = null\n\nconst fetchAgentLog = async () => {\n  if (!props.reportId) return\n  \n  try {\n    const res = await getAgentLog(props.reportId, agentLogLine.value)\n    \n    if (res.success && res.data) {\n      const newLogs = res.data.logs || []\n      \n      if (newLogs.length > 0) {\n        newLogs.forEach(log => {\n          agentLogs.value.push(log)\n          \n          if (log.action === 'planning_complete' && log.details?.outline) {\n            reportOutline.value = log.details.outline\n          }\n          \n          if (log.action === 'section_start') {\n            currentSectionIndex.value = log.section_index\n          }\n\n          // section_complete - 章节生成完成\n          if (log.action === 'section_complete') {\n            if (log.details?.content) {\n              generatedSections.value[log.section_index] = log.details.content\n              // 自动展开刚生成的章节\n              expandedContent.value.add(log.section_index - 1)\n              currentSectionIndex.value = null\n            }\n          }\n          \n          if (log.action === 'report_complete') {\n            isComplete.value = true\n            currentSectionIndex.value = null  // 确保清除 loading 状态\n            emit('update-status', 'completed')\n            stopPolling()\n            // 滚动逻辑统一在循环结束后的 nextTick 中处理\n          }\n          \n          if (log.action === 'report_start') {\n            startTime.value = new Date(log.timestamp)\n          }\n        })\n        \n        agentLogLine.value = res.data.from_line + newLogs.length\n        \n        nextTick(() => {\n          if (rightPanel.value) {\n            // 如果任务已完成，滚动到顶部；否则滚动到底部跟随最新日志\n            if (isComplete.value) {\n              rightPanel.value.scrollTop = 0\n            } else {\n              rightPanel.value.scrollTop = rightPanel.value.scrollHeight\n            }\n          }\n        })\n      }\n    }\n  } catch (err) {\n    console.warn('Failed to fetch agent log:', err)\n  }\n}\n\n// 提取最终答案内容 - 从 LLM response 中提取章节内容\nconst extractFinalContent = (response) => {\n  if (!response) return null\n  \n  // 尝试提取 <final_answer> 标签内的内容\n  const finalAnswerTagMatch = response.match(/<final_answer>([\\s\\S]*?)<\\/final_answer>/)\n  if (finalAnswerTagMatch) {\n    return finalAnswerTagMatch[1].trim()\n  }\n  \n  // 尝试找 Final Answer: 后面的内容（支持多种格式）\n  // 格式1: Final Answer:\\n\\n内容\n  // 格式2: Final Answer: 内容\n  const finalAnswerMatch = response.match(/Final\\s*Answer:\\s*\\n*([\\s\\S]*)$/i)\n  if (finalAnswerMatch) {\n    return finalAnswerMatch[1].trim()\n  }\n  \n  // 尝试找 最终答案: 后面的内容\n  const chineseFinalMatch = response.match(/最终答案[:：]\\s*\\n*([\\s\\S]*)$/i)\n  if (chineseFinalMatch) {\n    return chineseFinalMatch[1].trim()\n  }\n  \n  // 如果以 ## 或 # 或 > 开头，可能是直接的 markdown 内容\n  const trimmedResponse = response.trim()\n  if (trimmedResponse.match(/^[#>]/)) {\n    return trimmedResponse\n  }\n  \n  // 如果内容较长且包含markdown格式，尝试移除思考过程后返回\n  if (response.length > 300 && (response.includes('**') || response.includes('>'))) {\n    // 移除 Thought: 开头的思考过程\n    const thoughtMatch = response.match(/^Thought:[\\s\\S]*?(?=\\n\\n[^T]|\\n\\n$)/i)\n    if (thoughtMatch) {\n      const afterThought = response.substring(thoughtMatch[0].length).trim()\n      if (afterThought.length > 100) {\n        return afterThought\n      }\n    }\n  }\n  \n  return null\n}\n\nconst fetchConsoleLog = async () => {\n  if (!props.reportId) return\n  \n  try {\n    const res = await getConsoleLog(props.reportId, consoleLogLine.value)\n    \n    if (res.success && res.data) {\n      const newLogs = res.data.logs || []\n      \n      if (newLogs.length > 0) {\n        consoleLogs.value.push(...newLogs)\n        consoleLogLine.value = res.data.from_line + newLogs.length\n        \n        nextTick(() => {\n          if (logContent.value) {\n            logContent.value.scrollTop = logContent.value.scrollHeight\n          }\n        })\n      }\n    }\n  } catch (err) {\n    console.warn('Failed to fetch console log:', err)\n  }\n}\n\nconst startPolling = () => {\n  if (agentLogTimer || consoleLogTimer) return\n  \n  fetchAgentLog()\n  fetchConsoleLog()\n  \n  agentLogTimer = setInterval(fetchAgentLog, 2000)\n  consoleLogTimer = setInterval(fetchConsoleLog, 1500)\n}\n\nconst stopPolling = () => {\n  if (agentLogTimer) {\n    clearInterval(agentLogTimer)\n    agentLogTimer = null\n  }\n  if (consoleLogTimer) {\n    clearInterval(consoleLogTimer)\n    consoleLogTimer = null\n  }\n}\n\n// Lifecycle\nonMounted(() => {\n  if (props.reportId) {\n    addLog(`Report Agent initialized: ${props.reportId}`)\n    startPolling()\n  }\n})\n\nonUnmounted(() => {\n  stopPolling()\n})\n\nwatch(() => props.reportId, (newId) => {\n  if (newId) {\n    agentLogs.value = []\n    consoleLogs.value = []\n    agentLogLine.value = 0\n    consoleLogLine.value = 0\n    reportOutline.value = null\n    currentSectionIndex.value = null\n    generatedSections.value = {}\n    expandedContent.value = new Set()\n    expandedLogs.value = new Set()\n    collapsedSections.value = new Set()\n    isComplete.value = false\n    startTime.value = null\n    \n    startPolling()\n  }\n}, { immediate: true })\n</script>\n\n<style scoped>\n.report-panel {\n  height: 100%;\n  display: flex;\n  flex-direction: column;\n  background: #F8F9FA;\n  font-family: 'Inter', 'Noto Sans SC', system-ui, sans-serif;\n  overflow: hidden;\n}\n\n/* Main Split Layout */\n.main-split-layout {\n  flex: 1;\n  display: flex;\n  overflow: hidden;\n}\n\n/* Panel Headers */\n.panel-header {\n  display: flex;\n  align-items: center;\n  gap: 10px;\n  padding: 14px 20px;\n  background: #FFFFFF;\n  border-bottom: 1px solid #E5E7EB;\n  font-size: 13px;\n  font-weight: 600;\n  color: #374151;\n  text-transform: uppercase;\n  letter-spacing: 0.04em;\n  position: sticky;\n  top: 0;\n  z-index: 10;\n}\n\n.header-dot {\n  width: 8px;\n  height: 8px;\n  border-radius: 50%;\n  background: #1F2937;\n  box-shadow: 0 0 0 3px rgba(31, 41, 55, 0.15);\n  margin-right: 10px;\n  flex-shrink: 0;\n  animation: pulse-dot 1.5s ease-in-out infinite;\n}\n\n@keyframes pulse-dot {\n  0%, 100% {\n    box-shadow: 0 0 0 3px rgba(31, 41, 55, 0.15);\n  }\n  50% {\n    box-shadow: 0 0 0 5px rgba(31, 41, 55, 0.1);\n  }\n}\n\n.header-index {\n  font-size: 12px;\n  font-weight: 600;\n  color: #9CA3AF;\n  margin-right: 10px;\n  flex-shrink: 0;\n}\n\n.header-title {\n  font-size: 13px;\n  font-weight: 600;\n  color: #374151;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n  text-transform: none;\n  letter-spacing: 0;\n}\n\n.header-meta {\n  margin-left: auto;\n  font-size: 10px;\n  font-weight: 600;\n  color: #6B7280;\n  flex-shrink: 0;\n}\n\n/* Panel header status variants */\n.panel-header--active {\n  background: #FAFAFA;\n  border-color: #1F2937;\n}\n\n.panel-header--active .header-index {\n  color: #1F2937;\n}\n\n.panel-header--active .header-title {\n  color: #1F2937;\n}\n\n.panel-header--active .header-meta {\n  color: #1F2937;\n}\n\n.panel-header--done {\n  background: #F9FAFB;\n}\n\n.panel-header--done .header-index {\n  color: #10B981;\n}\n\n.panel-header--todo .header-index,\n.panel-header--todo .header-title {\n  color: #9CA3AF;\n}\n\n/* Left Panel - Report Style */\n.left-panel.report-style {\n  width: 45%;\n  min-width: 450px;\n  background: #FFFFFF;\n  border-right: 1px solid #E5E7EB;\n  overflow-y: auto;\n  display: flex;\n  flex-direction: column;\n  padding: 30px 50px 60px 50px;\n}\n\n.left-panel::-webkit-scrollbar {\n  width: 6px;\n}\n\n.left-panel::-webkit-scrollbar-track {\n  background: transparent;\n}\n\n.left-panel::-webkit-scrollbar-thumb {\n  background: transparent;\n  border-radius: 3px;\n  transition: background 0.3s ease;\n}\n\n.left-panel:hover::-webkit-scrollbar-thumb {\n  background: rgba(0, 0, 0, 0.15);\n}\n\n.left-panel::-webkit-scrollbar-thumb:hover {\n  background: rgba(0, 0, 0, 0.25);\n}\n\n/* Report Header */\n.report-content-wrapper {\n  max-width: 800px;\n  margin: 0 auto;\n  width: 100%;\n}\n\n.report-header-block {\n  margin-bottom: 30px;\n}\n\n.report-meta {\n  display: flex;\n  align-items: center;\n  gap: 12px;\n  margin-bottom: 24px;\n}\n\n.report-tag {\n  background: #000000;\n  color: #FFFFFF;\n  font-size: 11px;\n  font-weight: 700;\n  padding: 4px 8px;\n  letter-spacing: 0.05em;\n  text-transform: uppercase;\n}\n\n.report-id {\n  font-size: 11px;\n  color: #9CA3AF;\n  font-weight: 500;\n  letter-spacing: 0.02em;\n}\n\n.main-title {\n  font-family: 'Times New Roman', Times, serif;\n  font-size: 36px;\n  font-weight: 700;\n  color: #111827;\n  line-height: 1.2;\n  margin: 0 0 16px 0;\n  letter-spacing: -0.02em;\n}\n\n.sub-title {\n  font-family: 'Times New Roman', Times, serif;\n  font-size: 16px;\n  color: #6B7280;\n  font-style: italic;\n  line-height: 1.6;\n  margin: 0 0 30px 0;\n  font-weight: 400;\n}\n\n.header-divider {\n  height: 1px;\n  background: #E5E7EB;\n  width: 100%;\n}\n\n/* Sections List */\n.sections-list {\n  display: flex;\n  flex-direction: column;\n  gap: 32px;\n}\n\n.report-section-item {\n  display: flex;\n  flex-direction: column;\n  gap: 12px;\n}\n\n.section-header-row {\n  display: flex;\n  align-items: baseline;\n  gap: 12px;\n  transition: background-color 0.2s ease;\n  padding: 8px 12px;\n  margin: -8px -12px;\n  border-radius: 8px;\n}\n\n.section-header-row.clickable {\n  cursor: pointer;\n}\n\n.section-header-row.clickable:hover {\n  background-color: #F9FAFB;\n}\n\n.collapse-icon {\n  margin-left: auto;\n  color: #9CA3AF;\n  transition: transform 0.3s ease;\n  flex-shrink: 0;\n  align-self: center;\n}\n\n.collapse-icon.is-collapsed {\n  transform: rotate(-90deg);\n}\n\n.section-number {\n  font-family: 'JetBrains Mono', monospace;\n  font-size: 16px;\n  color: #9CA3AF; /* 深灰色，不随状态变化 */\n  font-weight: 500;\n}\n\n.section-title {\n  font-family: 'Times New Roman', Times, serif;\n  font-size: 24px;\n  font-weight: 600;\n  color: #111827;\n  margin: 0;\n  transition: color 0.3s ease;\n}\n\n/* States */\n.report-section-item.is-pending .section-title {\n  color: #D1D5DB;\n}\n\n.report-section-item.is-active .section-title,\n.report-section-item.is-completed .section-title {\n  color: #111827;\n}\n\n.section-body {\n  padding-left: 28px;\n  overflow: hidden;\n}\n\n/* Generated Content */\n.generated-content {\n  font-family: 'Inter', 'Noto Sans SC', system-ui, sans-serif;\n  font-size: 14px;\n  line-height: 1.8;\n  color: #374151;\n}\n\n.generated-content :deep(p) {\n  margin-bottom: 1em;\n}\n\n.generated-content :deep(.md-h2),\n.generated-content :deep(.md-h3),\n.generated-content :deep(.md-h4) {\n  font-family: 'Times New Roman', Times, serif;\n  color: #111827;\n  margin-top: 1.5em;\n  margin-bottom: 0.8em;\n  font-weight: 700;\n}\n\n.generated-content :deep(.md-h2) { font-size: 20px; border-bottom: 1px solid #F3F4F6; padding-bottom: 8px; }\n.generated-content :deep(.md-h3) { font-size: 18px; }\n.generated-content :deep(.md-h4) { font-size: 16px; }\n\n.generated-content :deep(.md-ul),\n.generated-content :deep(.md-ol) {\n  padding-left: 24px;\n  margin: 12px 0;\n}\n\n.generated-content :deep(.md-li),\n.generated-content :deep(.md-oli) {\n  margin: 6px 0;\n}\n\n.generated-content :deep(.md-quote) {\n  border-left: 3px solid #E5E7EB;\n  padding-left: 16px;\n  margin: 1.5em 0;\n  color: #6B7280;\n  font-style: italic;\n  font-family: 'Times New Roman', Times, serif;\n}\n\n.generated-content :deep(.code-block) {\n  background: #F9FAFB;\n  padding: 12px;\n  border-radius: 6px;\n  font-family: 'JetBrains Mono', monospace;\n  font-size: 12px;\n  overflow-x: auto;\n  margin: 1em 0;\n  border: 1px solid #E5E7EB;\n}\n\n.generated-content :deep(strong) {\n  font-weight: 600;\n  color: #111827;\n}\n\n/* Loading State */\n.loading-state {\n  display: flex;\n  align-items: center;\n  gap: 10px;\n  color: #6B7280;\n  font-size: 14px;\n  margin-top: 4px;\n}\n\n.loading-icon {\n  width: 18px;\n  height: 18px;\n  animation: spin 1s linear infinite;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n}\n\n.loading-text {\n  font-family: 'Times New Roman', Times, serif;\n  font-size: 15px;\n  color: #4B5563;\n}\n\n.cursor-blink {\n  display: inline-block;\n  width: 8px;\n  height: 14px;\n  background: #8B5CF6;\n  opacity: 0.5;\n  animation: blink 1s step-end infinite;\n}\n\n@keyframes blink {\n  0%, 100% { opacity: 0.5; }\n  50% { opacity: 0; }\n}\n\n@keyframes spin {\n  to { transform: rotate(360deg); }\n}\n\n/* Content Styles Override for this view */\n.generated-content :deep(.md-h2) {\n  font-family: 'Times New Roman', Times, serif;\n  font-size: 18px;\n  margin-top: 0;\n}\n\n\n/* Slide Content Transition */\n.slide-content-enter-active {\n  transition: opacity 0.3s ease-out;\n}\n\n.slide-content-leave-active {\n  transition: opacity 0.2s ease-in;\n}\n\n.slide-content-enter-from,\n.slide-content-leave-to {\n  opacity: 0;\n}\n\n.slide-content-enter-to,\n.slide-content-leave-from {\n  opacity: 1;\n}\n\n/* Waiting Placeholder */\n.waiting-placeholder {\n  flex: 1;\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  justify-content: center;\n  gap: 20px;\n  padding: 40px;\n  color: #9CA3AF;\n}\n\n.waiting-animation {\n  position: relative;\n  width: 48px;\n  height: 48px;\n}\n\n.waiting-ring {\n  position: absolute;\n  width: 100%;\n  height: 100%;\n  border: 2px solid #E5E7EB;\n  border-radius: 50%;\n  animation: ripple 2s cubic-bezier(0.4, 0, 0.2, 1) infinite;\n}\n\n.waiting-ring:nth-child(2) {\n  animation-delay: 0.4s;\n}\n\n.waiting-ring:nth-child(3) {\n  animation-delay: 0.8s;\n}\n\n@keyframes ripple {\n  0% { transform: scale(0.5); opacity: 1; }\n  100% { transform: scale(2); opacity: 0; }\n}\n\n.waiting-text {\n  font-size: 14px;\n}\n\n/* Right Panel */\n.right-panel {\n  flex: 1;\n  background: #FFFFFF;\n  overflow-y: auto;\n  display: flex;\n  flex-direction: column;\n\n  /* Functional palette (low saturation, status-based) */\n  --wf-border: #E5E7EB;\n  --wf-divider: #F3F4F6;\n\n  --wf-active-bg: #FAFAFA;\n  --wf-active-border: #1F2937;\n  --wf-active-dot: #1F2937;\n  --wf-active-text: #1F2937;\n\n  --wf-done-bg: #F9FAFB;\n  --wf-done-border: #E5E7EB;\n  --wf-done-dot: #10B981;\n\n  --wf-muted-dot: #D1D5DB;\n  --wf-todo-text: #9CA3AF;\n}\n\n.right-panel::-webkit-scrollbar {\n  width: 6px;\n}\n\n.right-panel::-webkit-scrollbar-track {\n  background: transparent;\n}\n\n.right-panel::-webkit-scrollbar-thumb {\n  background: transparent;\n  border-radius: 3px;\n  transition: background 0.3s ease;\n}\n\n.right-panel:hover::-webkit-scrollbar-thumb {\n  background: rgba(0, 0, 0, 0.15);\n}\n\n.right-panel::-webkit-scrollbar-thumb:hover {\n  background: rgba(0, 0, 0, 0.25);\n}\n\n.mono {\n  font-family: 'JetBrains Mono', monospace;\n}\n\n/* Workflow Overview */\n.workflow-overview {\n  padding: 16px 20px 0 20px;\n}\n\n.workflow-metrics {\n  display: flex;\n  flex-wrap: wrap;\n  align-items: center;\n  gap: 10px;\n  margin-bottom: 12px;\n}\n\n.metric {\n  display: inline-flex;\n  align-items: baseline;\n  gap: 6px;\n}\n\n.metric-right {\n  margin-left: auto;\n}\n\n.metric-label {\n  font-size: 11px;\n  font-weight: 600;\n  color: #9CA3AF;\n  text-transform: uppercase;\n  letter-spacing: 0.04em;\n}\n\n.metric-value {\n  font-size: 12px;\n  color: #374151;\n}\n\n.metric-pill {\n  font-size: 11px;\n  font-weight: 700;\n  letter-spacing: 0.04em;\n  text-transform: uppercase;\n  padding: 4px 10px;\n  border-radius: 999px;\n  border: 1px solid var(--wf-border);\n  background: #F9FAFB;\n  color: #6B7280;\n}\n\n.metric-pill.pill--processing {\n  background: var(--wf-active-bg);\n  border-color: var(--wf-active-border);\n  color: var(--wf-active-text);\n}\n\n.metric-pill.pill--completed {\n  background: #ECFDF5;\n  border-color: #A7F3D0;\n  color: #065F46;\n}\n\n.metric-pill.pill--pending {\n  background: transparent;\n  border-style: dashed;\n  color: #6B7280;\n}\n\n.workflow-steps {\n  display: flex;\n  flex-direction: column;\n  gap: 10px;\n  padding-bottom: 10px;\n}\n\n.wf-step {\n  display: grid;\n  grid-template-columns: 24px 1fr;\n  gap: 12px;\n  padding: 10px 12px;\n  border: 1px solid var(--wf-divider);\n  border-radius: 8px;\n  background: #FFFFFF;\n}\n\n.wf-step--active {\n  background: var(--wf-active-bg);\n  border-color: var(--wf-active-border);\n}\n\n.wf-step--done {\n  background: var(--wf-done-bg);\n  border-color: var(--wf-done-border);\n}\n\n.wf-step--todo {\n  background: transparent;\n  border-color: var(--wf-border);\n  border-style: dashed;\n}\n\n.wf-step-connector {\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  width: 24px;\n  flex-shrink: 0;\n}\n\n.wf-step-dot {\n  width: 10px;\n  height: 10px;\n  border-radius: 50%;\n  background: var(--wf-muted-dot);\n  border: 2px solid #FFFFFF;\n  z-index: 1;\n}\n\n.wf-step-line {\n  width: 2px;\n  flex: 1;\n  background: var(--wf-divider);\n  margin-top: -2px;\n}\n\n.wf-step--active .wf-step-dot {\n  background: var(--wf-active-dot);\n  box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.12);\n}\n\n.wf-step--done .wf-step-dot {\n  background: var(--wf-done-dot);\n}\n\n.wf-step-title-row {\n  display: flex;\n  align-items: baseline;\n  gap: 10px;\n  min-width: 0;\n}\n\n.wf-step-index {\n  font-size: 11px;\n  font-weight: 700;\n  color: #9CA3AF;\n  letter-spacing: 0.02em;\n  flex-shrink: 0;\n}\n\n.wf-step-title {\n  font-family: 'Times New Roman', Times, serif;\n  font-size: 13px;\n  font-weight: 600;\n  color: #111827;\n  line-height: 1.35;\n  min-width: 0;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n\n.wf-step-meta {\n  margin-left: auto;\n  font-size: 10px;\n  font-weight: 700;\n  color: var(--wf-active-text);\n  text-transform: uppercase;\n  letter-spacing: 0.04em;\n  flex-shrink: 0;\n}\n\n.wf-step--todo .wf-step-title,\n.wf-step--todo .wf-step-index {\n  color: var(--wf-todo-text);\n}\n\n.workflow-divider {\n  height: 1px;\n  background: var(--wf-divider);\n  margin: 14px 0 0 0;\n}\n\n/* Workflow Timeline */\n.workflow-timeline {\n  padding: 14px 20px 24px;\n  flex: 1;\n}\n\n.timeline-item {\n  display: grid;\n  grid-template-columns: 24px 1fr;\n  gap: 12px;\n  padding: 10px 12px;\n  margin-bottom: 10px;\n  border: 1px solid var(--wf-divider);\n  border-radius: 8px;\n  background: #FFFFFF;\n  transition: background-color 0.15s ease, border-color 0.15s ease;\n}\n\n.timeline-item:hover {\n  background: #F9FAFB;\n  border-color: var(--wf-border);\n}\n\n.timeline-item.node--active {\n  background: var(--wf-active-bg);\n  border-color: var(--wf-active-border);\n}\n\n.timeline-item.node--active:hover {\n  background: var(--wf-active-bg);\n  border-color: var(--wf-active-border);\n}\n\n.timeline-item.node--done {\n  background: var(--wf-done-bg);\n  border-color: var(--wf-done-border);\n}\n\n.timeline-item.node--done:hover {\n  background: var(--wf-done-bg);\n  border-color: var(--wf-done-border);\n}\n\n.timeline-connector {\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  width: 24px;\n  flex-shrink: 0;\n}\n\n.connector-dot {\n  width: 12px;\n  height: 12px;\n  border-radius: 50%;\n  background: var(--wf-muted-dot);\n  border: 2px solid #FFFFFF;\n  z-index: 1;\n}\n\n.connector-line {\n  width: 2px;\n  flex: 1;\n  background: var(--wf-divider);\n  margin-top: -2px;\n}\n\n/* Connector dot: status only */\n.dot-active {\n  background: var(--wf-active-dot);\n  box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.12);\n}\n\n.dot-done {\n  background: var(--wf-done-dot);\n}\n\n.dot-muted {\n  background: var(--wf-muted-dot);\n}\n\n.timeline-content {\n  min-width: 0;\n  background: transparent;\n  border: none;\n  border-radius: 0;\n  padding: 0;\n  margin: 0;\n  transition: none;\n}\n\n.timeline-content:hover {\n  box-shadow: none;\n}\n\n.timeline-header {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  margin-bottom: 10px;\n}\n\n.action-label {\n  font-size: 12px;\n  font-weight: 600;\n  color: #374151;\n  text-transform: uppercase;\n  letter-spacing: 0.03em;\n}\n\n.action-time {\n  font-size: 11px;\n  color: #9CA3AF;\n  font-family: 'JetBrains Mono', monospace;\n}\n\n.timeline-body {\n  font-size: 13px;\n  color: #4B5563;\n}\n\n.timeline-footer {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  margin-top: 10px;\n  padding-top: 10px;\n  border-top: 1px solid #F3F4F6;\n}\n\n.elapsed-placeholder {\n  flex-shrink: 0;\n}\n\n.footer-actions {\n  display: flex;\n  gap: 8px;\n  margin-left: auto;\n}\n\n.elapsed-badge {\n  font-size: 11px;\n  color: #6B7280;\n  background: #F3F4F6;\n  padding: 2px 8px;\n  border-radius: 10px;\n  font-family: 'JetBrains Mono', monospace;\n}\n\n/* Timeline Body Elements */\n.info-row {\n  display: flex;\n  gap: 8px;\n  margin-bottom: 6px;\n}\n\n.info-key {\n  font-size: 11px;\n  color: #9CA3AF;\n  min-width: 80px;\n}\n\n.info-val {\n  color: #374151;\n}\n\n.status-message {\n  padding: 8px 12px;\n  border-radius: 6px;\n  font-size: 13px;\n  border: 1px solid transparent;\n}\n\n.status-message.planning {\n  background: var(--wf-active-bg);\n  border-color: var(--wf-active-border);\n  color: var(--wf-active-text);\n}\n\n.status-message.success {\n  background: #ECFDF5;\n  border-color: #A7F3D0;\n  color: #065F46;\n}\n\n.outline-badge {\n  display: inline-block;\n  margin-top: 8px;\n  padding: 4px 10px;\n  background: #F9FAFB;\n  color: #6B7280;\n  border: 1px solid #E5E7EB;\n  border-radius: 12px;\n  font-size: 11px;\n  font-weight: 500;\n}\n\n.section-tag {\n  display: inline-flex;\n  align-items: center;\n  gap: 8px;\n  padding: 6px 12px;\n  background: #F9FAFB;\n  border: 1px solid var(--wf-border);\n  border-radius: 6px;\n}\n\n.section-tag.content-ready {\n  background: var(--wf-active-bg);\n  border: 1px dashed var(--wf-active-border);\n}\n\n.section-tag.content-ready svg {\n  color: var(--wf-active-dot);\n}\n\n\n.section-tag.completed {\n  background: #ECFDF5;\n  border: 1px solid #A7F3D0;\n}\n\n.section-tag.completed svg {\n  color: #059669;\n}\n\n.tag-num {\n  font-size: 11px;\n  font-weight: 700;\n  color: #6B7280;\n}\n\n.section-tag.completed .tag-num {\n  color: #059669;\n}\n\n.tag-title {\n  font-size: 13px;\n  font-weight: 500;\n  color: #374151;\n}\n\n.tool-badge {\n  display: inline-flex;\n  align-items: center;\n  gap: 6px;\n  padding: 6px 12px;\n  background: #F9FAFB;\n  color: #374151;\n  border: 1px solid var(--wf-border);\n  border-radius: 6px;\n  font-size: 12px;\n  font-weight: 600;\n  transition: all 0.2s ease;\n}\n\n.tool-icon {\n  flex-shrink: 0;\n}\n\n/* Tool Colors - Purple (Deep Insight) */\n.tool-badge.tool-purple {\n  background: linear-gradient(135deg, #F5F3FF 0%, #EDE9FE 100%);\n  border-color: #C4B5FD;\n  color: #6D28D9;\n}\n.tool-badge.tool-purple .tool-icon {\n  stroke: #7C3AED;\n}\n\n/* Tool Colors - Blue (Panorama Search) */\n.tool-badge.tool-blue {\n  background: linear-gradient(135deg, #EFF6FF 0%, #DBEAFE 100%);\n  border-color: #93C5FD;\n  color: #1D4ED8;\n}\n.tool-badge.tool-blue .tool-icon {\n  stroke: #2563EB;\n}\n\n/* Tool Colors - Green (Agent Interview) */\n.tool-badge.tool-green {\n  background: linear-gradient(135deg, #F0FDF4 0%, #DCFCE7 100%);\n  border-color: #86EFAC;\n  color: #15803D;\n}\n.tool-badge.tool-green .tool-icon {\n  stroke: #16A34A;\n}\n\n/* Tool Colors - Orange (Quick Search) */\n.tool-badge.tool-orange {\n  background: linear-gradient(135deg, #FFF7ED 0%, #FFEDD5 100%);\n  border-color: #FDBA74;\n  color: #C2410C;\n}\n.tool-badge.tool-orange .tool-icon {\n  stroke: #EA580C;\n}\n\n/* Tool Colors - Cyan (Graph Stats) */\n.tool-badge.tool-cyan {\n  background: linear-gradient(135deg, #ECFEFF 0%, #CFFAFE 100%);\n  border-color: #67E8F9;\n  color: #0E7490;\n}\n.tool-badge.tool-cyan .tool-icon {\n  stroke: #0891B2;\n}\n\n/* Tool Colors - Pink (Entity Query) */\n.tool-badge.tool-pink {\n  background: linear-gradient(135deg, #FDF2F8 0%, #FCE7F3 100%);\n  border-color: #F9A8D4;\n  color: #BE185D;\n}\n.tool-badge.tool-pink .tool-icon {\n  stroke: #DB2777;\n}\n\n/* Tool Colors - Gray (Default) */\n.tool-badge.tool-gray {\n  background: linear-gradient(135deg, #F9FAFB 0%, #F3F4F6 100%);\n  border-color: #D1D5DB;\n  color: #374151;\n}\n.tool-badge.tool-gray .tool-icon {\n  stroke: #6B7280;\n}\n\n.tool-params {\n  margin-top: 10px;\n  background: transparent;\n  border-radius: 0;\n  padding: 10px 0 0 0;\n  border-top: 1px dashed var(--wf-divider);\n  overflow-x: auto;\n}\n\n.tool-params pre {\n  margin: 0;\n  font-family: 'JetBrains Mono', monospace;\n  font-size: 11px;\n  color: #4B5563;\n  white-space: pre-wrap;\n  word-break: break-all;\n  background: #F9FAFB;\n  border: 1px solid #E5E7EB;\n  border-radius: 6px;\n  padding: 10px;\n}\n\n/* Unified Action Buttons */\n.action-btn {\n  background: #F3F4F6;\n  border: 1px solid #E5E7EB;\n  padding: 4px 10px;\n  border-radius: 4px;\n  font-size: 11px;\n  font-weight: 500;\n  color: #6B7280;\n  cursor: pointer;\n  transition: all 0.15s ease;\n  white-space: nowrap;\n}\n\n.action-btn:hover {\n  background: #E5E7EB;\n  color: #374151;\n  border-color: #D1D5DB;\n}\n\n/* Result Wrapper */\n.result-wrapper {\n  background: transparent;\n  border: none;\n  border-top: 1px solid var(--wf-divider);\n  border-radius: 0;\n  padding: 12px 0 0 0;\n}\n\n.result-meta {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  margin-bottom: 10px;\n}\n\n.result-tool {\n  font-size: 12px;\n  font-weight: 600;\n  color: #374151;\n}\n\n.result-size {\n  font-size: 10px;\n  color: #6B7280;\n  font-family: 'JetBrains Mono', monospace;\n}\n\n.result-raw {\n  margin-top: 10px;\n  max-height: 300px;\n  overflow-y: auto;\n}\n\n.result-raw pre {\n  margin: 0;\n  font-family: 'JetBrains Mono', monospace;\n  font-size: 11px;\n  white-space: pre-wrap;\n  word-break: break-word;\n  color: #374151;\n  background: #FFFFFF;\n  border: 1px solid #E5E7EB;\n  padding: 10px;\n  border-radius: 6px;\n}\n\n.raw-preview {\n  margin: 0;\n  font-family: 'JetBrains Mono', monospace;\n  font-size: 11px;\n  white-space: pre-wrap;\n  word-break: break-word;\n  color: #6B7280;\n}\n\n/* Legacy toggle-raw removed - using unified .action-btn */\n\n/* LLM Response */\n.llm-meta {\n  display: flex;\n  gap: 8px;\n  flex-wrap: wrap;\n}\n\n.meta-tag {\n  font-size: 11px;\n  padding: 3px 8px;\n  background: #F3F4F6;\n  color: #6B7280;\n  border-radius: 4px;\n}\n\n.meta-tag.active {\n  background: #DBEAFE;\n  color: #1E40AF;\n}\n\n.meta-tag.final-answer {\n  background: #D1FAE5;\n  color: #059669;\n  font-weight: 600;\n}\n\n.final-answer-hint {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  margin-top: 10px;\n  padding: 10px 14px;\n  background: #ECFDF5;\n  border: 1px solid #A7F3D0;\n  border-radius: 6px;\n  color: #065F46;\n  font-size: 12px;\n  font-weight: 500;\n}\n\n.final-answer-hint svg {\n  flex-shrink: 0;\n}\n\n.llm-content {\n  margin-top: 10px;\n  max-height: 200px;\n  overflow-y: auto;\n}\n\n.llm-content pre {\n  margin: 0;\n  font-family: 'JetBrains Mono', monospace;\n  font-size: 11px;\n  white-space: pre-wrap;\n  word-break: break-word;\n  color: #4B5563;\n  background: #F3F4F6;\n  padding: 10px;\n  border-radius: 6px;\n}\n\n/* Complete Banner */\n.complete-banner {\n  display: flex;\n  align-items: center;\n  gap: 10px;\n  padding: 12px 16px;\n  background: #ECFDF5;\n  border: 1px solid #A7F3D0;\n  border-radius: 8px;\n  color: #065F46;\n  font-weight: 600;\n  font-size: 14px;\n}\n\n.next-step-btn {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  gap: 8px;\n  width: calc(100% - 40px);\n  margin: 4px 20px 0 20px;\n  padding: 14px 20px;\n  font-size: 14px;\n  font-weight: 600;\n  color: #FFFFFF;\n  background: #1F2937;\n  border: none;\n  border-radius: 8px;\n  cursor: pointer;\n  transition: all 0.2s ease;\n}\n\n.next-step-btn:hover {\n  background: #374151;\n}\n\n.next-step-btn svg {\n  transition: transform 0.2s ease;\n}\n\n.next-step-btn:hover svg {\n  transform: translateX(4px);\n}\n\n/* Workflow Empty */\n.workflow-empty {\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  justify-content: center;\n  padding: 60px 20px;\n  color: #9CA3AF;\n  font-size: 13px;\n}\n\n.empty-pulse {\n  width: 24px;\n  height: 24px;\n  background: #E5E7EB;\n  border-radius: 50%;\n  margin-bottom: 16px;\n  animation: pulse-ring 1.5s infinite;\n}\n\n@keyframes pulse-ring {\n  0%, 100% { transform: scale(1); opacity: 1; }\n  50% { transform: scale(1.2); opacity: 0.5; }\n}\n\n/* Timeline Transitions */\n.timeline-item-enter-active {\n  transition: all 0.4s ease;\n}\n\n.timeline-item-enter-from {\n  opacity: 0;\n  transform: translateX(-20px);\n}\n\n/* ========== Structured Result Display Components ========== */\n\n/* Common Styles - using :deep() for dynamic components */\n:deep(.stat-row) {\n  display: flex;\n  gap: 8px;\n  margin-bottom: 12px;\n}\n\n:deep(.stat-box) {\n  flex: 1;\n  background: #FFFFFF;\n  border: 1px solid #E5E7EB;\n  border-radius: 6px;\n  padding: 10px 8px;\n  text-align: center;\n}\n\n:deep(.stat-box .stat-num) {\n  display: block;\n  font-size: 20px;\n  font-weight: 700;\n  color: #111827;\n  font-family: 'JetBrains Mono', monospace;\n}\n\n:deep(.stat-box .stat-label) {\n  display: block;\n  font-size: 10px;\n  color: #9CA3AF;\n  margin-top: 2px;\n  text-transform: uppercase;\n  letter-spacing: 0.03em;\n}\n\n:deep(.stat-box.highlight) {\n  background: #ECFDF5;\n  border-color: #A7F3D0;\n}\n\n:deep(.stat-box.highlight .stat-num) {\n  color: #059669;\n}\n\n:deep(.stat-box.muted) {\n  background: #F9FAFB;\n  border-color: #E5E7EB;\n}\n\n:deep(.stat-box.muted .stat-num) {\n  color: #6B7280;\n}\n\n:deep(.query-display) {\n  background: #F9FAFB;\n  padding: 10px 14px;\n  border-radius: 6px;\n  font-size: 12px;\n  color: #374151;\n  margin-bottom: 12px;\n  border: 1px solid #E5E7EB;\n  line-height: 1.5;\n}\n\n:deep(.expand-details) {\n  background: #FFFFFF;\n  border: 1px solid #E5E7EB;\n  padding: 8px 14px;\n  border-radius: 6px;\n  font-size: 11px;\n  font-weight: 500;\n  color: #6B7280;\n  cursor: pointer;\n  transition: all 0.15s ease;\n}\n\n:deep(.expand-details:hover) {\n  border-color: #D1D5DB;\n  color: #374151;\n}\n\n:deep(.detail-content) {\n  margin-top: 14px;\n  background: #FFFFFF;\n  border: 1px solid #E5E7EB;\n  border-radius: 8px;\n  padding: 14px;\n}\n\n:deep(.section-label) {\n  font-size: 11px;\n  font-weight: 600;\n  color: #6B7280;\n  text-transform: uppercase;\n  letter-spacing: 0.04em;\n  margin-bottom: 10px;\n  padding-bottom: 6px;\n  border-bottom: 1px solid #F3F4F6;\n}\n\n/* Facts Section */\n:deep(.facts-section) {\n  margin-bottom: 14px;\n}\n\n:deep(.fact-row) {\n  display: flex;\n  gap: 10px;\n  padding: 8px 0;\n  border-bottom: 1px solid #F3F4F6;\n}\n\n:deep(.fact-row:last-child) {\n  border-bottom: none;\n}\n\n:deep(.fact-row.active) {\n  background: #ECFDF5;\n  margin: 0 -10px;\n  padding: 8px 10px;\n  border-radius: 6px;\n  border-bottom: none;\n}\n\n:deep(.fact-idx) {\n  min-width: 22px;\n  height: 22px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  background: #F3F4F6;\n  border-radius: 6px;\n  font-size: 10px;\n  font-weight: 700;\n  color: #6B7280;\n  flex-shrink: 0;\n}\n\n:deep(.fact-row.active .fact-idx) {\n  background: #A7F3D0;\n  color: #065F46;\n}\n\n:deep(.fact-text) {\n  font-size: 12px;\n  color: #4B5563;\n  line-height: 1.6;\n}\n\n/* Entities Section */\n:deep(.entities-section) {\n  margin-bottom: 14px;\n}\n\n:deep(.entity-chips) {\n  display: flex;\n  flex-wrap: wrap;\n  gap: 8px;\n}\n\n:deep(.entity-chip) {\n  display: inline-flex;\n  align-items: center;\n  gap: 6px;\n  background: #F9FAFB;\n  border: 1px solid #E5E7EB;\n  border-radius: 6px;\n  padding: 6px 12px;\n}\n\n:deep(.chip-name) {\n  font-size: 12px;\n  font-weight: 500;\n  color: #111827;\n}\n\n:deep(.chip-type) {\n  font-size: 10px;\n  color: #9CA3AF;\n  background: #E5E7EB;\n  padding: 1px 6px;\n  border-radius: 3px;\n}\n\n/* Relations Section */\n:deep(.relations-section) {\n  margin-bottom: 14px;\n}\n\n:deep(.relation-row) {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  padding: 8px 0;\n  flex-wrap: wrap;\n  border-bottom: 1px solid #F3F4F6;\n}\n\n:deep(.relation-row:last-child) {\n  border-bottom: none;\n}\n\n:deep(.rel-node) {\n  font-size: 12px;\n  font-weight: 500;\n  color: #111827;\n  background: #F3F4F6;\n  padding: 4px 10px;\n  border-radius: 4px;\n}\n\n:deep(.rel-edge) {\n  font-size: 10px;\n  font-weight: 600;\n  color: #FFFFFF;\n  background: #4F46E5;\n  padding: 3px 10px;\n  border-radius: 10px;\n}\n\n/* ========== Interview Display - Conversation Style ========== */\n:deep(.interview-display) {\n  padding: 0;\n}\n\n/* Header */\n:deep(.interview-display .interview-header) {\n  padding: 0;\n  background: transparent;\n  border-bottom: none;\n  margin-bottom: 16px;\n}\n\n:deep(.interview-display .header-main) {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n}\n\n:deep(.interview-display .header-title) {\n  font-family: 'JetBrains Mono', monospace;\n  font-size: 13px;\n  font-weight: 600;\n  color: #111827;\n  letter-spacing: -0.01em;\n}\n\n:deep(.interview-display .header-stats) {\n  display: flex;\n  align-items: center;\n  gap: 6px;\n}\n\n:deep(.interview-display .stat-item) {\n  display: flex;\n  align-items: baseline;\n  gap: 4px;\n}\n\n:deep(.interview-display .stat-value) {\n  font-size: 14px;\n  font-weight: 600;\n  color: #4F46E5;\n  font-family: 'JetBrains Mono', monospace;\n}\n\n:deep(.interview-display .stat-label) {\n  font-size: 11px;\n  color: #9CA3AF;\n  text-transform: lowercase;\n}\n\n:deep(.interview-display .stat-divider) {\n  color: #D1D5DB;\n  font-size: 12px;\n}\n\n:deep(.interview-display .stat-size) {\n  font-size: 11px;\n  color: #9CA3AF;\n  font-family: 'JetBrains Mono', monospace;\n}\n\n:deep(.interview-display .header-topic) {\n  margin-top: 4px;\n  font-size: 12px;\n  color: #6B7280;\n  line-height: 1.5;\n}\n\n/* Agent Tabs - Card Style */\n:deep(.interview-display .agent-tabs) {\n  display: flex;\n  gap: 8px;\n  padding: 0 0 14px 0;\n  background: transparent;\n  border-bottom: 1px solid #F3F4F6;\n  overflow-x: auto;\n  overflow-y: hidden;\n  scrollbar-width: thin;\n  scrollbar-color: #E5E7EB transparent;\n}\n\n:deep(.interview-display .agent-tabs::-webkit-scrollbar) {\n  height: 4px;\n}\n\n:deep(.interview-display .agent-tabs::-webkit-scrollbar-track) {\n  background: transparent;\n}\n\n:deep(.interview-display .agent-tabs::-webkit-scrollbar-thumb) {\n  background: #E5E7EB;\n  border-radius: 2px;\n}\n\n:deep(.interview-display .agent-tabs::-webkit-scrollbar-thumb:hover) {\n  background: #D1D5DB;\n}\n\n:deep(.interview-display .agent-tab) {\n  display: flex;\n  align-items: center;\n  gap: 6px;\n  padding: 6px 12px;\n  background: #F9FAFB;\n  border: 1px solid #E5E7EB;\n  border-radius: 8px;\n  font-size: 12px;\n  font-weight: 500;\n  color: #6B7280;\n  cursor: pointer;\n  transition: all 0.15s ease;\n  white-space: nowrap;\n}\n\n:deep(.interview-display .agent-tab:hover) {\n  background: #F3F4F6;\n  border-color: #D1D5DB;\n  color: #374151;\n}\n\n:deep(.interview-display .agent-tab.active) {\n  background: linear-gradient(135deg, #EEF2FF 0%, #E0E7FF 100%);\n  border-color: #A5B4FC;\n  color: #4338CA;\n  box-shadow: 0 1px 2px rgba(99, 102, 241, 0.1);\n}\n\n:deep(.interview-display .tab-avatar) {\n  width: 18px;\n  height: 18px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  background: #E5E7EB;\n  color: #6B7280;\n  font-size: 10px;\n  font-weight: 700;\n  border-radius: 50%;\n  flex-shrink: 0;\n}\n\n:deep(.interview-display .agent-tab:hover .tab-avatar) {\n  background: #D1D5DB;\n}\n\n:deep(.interview-display .agent-tab.active .tab-avatar) {\n  background: #6366F1;\n  color: #FFFFFF;\n}\n\n:deep(.interview-display .tab-name) {\n  max-width: 100px;\n  overflow: hidden;\n  text-overflow: ellipsis;\n}\n\n/* Interview Detail */\n:deep(.interview-display .interview-detail) {\n  padding: 12px 0;\n  background: transparent;\n}\n\n/* Agent Profile - No card */\n:deep(.interview-display .agent-profile) {\n  display: flex;\n  gap: 12px;\n  padding: 0;\n  background: transparent;\n  border: none;\n  margin-bottom: 16px;\n}\n\n:deep(.interview-display .profile-avatar) {\n  width: 32px;\n  height: 32px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  background: #E5E7EB;\n  color: #6B7280;\n  font-size: 14px;\n  font-weight: 600;\n  border-radius: 50%;\n  flex-shrink: 0;\n}\n\n:deep(.interview-display .profile-info) {\n  flex: 1;\n  min-width: 0;\n}\n\n:deep(.interview-display .profile-name) {\n  font-size: 13px;\n  font-weight: 600;\n  color: #111827;\n  margin-bottom: 2px;\n}\n\n:deep(.interview-display .profile-role) {\n  font-size: 11px;\n  color: #6B7280;\n  margin-bottom: 4px;\n}\n\n:deep(.interview-display .profile-bio) {\n  font-size: 11px;\n  color: #9CA3AF;\n  line-height: 1.4;\n  display: -webkit-box;\n  -webkit-line-clamp: 2;\n  -webkit-box-orient: vertical;\n  overflow: hidden;\n}\n\n/* Selection Reason - 选择理由 */\n:deep(.interview-display .selection-reason) {\n  background: #F8FAFC;\n  border: 1px solid #E2E8F0;\n  border-radius: 8px;\n  padding: 12px 14px;\n  margin-bottom: 16px;\n}\n\n:deep(.interview-display .reason-label) {\n  font-size: 11px;\n  font-weight: 600;\n  color: #64748B;\n  text-transform: uppercase;\n  letter-spacing: 0.03em;\n  margin-bottom: 6px;\n}\n\n:deep(.interview-display .reason-content) {\n  font-size: 12px;\n  color: #475569;\n  line-height: 1.6;\n}\n\n/* Q&A Thread - Clean list */\n:deep(.interview-display .qa-thread) {\n  display: flex;\n  flex-direction: column;\n  gap: 20px;\n}\n\n:deep(.interview-display .qa-pair) {\n  display: flex;\n  flex-direction: column;\n  gap: 12px;\n  padding: 0;\n  background: transparent;\n  border: none;\n  border-radius: 0;\n}\n\n:deep(.interview-display .qa-question),\n:deep(.interview-display .qa-answer) {\n  display: flex;\n  gap: 12px;\n}\n\n:deep(.interview-display .qa-badge) {\n  width: 20px;\n  height: 20px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  font-family: 'JetBrains Mono', monospace;\n  font-size: 10px;\n  font-weight: 700;\n  border-radius: 4px;\n  flex-shrink: 0;\n}\n\n:deep(.interview-display .q-badge) {\n  background: transparent;\n  color: #9CA3AF;\n  border: 1px solid #E5E7EB;\n}\n\n:deep(.interview-display .a-badge) {\n  background: #4F46E5;\n  color: #FFFFFF;\n  border: 1px solid #4F46E5;\n}\n\n:deep(.interview-display .qa-content) {\n  flex: 1;\n  min-width: 0;\n}\n\n:deep(.interview-display .qa-sender) {\n  font-size: 11px;\n  font-weight: 600;\n  color: #9CA3AF;\n  margin-bottom: 4px;\n  text-transform: uppercase;\n  letter-spacing: 0.03em;\n}\n\n:deep(.interview-display .qa-text) {\n  font-size: 13px;\n  color: #374151;\n  line-height: 1.6;\n}\n\n:deep(.interview-display .qa-answer) {\n  background: transparent;\n  padding: 0;\n  border: none;\n  margin-top: 0;\n}\n\n:deep(.interview-display .answer-placeholder) {\n  opacity: 0.6;\n}\n\n:deep(.interview-display .placeholder-text) {\n  font-style: italic;\n  color: #9CA3AF;\n}\n\n:deep(.interview-display .qa-answer-header) {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  margin-bottom: 4px;\n}\n\n/* Platform Switch */\n:deep(.interview-display .platform-switch) {\n  display: flex;\n  gap: 2px;\n  background: transparent;\n  padding: 0;\n  border-radius: 0;\n}\n\n:deep(.interview-display .platform-btn) {\n  display: flex;\n  align-items: center;\n  gap: 4px;\n  padding: 2px 6px;\n  background: transparent;\n  border: 1px solid transparent;\n  border-radius: 4px;\n  font-size: 10px;\n  font-weight: 500;\n  color: #9CA3AF;\n  cursor: pointer;\n  transition: all 0.15s ease;\n}\n\n:deep(.interview-display .platform-btn:hover) {\n  color: #6B7280;\n}\n\n:deep(.interview-display .platform-btn.active) {\n  background: transparent;\n  color: #4F46E5;\n  border-color: #E5E7EB;\n  box-shadow: none;\n}\n\n:deep(.interview-display .platform-icon) {\n  flex-shrink: 0;\n}\n\n:deep(.interview-display .answer-text) {\n  font-size: 13px;\n  color: #111827;\n  line-height: 1.6;\n}\n\n:deep(.interview-display .answer-text strong) {\n  color: #111827;\n  font-weight: 600;\n}\n\n:deep(.interview-display .expand-answer-btn) {\n  display: inline-block;\n  margin-top: 8px;\n  padding: 0;\n  background: transparent;\n  border: none;\n  border-bottom: 1px dotted #D1D5DB;\n  border-radius: 0;\n  font-size: 11px;\n  font-weight: 500;\n  color: #9CA3AF;\n  cursor: pointer;\n  transition: all 0.15s ease;\n}\n\n:deep(.interview-display .expand-answer-btn:hover) {\n  background: transparent;\n  color: #6B7280;\n  border-bottom-style: solid;\n}\n\n/* Quotes Section - Clean list */\n:deep(.interview-display .quotes-section) {\n  background: transparent;\n  border: none;\n  border-top: 1px solid #F3F4F6;\n  border-radius: 0;\n  padding: 16px 0 0 0;\n  margin-top: 16px;\n}\n\n:deep(.interview-display .quotes-header) {\n  font-size: 11px;\n  font-weight: 600;\n  color: #9CA3AF;\n  text-transform: uppercase;\n  letter-spacing: 0.04em;\n  margin-bottom: 12px;\n}\n\n:deep(.interview-display .quotes-list) {\n  display: flex;\n  flex-direction: column;\n  gap: 12px;\n}\n\n:deep(.interview-display .quote-item) {\n  margin: 0;\n  padding: 10px 12px;\n  background: #FFFFFF;\n  border: 1px solid #E5E7EB;\n  border-radius: 6px;\n  font-size: 12px;\n  font-style: italic;\n  color: #4B5563;\n  line-height: 1.5;\n}\n\n/* Summary Section */\n:deep(.interview-display .summary-section) {\n  margin-top: 20px;\n  padding: 16px 0 0 0;\n  background: transparent;\n  border: none;\n  border-top: 1px solid #F3F4F6;\n  border-radius: 0;\n}\n\n:deep(.interview-display .summary-header) {\n  font-size: 11px;\n  font-weight: 600;\n  color: #9CA3AF;\n  text-transform: uppercase;\n  letter-spacing: 0.04em;\n  margin-bottom: 8px;\n}\n\n:deep(.interview-display .summary-content) {\n  font-size: 13px;\n  color: #374151;\n  line-height: 1.6;\n}\n\n/* Markdown styles in summary */\n:deep(.interview-display .summary-content h2),\n:deep(.interview-display .summary-content h3),\n:deep(.interview-display .summary-content h4),\n:deep(.interview-display .summary-content h5) {\n  margin: 12px 0 8px 0;\n  font-weight: 600;\n  color: #111827;\n}\n\n:deep(.interview-display .summary-content h2) {\n  font-size: 15px;\n}\n\n:deep(.interview-display .summary-content h3) {\n  font-size: 14px;\n}\n\n:deep(.interview-display .summary-content h4),\n:deep(.interview-display .summary-content h5) {\n  font-size: 13px;\n}\n\n:deep(.interview-display .summary-content p) {\n  margin: 8px 0;\n}\n\n:deep(.interview-display .summary-content strong) {\n  font-weight: 600;\n  color: #111827;\n}\n\n:deep(.interview-display .summary-content em) {\n  font-style: italic;\n}\n\n:deep(.interview-display .summary-content ul),\n:deep(.interview-display .summary-content ol) {\n  margin: 8px 0;\n  padding-left: 20px;\n}\n\n:deep(.interview-display .summary-content li) {\n  margin: 4px 0;\n}\n\n:deep(.interview-display .summary-content blockquote) {\n  margin: 8px 0;\n  padding-left: 12px;\n  border-left: 3px solid #E5E7EB;\n  color: #6B7280;\n  font-style: italic;\n}\n\n/* Markdown styles in quotes */\n:deep(.interview-display .quote-item strong) {\n  font-weight: 600;\n  color: #374151;\n}\n\n:deep(.interview-display .quote-item em) {\n  font-style: italic;\n}\n\n/* ========== Enhanced Insight Display Styles ========== */\n:deep(.insight-display) {\n  padding: 0;\n}\n\n:deep(.insight-header) {\n  padding: 12px 16px;\n  background: linear-gradient(135deg, #F5F3FF 0%, #EDE9FE 100%);\n  border-radius: 8px 8px 0 0;\n  border: 1px solid #C4B5FD;\n  border-bottom: none;\n}\n\n:deep(.insight-header .header-main) {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  margin-bottom: 8px;\n}\n\n:deep(.insight-header .header-title) {\n  font-size: 14px;\n  font-weight: 700;\n  color: #6D28D9;\n}\n\n:deep(.insight-header .header-stats) {\n  display: flex;\n  align-items: center;\n  gap: 4px;\n  font-size: 11px;\n}\n\n:deep(.insight-header .stat-item) {\n  display: flex;\n  align-items: baseline;\n  gap: 2px;\n}\n\n:deep(.insight-header .stat-value) {\n  font-family: 'JetBrains Mono', monospace;\n  font-weight: 700;\n  color: #7C3AED;\n}\n\n:deep(.insight-header .stat-label) {\n  color: #8B5CF6;\n  font-size: 10px;\n}\n\n:deep(.insight-header .stat-divider) {\n  color: #C4B5FD;\n  margin: 0 4px;\n}\n\n:deep(.insight-header .stat-size) {\n  font-family: 'JetBrains Mono', monospace;\n  font-size: 10px;\n  color: #9CA3AF;\n}\n\n:deep(.insight-header .header-topic) {\n  font-size: 13px;\n  color: #5B21B6;\n  line-height: 1.5;\n}\n\n:deep(.insight-header .header-scenario) {\n  margin-top: 6px;\n  font-size: 11px;\n  color: #7C3AED;\n}\n\n:deep(.insight-header .scenario-label) {\n  font-weight: 600;\n}\n\n:deep(.insight-tabs) {\n  display: flex;\n  gap: 2px;\n  padding: 8px 12px;\n  background: #FAFAFA;\n  border: 1px solid #E5E7EB;\n  border-top: none;\n}\n\n:deep(.insight-tab) {\n  display: flex;\n  align-items: center;\n  gap: 4px;\n  padding: 6px 10px;\n  background: transparent;\n  border: 1px solid transparent;\n  border-radius: 6px;\n  font-size: 11px;\n  font-weight: 500;\n  color: #6B7280;\n  cursor: pointer;\n  transition: all 0.15s ease;\n}\n\n:deep(.insight-tab:hover) {\n  background: #F3F4F6;\n  color: #374151;\n}\n\n:deep(.insight-tab.active) {\n  background: #FFFFFF;\n  color: #7C3AED;\n  border-color: #C4B5FD;\n  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);\n}\n\n\n:deep(.insight-content) {\n  padding: 12px;\n  background: #FFFFFF;\n  border: 1px solid #E5E7EB;\n  border-top: none;\n  border-radius: 0 0 8px 8px;\n}\n\n:deep(.insight-display .panel-header) {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  margin-bottom: 12px;\n  padding-bottom: 8px;\n  border-bottom: 1px solid #F3F4F6;\n}\n\n:deep(.insight-display .panel-title) {\n  font-size: 12px;\n  font-weight: 600;\n  color: #374151;\n}\n\n:deep(.insight-display .panel-count) {\n  font-size: 10px;\n  color: #9CA3AF;\n}\n\n:deep(.insight-display .facts-list),\n:deep(.insight-display .relations-list),\n:deep(.insight-display .subqueries-list) {\n  display: flex;\n  flex-direction: column;\n  gap: 8px;\n}\n\n:deep(.insight-display .entities-grid) {\n  display: flex;\n  flex-wrap: wrap;\n  gap: 6px;\n}\n\n:deep(.insight-display .fact-item) {\n  display: flex;\n  gap: 10px;\n  padding: 10px 12px;\n  background: #F9FAFB;\n  border: 1px solid #E5E7EB;\n  border-radius: 6px;\n}\n\n:deep(.insight-display .fact-number) {\n  flex-shrink: 0;\n  width: 20px;\n  height: 20px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  background: #E5E7EB;\n  border-radius: 50%;\n  font-family: 'JetBrains Mono', monospace;\n  font-size: 10px;\n  font-weight: 700;\n  color: #6B7280;\n}\n\n:deep(.insight-display .fact-content) {\n  flex: 1;\n  font-size: 12px;\n  color: #374151;\n  line-height: 1.6;\n}\n\n/* Entity Tag Styles - Compact multi-column layout */\n:deep(.insight-display .entity-tag) {\n  display: inline-flex;\n  align-items: center;\n  gap: 4px;\n  padding: 4px 8px;\n  background: #F9FAFB;\n  border: 1px solid #E5E7EB;\n  border-radius: 6px;\n  cursor: default;\n  transition: all 0.15s ease;\n}\n\n:deep(.insight-display .entity-tag:hover) {\n  background: #F3F4F6;\n  border-color: #D1D5DB;\n}\n\n:deep(.insight-display .entity-tag .entity-name) {\n  font-size: 12px;\n  font-weight: 500;\n  color: #111827;\n}\n\n:deep(.insight-display .entity-tag .entity-type) {\n  font-size: 9px;\n  color: #7C3AED;\n  background: #EDE9FE;\n  padding: 1px 4px;\n  border-radius: 3px;\n}\n\n:deep(.insight-display .entity-tag .entity-fact-count) {\n  font-size: 9px;\n  color: #9CA3AF;\n  margin-left: 2px;\n}\n\n/* Legacy entity card styles for backwards compatibility */\n:deep(.insight-display .entity-card) {\n  padding: 12px;\n  background: #F9FAFB;\n  border: 1px solid #E5E7EB;\n  border-radius: 8px;\n}\n\n:deep(.insight-display .entity-header) {\n  display: flex;\n  align-items: center;\n  gap: 10px;\n}\n\n:deep(.insight-display .entity-info) {\n  flex: 1;\n}\n\n:deep(.insight-display .entity-card .entity-name) {\n  font-size: 13px;\n  font-weight: 600;\n  color: #111827;\n}\n\n:deep(.insight-display .entity-card .entity-type) {\n  font-size: 10px;\n  color: #7C3AED;\n  background: #EDE9FE;\n  padding: 2px 6px;\n  border-radius: 4px;\n  display: inline-block;\n  margin-top: 2px;\n}\n\n:deep(.insight-display .entity-card .entity-fact-count) {\n  font-size: 10px;\n  color: #9CA3AF;\n  background: #F3F4F6;\n  padding: 2px 6px;\n  border-radius: 4px;\n}\n\n:deep(.insight-display .entity-summary) {\n  margin-top: 8px;\n  padding-top: 8px;\n  border-top: 1px solid #E5E7EB;\n  font-size: 11px;\n  color: #6B7280;\n  line-height: 1.5;\n}\n\n/* Relation Item Styles */\n:deep(.insight-display .relation-item) {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  padding: 10px 12px;\n  background: #F9FAFB;\n  border: 1px solid #E5E7EB;\n  border-radius: 6px;\n}\n\n:deep(.insight-display .rel-source),\n:deep(.insight-display .rel-target) {\n  padding: 4px 8px;\n  background: #FFFFFF;\n  border: 1px solid #D1D5DB;\n  border-radius: 4px;\n  font-size: 11px;\n  font-weight: 500;\n  color: #374151;\n}\n\n:deep(.insight-display .rel-arrow) {\n  display: flex;\n  align-items: center;\n  gap: 4px;\n  flex: 1;\n}\n\n:deep(.insight-display .rel-line) {\n  flex: 1;\n  height: 1px;\n  background: #D1D5DB;\n}\n\n:deep(.insight-display .rel-label) {\n  padding: 2px 6px;\n  background: #EDE9FE;\n  border-radius: 4px;\n  font-size: 10px;\n  font-weight: 500;\n  color: #7C3AED;\n  white-space: nowrap;\n}\n\n/* Sub-query Styles */\n:deep(.insight-display .subquery-item) {\n  display: flex;\n  gap: 10px;\n  padding: 10px 12px;\n  background: #F9FAFB;\n  border: 1px solid #E5E7EB;\n  border-radius: 6px;\n}\n\n:deep(.insight-display .subquery-number) {\n  flex-shrink: 0;\n  padding: 2px 6px;\n  background: #7C3AED;\n  border-radius: 4px;\n  font-family: 'JetBrains Mono', monospace;\n  font-size: 10px;\n  font-weight: 700;\n  color: #FFFFFF;\n}\n\n:deep(.insight-display .subquery-text) {\n  font-size: 12px;\n  color: #374151;\n  line-height: 1.5;\n}\n\n/* Expand Button */\n:deep(.insight-display .expand-btn),\n:deep(.panorama-display .expand-btn),\n:deep(.quick-search-display .expand-btn) {\n  display: block;\n  width: 100%;\n  margin-top: 12px;\n  padding: 8px 12px;\n  background: #F9FAFB;\n  border: 1px solid #E5E7EB;\n  border-radius: 6px;\n  font-size: 11px;\n  font-weight: 500;\n  color: #6B7280;\n  cursor: pointer;\n  transition: all 0.15s ease;\n  text-align: center;\n}\n\n:deep(.insight-display .expand-btn:hover),\n:deep(.panorama-display .expand-btn:hover),\n:deep(.quick-search-display .expand-btn:hover) {\n  background: #F3F4F6;\n  color: #374151;\n  border-color: #D1D5DB;\n}\n\n/* Empty State */\n:deep(.insight-display .empty-state),\n:deep(.panorama-display .empty-state),\n:deep(.quick-search-display .empty-state) {\n  padding: 24px;\n  text-align: center;\n  font-size: 12px;\n  color: #9CA3AF;\n}\n\n/* ========== Enhanced Panorama Display Styles ========== */\n:deep(.panorama-display) {\n  padding: 0;\n}\n\n:deep(.panorama-header) {\n  padding: 12px 16px;\n  background: linear-gradient(135deg, #EFF6FF 0%, #DBEAFE 100%);\n  border-radius: 8px 8px 0 0;\n  border: 1px solid #93C5FD;\n  border-bottom: none;\n}\n\n:deep(.panorama-header .header-main) {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  margin-bottom: 8px;\n}\n\n:deep(.panorama-header .header-title) {\n  font-size: 14px;\n  font-weight: 700;\n  color: #1D4ED8;\n}\n\n:deep(.panorama-header .header-stats) {\n  display: flex;\n  align-items: center;\n  gap: 4px;\n  font-size: 11px;\n}\n\n:deep(.panorama-header .stat-item) {\n  display: flex;\n  align-items: baseline;\n  gap: 2px;\n}\n\n:deep(.panorama-header .stat-value) {\n  font-family: 'JetBrains Mono', monospace;\n  font-weight: 700;\n  color: #2563EB;\n}\n\n:deep(.panorama-header .stat-label) {\n  color: #60A5FA;\n  font-size: 10px;\n}\n\n:deep(.panorama-header .stat-divider) {\n  color: #93C5FD;\n  margin: 0 4px;\n}\n\n:deep(.panorama-header .stat-size) {\n  font-family: 'JetBrains Mono', monospace;\n  font-size: 10px;\n  color: #9CA3AF;\n}\n\n:deep(.panorama-header .header-topic) {\n  font-size: 13px;\n  color: #1E40AF;\n  line-height: 1.5;\n}\n\n:deep(.panorama-tabs) {\n  display: flex;\n  gap: 2px;\n  padding: 8px 12px;\n  background: #FAFAFA;\n  border: 1px solid #E5E7EB;\n  border-top: none;\n}\n\n:deep(.panorama-tab) {\n  display: flex;\n  align-items: center;\n  gap: 4px;\n  padding: 6px 10px;\n  background: transparent;\n  border: 1px solid transparent;\n  border-radius: 6px;\n  font-size: 11px;\n  font-weight: 500;\n  color: #6B7280;\n  cursor: pointer;\n  transition: all 0.15s ease;\n}\n\n:deep(.panorama-tab:hover) {\n  background: #F3F4F6;\n  color: #374151;\n}\n\n:deep(.panorama-tab.active) {\n  background: #FFFFFF;\n  color: #2563EB;\n  border-color: #93C5FD;\n  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);\n}\n\n\n:deep(.panorama-content) {\n  padding: 12px;\n  background: #FFFFFF;\n  border: 1px solid #E5E7EB;\n  border-top: none;\n  border-radius: 0 0 8px 8px;\n}\n\n:deep(.panorama-display .panel-header) {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  margin-bottom: 12px;\n  padding-bottom: 8px;\n  border-bottom: 1px solid #F3F4F6;\n}\n\n:deep(.panorama-display .panel-title) {\n  font-size: 12px;\n  font-weight: 600;\n  color: #374151;\n}\n\n:deep(.panorama-display .panel-count) {\n  font-size: 10px;\n  color: #9CA3AF;\n}\n\n:deep(.panorama-display .facts-list) {\n  display: flex;\n  flex-direction: column;\n  gap: 8px;\n}\n\n:deep(.panorama-display .fact-item) {\n  display: flex;\n  gap: 10px;\n  padding: 10px 12px;\n  background: #F9FAFB;\n  border: 1px solid #E5E7EB;\n  border-radius: 6px;\n}\n\n:deep(.panorama-display .fact-item.active) {\n  background: #F9FAFB;\n  border-color: #E5E7EB;\n}\n\n:deep(.panorama-display .fact-item.historical) {\n  background: #F9FAFB;\n  border-color: #E5E7EB;\n}\n\n:deep(.panorama-display .fact-number) {\n  flex-shrink: 0;\n  width: 20px;\n  height: 20px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  background: #E5E7EB;\n  border-radius: 50%;\n  font-family: 'JetBrains Mono', monospace;\n  font-size: 10px;\n  font-weight: 700;\n  color: #6B7280;\n}\n\n:deep(.panorama-display .fact-item.active .fact-number) {\n  background: #E5E7EB;\n  color: #6B7280;\n}\n\n:deep(.panorama-display .fact-item.historical .fact-number) {\n  background: #9CA3AF;\n  color: #FFFFFF;\n}\n\n:deep(.panorama-display .fact-content) {\n  flex: 1;\n  font-size: 12px;\n  color: #374151;\n  line-height: 1.6;\n}\n\n:deep(.panorama-display .fact-time) {\n  display: block;\n  font-size: 10px;\n  color: #9CA3AF;\n  margin-bottom: 4px;\n  font-family: 'JetBrains Mono', monospace;\n}\n\n:deep(.panorama-display .fact-text) {\n  display: block;\n}\n\n/* Entities Grid */\n:deep(.panorama-display .entities-grid) {\n  display: flex;\n  flex-wrap: wrap;\n  gap: 8px;\n}\n\n:deep(.panorama-display .entity-tag) {\n  display: flex;\n  align-items: center;\n  gap: 6px;\n  padding: 6px 10px;\n  background: #F9FAFB;\n  border: 1px solid #E5E7EB;\n  border-radius: 6px;\n}\n\n:deep(.panorama-display .entity-name) {\n  font-size: 12px;\n  font-weight: 500;\n  color: #374151;\n}\n\n:deep(.panorama-display .entity-type) {\n  font-size: 10px;\n  color: #2563EB;\n  background: #DBEAFE;\n  padding: 2px 6px;\n  border-radius: 4px;\n}\n\n/* ========== Enhanced Quick Search Display Styles ========== */\n:deep(.quick-search-display) {\n  padding: 0;\n}\n\n:deep(.quicksearch-header) {\n  padding: 12px 16px;\n  background: linear-gradient(135deg, #FFF7ED 0%, #FFEDD5 100%);\n  border-radius: 8px 8px 0 0;\n  border: 1px solid #FDBA74;\n  border-bottom: none;\n}\n\n:deep(.quicksearch-header .header-main) {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  margin-bottom: 8px;\n}\n\n:deep(.quicksearch-header .header-title) {\n  font-size: 14px;\n  font-weight: 700;\n  color: #C2410C;\n}\n\n:deep(.quicksearch-header .header-stats) {\n  display: flex;\n  align-items: center;\n  gap: 4px;\n  font-size: 11px;\n}\n\n:deep(.quicksearch-header .stat-item) {\n  display: flex;\n  align-items: baseline;\n  gap: 2px;\n}\n\n:deep(.quicksearch-header .stat-value) {\n  font-family: 'JetBrains Mono', monospace;\n  font-weight: 700;\n  color: #EA580C;\n}\n\n:deep(.quicksearch-header .stat-label) {\n  color: #FB923C;\n  font-size: 10px;\n}\n\n:deep(.quicksearch-header .stat-divider) {\n  color: #FDBA74;\n  margin: 0 4px;\n}\n\n:deep(.quicksearch-header .stat-size) {\n  font-family: 'JetBrains Mono', monospace;\n  font-size: 10px;\n  color: #9CA3AF;\n}\n\n:deep(.quicksearch-header .header-query) {\n  font-size: 13px;\n  color: #9A3412;\n  line-height: 1.5;\n}\n\n:deep(.quicksearch-header .query-label) {\n  font-weight: 600;\n}\n\n:deep(.quicksearch-tabs) {\n  display: flex;\n  gap: 2px;\n  padding: 8px 12px;\n  background: #FAFAFA;\n  border: 1px solid #E5E7EB;\n  border-top: none;\n}\n\n:deep(.quicksearch-tab) {\n  display: flex;\n  align-items: center;\n  gap: 4px;\n  padding: 6px 10px;\n  background: transparent;\n  border: 1px solid transparent;\n  border-radius: 6px;\n  font-size: 11px;\n  font-weight: 500;\n  color: #6B7280;\n  cursor: pointer;\n  transition: all 0.15s ease;\n}\n\n:deep(.quicksearch-tab:hover) {\n  background: #F3F4F6;\n  color: #374151;\n}\n\n:deep(.quicksearch-tab.active) {\n  background: #FFFFFF;\n  color: #EA580C;\n  border-color: #FDBA74;\n  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);\n}\n\n\n:deep(.quicksearch-content) {\n  padding: 12px;\n  background: #FFFFFF;\n  border: 1px solid #E5E7EB;\n  border-top: none;\n  border-radius: 0 0 8px 8px;\n}\n\n/* When there are no tabs, content connects directly to header */\n:deep(.quicksearch-content.no-tabs) {\n  border-top: none;\n}\n\n:deep(.quick-search-display .panel-header) {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  margin-bottom: 12px;\n  padding-bottom: 8px;\n  border-bottom: 1px solid #F3F4F6;\n}\n\n:deep(.quick-search-display .panel-title) {\n  font-size: 12px;\n  font-weight: 600;\n  color: #374151;\n}\n\n:deep(.quick-search-display .panel-count) {\n  font-size: 10px;\n  color: #9CA3AF;\n}\n\n:deep(.quick-search-display .facts-list) {\n  display: flex;\n  flex-direction: column;\n  gap: 8px;\n}\n\n:deep(.quick-search-display .fact-item) {\n  display: flex;\n  gap: 10px;\n  padding: 10px 12px;\n  background: #F9FAFB;\n  border: 1px solid #E5E7EB;\n  border-radius: 6px;\n}\n\n:deep(.quick-search-display .fact-item.active) {\n  background: #F9FAFB;\n  border-color: #E5E7EB;\n}\n\n:deep(.quick-search-display .fact-number) {\n  flex-shrink: 0;\n  width: 20px;\n  height: 20px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  background: #E5E7EB;\n  border-radius: 50%;\n  font-family: 'JetBrains Mono', monospace;\n  font-size: 10px;\n  font-weight: 700;\n  color: #6B7280;\n}\n\n:deep(.quick-search-display .fact-item.active .fact-number) {\n  background: #E5E7EB;\n  color: #6B7280;\n}\n\n:deep(.quick-search-display .fact-content) {\n  flex: 1;\n  font-size: 12px;\n  color: #374151;\n  line-height: 1.6;\n}\n\n/* Edges Panel */\n:deep(.quick-search-display .edges-list) {\n  display: flex;\n  flex-direction: column;\n  gap: 8px;\n}\n\n:deep(.quick-search-display .edge-item) {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  padding: 10px 12px;\n  background: #F9FAFB;\n  border: 1px solid #E5E7EB;\n  border-radius: 6px;\n}\n\n:deep(.quick-search-display .edge-source),\n:deep(.quick-search-display .edge-target) {\n  padding: 4px 8px;\n  background: #FFFFFF;\n  border: 1px solid #D1D5DB;\n  border-radius: 4px;\n  font-size: 11px;\n  font-weight: 500;\n  color: #374151;\n}\n\n:deep(.quick-search-display .edge-arrow) {\n  display: flex;\n  align-items: center;\n  gap: 4px;\n  flex: 1;\n}\n\n:deep(.quick-search-display .edge-line) {\n  flex: 1;\n  height: 1px;\n  background: #D1D5DB;\n}\n\n:deep(.quick-search-display .edge-label) {\n  padding: 2px 6px;\n  background: #FFEDD5;\n  border-radius: 4px;\n  font-size: 10px;\n  font-weight: 500;\n  color: #C2410C;\n  white-space: nowrap;\n}\n\n/* Nodes Grid */\n:deep(.quick-search-display .nodes-grid) {\n  display: flex;\n  flex-wrap: wrap;\n  gap: 8px;\n}\n\n:deep(.quick-search-display .node-tag) {\n  display: flex;\n  align-items: center;\n  gap: 6px;\n  padding: 6px 10px;\n  background: #F9FAFB;\n  border: 1px solid #E5E7EB;\n  border-radius: 6px;\n}\n\n:deep(.quick-search-display .node-name) {\n  font-size: 12px;\n  font-weight: 500;\n  color: #374151;\n}\n\n:deep(.quick-search-display .node-type) {\n  font-size: 10px;\n  color: #EA580C;\n  background: #FFEDD5;\n  padding: 2px 6px;\n  border-radius: 4px;\n}\n\n/* Console Logs - 与 Step3Simulation.vue 保持一致 */\n.console-logs {\n  background: #000;\n  color: #DDD;\n  padding: 16px;\n  font-family: 'JetBrains Mono', monospace;\n  border-top: 1px solid #222;\n  flex-shrink: 0;\n}\n\n.log-header {\n  display: flex;\n  justify-content: space-between;\n  border-bottom: 1px solid #333;\n  padding-bottom: 8px;\n  margin-bottom: 8px;\n  font-size: 10px;\n  color: #666;\n}\n\n.log-title {\n  text-transform: uppercase;\n  letter-spacing: 0.1em;\n}\n\n.log-content {\n  display: flex;\n  flex-direction: column;\n  gap: 4px;\n  height: 100px;\n  overflow-y: auto;\n  padding-right: 4px;\n}\n\n.log-content::-webkit-scrollbar { width: 4px; }\n.log-content::-webkit-scrollbar-thumb { background: #333; border-radius: 2px; }\n\n.log-line {\n  font-size: 11px;\n  line-height: 1.5;\n}\n\n.log-msg {\n  color: #BBB;\n  word-break: break-all;\n}\n\n.log-msg.error { color: #EF5350; }\n.log-msg.warning { color: #FFA726; }\n.log-msg.success { color: #66BB6A; }\n</style>\n"
  },
  {
    "path": "frontend/src/components/Step5Interaction.vue",
    "content": "<template>\n  <div class=\"interaction-panel\">\n    <!-- Main Split Layout -->\n    <div class=\"main-split-layout\">\n      <!-- LEFT PANEL: Report Style -->\n      <div class=\"left-panel report-style\" ref=\"leftPanel\">\n        <div v-if=\"reportOutline\" class=\"report-content-wrapper\">\n          <!-- Report Header -->\n          <div class=\"report-header-block\">\n            <div class=\"report-meta\">\n              <span class=\"report-tag\">Prediction Report</span>\n              <span class=\"report-id\">ID: {{ reportId || 'REF-2024-X92' }}</span>\n            </div>\n            <h1 class=\"main-title\">{{ reportOutline.title }}</h1>\n            <p class=\"sub-title\">{{ reportOutline.summary }}</p>\n            <div class=\"header-divider\"></div>\n          </div>\n\n          <!-- Sections List -->\n          <div class=\"sections-list\">\n            <div \n              v-for=\"(section, idx) in reportOutline.sections\" \n              :key=\"idx\"\n              class=\"report-section-item\"\n              :class=\"{ \n                'is-active': currentSectionIndex === idx + 1,\n                'is-completed': isSectionCompleted(idx + 1),\n                'is-pending': !isSectionCompleted(idx + 1) && currentSectionIndex !== idx + 1\n              }\"\n            >\n              <div class=\"section-header-row\" @click=\"toggleSectionCollapse(idx)\" :class=\"{ 'clickable': isSectionCompleted(idx + 1) }\">\n                <span class=\"section-number\">{{ String(idx + 1).padStart(2, '0') }}</span>\n                <h3 class=\"section-title\">{{ section.title }}</h3>\n                <svg \n                  v-if=\"isSectionCompleted(idx + 1)\" \n                  class=\"collapse-icon\" \n                  :class=\"{ 'is-collapsed': collapsedSections.has(idx) }\"\n                  viewBox=\"0 0 24 24\" \n                  width=\"20\" \n                  height=\"20\" \n                  fill=\"none\" \n                  stroke=\"currentColor\" \n                  stroke-width=\"2\"\n                >\n                  <polyline points=\"6 9 12 15 18 9\"></polyline>\n                </svg>\n              </div>\n              \n              <div class=\"section-body\" v-show=\"!collapsedSections.has(idx)\">\n                <!-- Completed Content -->\n                <div v-if=\"generatedSections[idx + 1]\" class=\"generated-content\" v-html=\"renderMarkdown(generatedSections[idx + 1])\"></div>\n                \n                <!-- Loading State -->\n                <div v-else-if=\"currentSectionIndex === idx + 1\" class=\"loading-state\">\n                  <div class=\"loading-icon\">\n                    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\">\n                      <circle cx=\"12\" cy=\"12\" r=\"10\" stroke-width=\"4\" stroke=\"#E5E7EB\"></circle>\n                      <path d=\"M12 2a10 10 0 0 1 10 10\" stroke-width=\"4\" stroke=\"#4B5563\" stroke-linecap=\"round\"></path>\n                    </svg>\n                  </div>\n                  <span class=\"loading-text\">正在生成{{ section.title }}...</span>\n                </div>\n              </div>\n            </div>\n          </div>\n        </div>\n\n        <!-- Waiting State -->\n        <div v-if=\"!reportOutline\" class=\"waiting-placeholder\">\n          <div class=\"waiting-animation\">\n            <div class=\"waiting-ring\"></div>\n            <div class=\"waiting-ring\"></div>\n            <div class=\"waiting-ring\"></div>\n          </div>\n          <span class=\"waiting-text\">Waiting for Report Agent...</span>\n        </div>\n      </div>\n\n      <!-- RIGHT PANEL: Interaction Interface -->\n      <div class=\"right-panel\" ref=\"rightPanel\">\n        <!-- Unified Action Bar - Professional Design -->\n        <div class=\"action-bar\">\n        <div class=\"action-bar-header\">\n          <svg class=\"action-bar-icon\" viewBox=\"0 0 24 24\" width=\"28\" height=\"28\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\">\n            <path d=\"M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z\"></path>\n          </svg>\n          <div class=\"action-bar-text\">\n            <span class=\"action-bar-title\">Interactive Tools</span>\n            <span class=\"action-bar-subtitle mono\">{{ profiles.length }} agents available</span>\n          </div>\n        </div>\n          <div class=\"action-bar-tabs\">\n            <button \n              class=\"tab-pill\"\n              :class=\"{ active: activeTab === 'chat' && chatTarget === 'report_agent' }\"\n              @click=\"selectReportAgentChat\"\n            >\n              <svg viewBox=\"0 0 24 24\" width=\"14\" height=\"14\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n                <path d=\"M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z\"></path>\n              </svg>\n              <span>与Report Agent对话</span>\n            </button>\n            <div class=\"agent-dropdown\" v-if=\"profiles.length > 0\">\n              <button \n                class=\"tab-pill agent-pill\"\n                :class=\"{ active: activeTab === 'chat' && chatTarget === 'agent' }\"\n                @click=\"toggleAgentDropdown\"\n              >\n                <svg viewBox=\"0 0 24 24\" width=\"14\" height=\"14\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n                  <path d=\"M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2\"></path>\n                  <circle cx=\"12\" cy=\"7\" r=\"4\"></circle>\n                </svg>\n                <span>{{ selectedAgent ? selectedAgent.username : '与世界中任意个体对话' }}</span>\n                <svg class=\"dropdown-arrow\" :class=\"{ open: showAgentDropdown }\" viewBox=\"0 0 24 24\" width=\"12\" height=\"12\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n                  <polyline points=\"6 9 12 15 18 9\"></polyline>\n                </svg>\n              </button>\n              <div v-if=\"showAgentDropdown\" class=\"dropdown-menu\">\n                <div class=\"dropdown-header\">选择对话对象</div>\n                <div \n                  v-for=\"(agent, idx) in profiles\" \n                  :key=\"idx\"\n                  class=\"dropdown-item\"\n                  @click=\"selectAgent(agent, idx)\"\n                >\n                  <div class=\"agent-avatar\">{{ (agent.username || 'A')[0] }}</div>\n                  <div class=\"agent-info\">\n                    <span class=\"agent-name\">{{ agent.username }}</span>\n                    <span class=\"agent-role\">{{ agent.profession || '未知职业' }}</span>\n                  </div>\n                </div>\n              </div>\n            </div>\n            <div class=\"tab-divider\"></div>\n            <button \n              class=\"tab-pill survey-pill\"\n              :class=\"{ active: activeTab === 'survey' }\"\n              @click=\"selectSurveyTab\"\n            >\n              <svg viewBox=\"0 0 24 24\" width=\"14\" height=\"14\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n                <path d=\"M9 11l3 3L22 4\"></path>\n                <path d=\"M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11\"></path>\n              </svg>\n              <span>发送问卷调查到世界中</span>\n            </button>\n          </div>\n        </div>\n\n        <!-- Chat Mode -->\n        <div v-if=\"activeTab === 'chat'\" class=\"chat-container\">\n\n          <!-- Report Agent Tools Card -->\n          <div v-if=\"chatTarget === 'report_agent'\" class=\"report-agent-tools-card\">\n            <div class=\"tools-card-header\">\n              <div class=\"tools-card-avatar\">R</div>\n              <div class=\"tools-card-info\">\n                <div class=\"tools-card-name\">Report Agent - Chat</div>\n                <div class=\"tools-card-subtitle\">报告生成智能体的快速对话版本，可调用 4 种专业工具，拥有MiroFish的完整记忆</div>\n              </div>\n              <button class=\"tools-card-toggle\" @click=\"showToolsDetail = !showToolsDetail\">\n                <svg :class=\"{ 'is-expanded': showToolsDetail }\" viewBox=\"0 0 24 24\" width=\"16\" height=\"16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n                  <polyline points=\"6 9 12 15 18 9\"></polyline>\n                </svg>\n              </button>\n            </div>\n            <div v-if=\"showToolsDetail\" class=\"tools-card-body\">\n              <div class=\"tools-grid\">\n                <div class=\"tool-item tool-purple\">\n                  <div class=\"tool-icon-wrapper\">\n                    <svg viewBox=\"0 0 24 24\" width=\"16\" height=\"16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n                      <path d=\"M9 18h6M10 22h4M12 2a7 7 0 0 0-4 12.5V17a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1v-2.5A7 7 0 0 0 12 2z\"></path>\n                    </svg>\n                  </div>\n                  <div class=\"tool-content\">\n                    <div class=\"tool-name\">InsightForge 深度归因</div>\n                    <div class=\"tool-desc\">对齐现实世界种子数据与模拟环境状态，结合Global/Local Memory机制，提供跨时空的深度归因分析</div>\n                  </div>\n                </div>\n                <div class=\"tool-item tool-blue\">\n                  <div class=\"tool-icon-wrapper\">\n                    <svg viewBox=\"0 0 24 24\" width=\"16\" height=\"16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n                      <circle cx=\"12\" cy=\"12\" r=\"10\"></circle>\n                      <path d=\"M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z\"></path>\n                    </svg>\n                  </div>\n                  <div class=\"tool-content\">\n                    <div class=\"tool-name\">PanoramaSearch 全景追踪</div>\n                    <div class=\"tool-desc\">基于图结构的广度遍历算法，重构事件传播路径，捕获全量信息流动的拓扑结构</div>\n                  </div>\n                </div>\n                <div class=\"tool-item tool-orange\">\n                  <div class=\"tool-icon-wrapper\">\n                    <svg viewBox=\"0 0 24 24\" width=\"16\" height=\"16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n                      <polygon points=\"13 2 3 14 12 14 11 22 21 10 12 10 13 2\"></polygon>\n                    </svg>\n                  </div>\n                  <div class=\"tool-content\">\n                    <div class=\"tool-name\">QuickSearch 快速检索</div>\n                    <div class=\"tool-desc\">基于 GraphRAG 的即时查询接口，优化索引效率，用于快速提取具体的节点属性与离散事实</div>\n                  </div>\n                </div>\n                <div class=\"tool-item tool-green\">\n                  <div class=\"tool-icon-wrapper\">\n                    <svg viewBox=\"0 0 24 24\" width=\"16\" height=\"16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n                      <path d=\"M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2\"></path>\n                      <circle cx=\"9\" cy=\"7\" r=\"4\"></circle>\n                      <path d=\"M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75\"></path>\n                    </svg>\n                  </div>\n                  <div class=\"tool-content\">\n                    <div class=\"tool-name\">InterviewSubAgent 虚拟访谈</div>\n                    <div class=\"tool-desc\">自主式访谈，能够并行与模拟世界中个体进行多轮对话，采集非结构化的观点数据与心理状态</div>\n                  </div>\n                </div>\n              </div>\n            </div>\n          </div>\n\n          <!-- Agent Profile Card -->\n          <div v-if=\"chatTarget === 'agent' && selectedAgent\" class=\"agent-profile-card\">\n            <div class=\"profile-card-header\">\n              <div class=\"profile-card-avatar\">{{ (selectedAgent.username || 'A')[0] }}</div>\n              <div class=\"profile-card-info\">\n                <div class=\"profile-card-name\">{{ selectedAgent.username }}</div>\n                <div class=\"profile-card-meta\">\n                  <span v-if=\"selectedAgent.name\" class=\"profile-card-handle\">@{{ selectedAgent.name }}</span>\n                  <span class=\"profile-card-profession\">{{ selectedAgent.profession || '未知职业' }}</span>\n                </div>\n              </div>\n              <button class=\"profile-card-toggle\" @click=\"showFullProfile = !showFullProfile\">\n                <svg :class=\"{ 'is-expanded': showFullProfile }\" viewBox=\"0 0 24 24\" width=\"16\" height=\"16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n                  <polyline points=\"6 9 12 15 18 9\"></polyline>\n                </svg>\n              </button>\n            </div>\n            <div v-if=\"showFullProfile && selectedAgent.bio\" class=\"profile-card-body\">\n              <div class=\"profile-card-bio\">\n                <div class=\"profile-card-label\">简介</div>\n                <p>{{ selectedAgent.bio }}</p>\n              </div>\n            </div>\n          </div>\n\n          <!-- Chat Messages -->\n          <div class=\"chat-messages\" ref=\"chatMessages\">\n            <div v-if=\"chatHistory.length === 0\" class=\"chat-empty\">\n              <div class=\"empty-icon\">\n                <svg viewBox=\"0 0 24 24\" width=\"48\" height=\"48\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\">\n                  <path d=\"M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z\"></path>\n                </svg>\n              </div>\n              <p class=\"empty-text\">\n                {{ chatTarget === 'report_agent' ? '与 Report Agent 对话，深入了解报告内容' : '与模拟个体对话，了解他们的观点' }}\n              </p>\n            </div>\n            <div \n              v-for=\"(msg, idx) in chatHistory\" \n              :key=\"idx\"\n              class=\"chat-message\"\n              :class=\"msg.role\"\n            >\n              <div class=\"message-avatar\">\n                <span v-if=\"msg.role === 'user'\">U</span>\n                <span v-else>{{ msg.role === 'assistant' && chatTarget === 'report_agent' ? 'R' : (selectedAgent?.username?.[0] || 'A') }}</span>\n              </div>\n              <div class=\"message-content\">\n                <div class=\"message-header\">\n                  <span class=\"sender-name\">\n                    {{ msg.role === 'user' ? 'You' : (chatTarget === 'report_agent' ? 'Report Agent' : (selectedAgent?.username || 'Agent')) }}\n                  </span>\n                  <span class=\"message-time\">{{ formatTime(msg.timestamp) }}</span>\n                </div>\n                <div class=\"message-text\" v-html=\"renderMarkdown(msg.content)\"></div>\n              </div>\n            </div>\n            <div v-if=\"isSending\" class=\"chat-message assistant\">\n              <div class=\"message-avatar\">\n                <span>{{ chatTarget === 'report_agent' ? 'R' : (selectedAgent?.username?.[0] || 'A') }}</span>\n              </div>\n              <div class=\"message-content\">\n                <div class=\"typing-indicator\">\n                  <span></span>\n                  <span></span>\n                  <span></span>\n                </div>\n              </div>\n            </div>\n          </div>\n\n          <!-- Chat Input -->\n          <div class=\"chat-input-area\">\n            <textarea \n              v-model=\"chatInput\"\n              class=\"chat-input\"\n              placeholder=\"输入您的问题...\"\n              @keydown.enter.exact.prevent=\"sendMessage\"\n              :disabled=\"isSending || (!selectedAgent && chatTarget === 'agent')\"\n              rows=\"1\"\n              ref=\"chatInputRef\"\n            ></textarea>\n            <button \n              class=\"send-btn\"\n              @click=\"sendMessage\"\n              :disabled=\"!chatInput.trim() || isSending || (!selectedAgent && chatTarget === 'agent')\"\n            >\n              <svg viewBox=\"0 0 24 24\" width=\"18\" height=\"18\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n                <line x1=\"22\" y1=\"2\" x2=\"11\" y2=\"13\"></line>\n                <polygon points=\"22 2 15 22 11 13 2 9 22 2\"></polygon>\n              </svg>\n            </button>\n          </div>\n        </div>\n\n        <!-- Survey Mode -->\n        <div v-if=\"activeTab === 'survey'\" class=\"survey-container\">\n          <!-- Survey Setup -->\n          <div class=\"survey-setup\">\n            <div class=\"setup-section\">\n              <div class=\"section-header\">\n                <span class=\"section-title\">选择调查对象</span>\n                <span class=\"selection-count\">已选 {{ selectedAgents.size }} / {{ profiles.length }}</span>\n              </div>\n              <div class=\"agents-grid\">\n                <label \n                  v-for=\"(agent, idx) in profiles\" \n                  :key=\"idx\"\n                  class=\"agent-checkbox\"\n                  :class=\"{ checked: selectedAgents.has(idx) }\"\n                >\n                  <input \n                    type=\"checkbox\" \n                    :checked=\"selectedAgents.has(idx)\"\n                    @change=\"toggleAgentSelection(idx)\"\n                  >\n                  <div class=\"checkbox-avatar\">{{ (agent.username || 'A')[0] }}</div>\n                  <div class=\"checkbox-info\">\n                    <span class=\"checkbox-name\">{{ agent.username }}</span>\n                    <span class=\"checkbox-role\">{{ agent.profession || '未知职业' }}</span>\n                  </div>\n                  <div class=\"checkbox-indicator\">\n                    <svg viewBox=\"0 0 24 24\" width=\"16\" height=\"16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"3\">\n                      <polyline points=\"20 6 9 17 4 12\"></polyline>\n                    </svg>\n                  </div>\n                </label>\n              </div>\n              <div class=\"selection-actions\">\n                <button class=\"action-link\" @click=\"selectAllAgents\">全选</button>\n                <span class=\"action-divider\">|</span>\n                <button class=\"action-link\" @click=\"clearAgentSelection\">清空</button>\n              </div>\n            </div>\n\n            <div class=\"setup-section\">\n              <div class=\"section-header\">\n                <span class=\"section-title\">问卷问题</span>\n              </div>\n              <textarea \n                v-model=\"surveyQuestion\"\n                class=\"survey-input\"\n                placeholder=\"输入您想问所有被选中对象的问题...\"\n                rows=\"3\"\n              ></textarea>\n            </div>\n\n            <button \n              class=\"survey-submit-btn\"\n              :disabled=\"selectedAgents.size === 0 || !surveyQuestion.trim() || isSurveying\"\n              @click=\"submitSurvey\"\n            >\n              <span v-if=\"isSurveying\" class=\"loading-spinner\"></span>\n              <span v-else>发送问卷</span>\n            </button>\n          </div>\n\n          <!-- Survey Results -->\n          <div v-if=\"surveyResults.length > 0\" class=\"survey-results\">\n            <div class=\"results-header\">\n              <span class=\"results-title\">调查结果</span>\n              <span class=\"results-count\">{{ surveyResults.length }} 条回复</span>\n            </div>\n            <div class=\"results-list\">\n              <div \n                v-for=\"(result, idx) in surveyResults\" \n                :key=\"idx\"\n                class=\"result-card\"\n              >\n                <div class=\"result-header\">\n                  <div class=\"result-avatar\">{{ (result.agent_name || 'A')[0] }}</div>\n                  <div class=\"result-info\">\n                    <span class=\"result-name\">{{ result.agent_name }}</span>\n                    <span class=\"result-role\">{{ result.profession || '未知职业' }}</span>\n                  </div>\n                </div>\n                <div class=\"result-question\">\n                  <svg viewBox=\"0 0 24 24\" width=\"14\" height=\"14\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n                    <circle cx=\"12\" cy=\"12\" r=\"10\"></circle>\n                    <path d=\"M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3\"></path>\n                    <line x1=\"12\" y1=\"17\" x2=\"12.01\" y2=\"17\"></line>\n                  </svg>\n                  <span>{{ result.question }}</span>\n                </div>\n                <div class=\"result-answer\" v-html=\"renderMarkdown(result.answer)\"></div>\n              </div>\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'\nimport { chatWithReport, getReport, getAgentLog } from '../api/report'\nimport { interviewAgents, getSimulationProfilesRealtime } from '../api/simulation'\n\nconst props = defineProps({\n  reportId: String,\n  simulationId: String\n})\n\nconst emit = defineEmits(['add-log', 'update-status'])\n\n// State\nconst activeTab = ref('chat')\nconst chatTarget = ref('report_agent')\nconst showAgentDropdown = ref(false)\nconst selectedAgent = ref(null)\nconst selectedAgentIndex = ref(null)\nconst showFullProfile = ref(true)\nconst showToolsDetail = ref(true)\n\n// Chat State\nconst chatInput = ref('')\nconst chatHistory = ref([])\nconst chatHistoryCache = ref({}) // 缓存所有对话记录: { 'report_agent': [], 'agent_0': [], 'agent_1': [], ... }\nconst isSending = ref(false)\nconst chatMessages = ref(null)\nconst chatInputRef = ref(null)\n\n// Survey State\nconst selectedAgents = ref(new Set())\nconst surveyQuestion = ref('')\nconst surveyResults = ref([])\nconst isSurveying = ref(false)\n\n// Report Data\nconst reportOutline = ref(null)\nconst generatedSections = ref({})\nconst collapsedSections = ref(new Set())\nconst currentSectionIndex = ref(null)\nconst profiles = ref([])\n\n// Helper Methods\nconst isSectionCompleted = (sectionIndex) => {\n  return !!generatedSections.value[sectionIndex]\n}\n\n// Refs\nconst leftPanel = ref(null)\nconst rightPanel = ref(null)\n\n// Methods\nconst addLog = (msg) => {\n  emit('add-log', msg)\n}\n\nconst toggleSectionCollapse = (idx) => {\n  if (!generatedSections.value[idx + 1]) return\n  const newSet = new Set(collapsedSections.value)\n  if (newSet.has(idx)) {\n    newSet.delete(idx)\n  } else {\n    newSet.add(idx)\n  }\n  collapsedSections.value = newSet\n}\n\nconst selectChatTarget = (target) => {\n  chatTarget.value = target\n  if (target === 'report_agent') {\n    showAgentDropdown.value = false\n  }\n}\n\n// 保存当前对话记录到缓存\nconst saveChatHistory = () => {\n  if (chatHistory.value.length === 0) return\n  \n  if (chatTarget.value === 'report_agent') {\n    chatHistoryCache.value['report_agent'] = [...chatHistory.value]\n  } else if (selectedAgentIndex.value !== null) {\n    chatHistoryCache.value[`agent_${selectedAgentIndex.value}`] = [...chatHistory.value]\n  }\n}\n\nconst selectReportAgentChat = () => {\n  // 保存当前对话记录\n  saveChatHistory()\n  \n  activeTab.value = 'chat'\n  chatTarget.value = 'report_agent'\n  selectedAgent.value = null\n  selectedAgentIndex.value = null\n  showAgentDropdown.value = false\n  \n  // 恢复 Report Agent 的对话记录\n  chatHistory.value = chatHistoryCache.value['report_agent'] || []\n}\n\nconst selectSurveyTab = () => {\n  activeTab.value = 'survey'\n  selectedAgent.value = null\n  selectedAgentIndex.value = null\n  showAgentDropdown.value = false\n}\n\nconst toggleAgentDropdown = () => {\n  showAgentDropdown.value = !showAgentDropdown.value\n  if (showAgentDropdown.value) {\n    activeTab.value = 'chat'\n    chatTarget.value = 'agent'\n  }\n}\n\nconst selectAgent = (agent, idx) => {\n  // 保存当前对话记录\n  saveChatHistory()\n  \n  selectedAgent.value = agent\n  selectedAgentIndex.value = idx\n  chatTarget.value = 'agent'\n  showAgentDropdown.value = false\n  \n  // 恢复该 Agent 的对话记录\n  chatHistory.value = chatHistoryCache.value[`agent_${idx}`] || []\n  addLog(`选择对话对象: ${agent.username}`)\n}\n\nconst formatTime = (timestamp) => {\n  if (!timestamp) return ''\n  try {\n    return new Date(timestamp).toLocaleTimeString('en-US', { \n      hour12: false, \n      hour: '2-digit', \n      minute: '2-digit'\n    })\n  } catch {\n    return ''\n  }\n}\n\nconst renderMarkdown = (content) => {\n  if (!content) return ''\n  \n  let processedContent = content.replace(/^##\\s+.+\\n+/, '')\n  let html = processedContent.replace(/```(\\w*)\\n([\\s\\S]*?)```/g, '<pre class=\"code-block\"><code>$2</code></pre>')\n  html = html.replace(/`([^`]+)`/g, '<code class=\"inline-code\">$1</code>')\n  html = html.replace(/^#### (.+)$/gm, '<h5 class=\"md-h5\">$1</h5>')\n  html = html.replace(/^### (.+)$/gm, '<h4 class=\"md-h4\">$1</h4>')\n  html = html.replace(/^## (.+)$/gm, '<h3 class=\"md-h3\">$1</h3>')\n  html = html.replace(/^# (.+)$/gm, '<h2 class=\"md-h2\">$1</h2>')\n  html = html.replace(/^> (.+)$/gm, '<blockquote class=\"md-quote\">$1</blockquote>')\n  \n  // 处理列表 - 支持子列表\n  html = html.replace(/^(\\s*)- (.+)$/gm, (match, indent, text) => {\n    const level = Math.floor(indent.length / 2)\n    return `<li class=\"md-li\" data-level=\"${level}\">${text}</li>`\n  })\n  html = html.replace(/^(\\s*)(\\d+)\\. (.+)$/gm, (match, indent, num, text) => {\n    const level = Math.floor(indent.length / 2)\n    return `<li class=\"md-oli\" data-level=\"${level}\">${text}</li>`\n  })\n  \n  // 包装无序列表\n  html = html.replace(/(<li class=\"md-li\"[^>]*>.*?<\\/li>\\s*)+/g, '<ul class=\"md-ul\">$&</ul>')\n  // 包装有序列表\n  html = html.replace(/(<li class=\"md-oli\"[^>]*>.*?<\\/li>\\s*)+/g, '<ol class=\"md-ol\">$&</ol>')\n  \n  // 清理列表项之间的所有空白\n  html = html.replace(/<\\/li>\\s+<li/g, '</li><li')\n  // 清理列表开始标签后的空白\n  html = html.replace(/<ul class=\"md-ul\">\\s+/g, '<ul class=\"md-ul\">')\n  html = html.replace(/<ol class=\"md-ol\">\\s+/g, '<ol class=\"md-ol\">')\n  // 清理列表结束标签前的空白\n  html = html.replace(/\\s+<\\/ul>/g, '</ul>')\n  html = html.replace(/\\s+<\\/ol>/g, '</ol>')\n  \n  html = html.replace(/\\*\\*(.+?)\\*\\*/g, '<strong>$1</strong>')\n  html = html.replace(/\\*(.+?)\\*/g, '<em>$1</em>')\n  html = html.replace(/_(.+?)_/g, '<em>$1</em>')\n  html = html.replace(/^---$/gm, '<hr class=\"md-hr\">')\n  html = html.replace(/\\n\\n/g, '</p><p class=\"md-p\">')\n  html = html.replace(/\\n/g, '<br>')\n  html = '<p class=\"md-p\">' + html + '</p>'\n  html = html.replace(/<p class=\"md-p\"><\\/p>/g, '')\n  html = html.replace(/<p class=\"md-p\">(<h[2-5])/g, '$1')\n  html = html.replace(/(<\\/h[2-5]>)<\\/p>/g, '$1')\n  html = html.replace(/<p class=\"md-p\">(<ul|<ol|<blockquote|<pre|<hr)/g, '$1')\n  html = html.replace(/(<\\/ul>|<\\/ol>|<\\/blockquote>|<\\/pre>)<\\/p>/g, '$1')\n  // 清理块级元素前后的 <br> 标签\n  html = html.replace(/<br>\\s*(<ul|<ol|<blockquote)/g, '$1')\n  html = html.replace(/(<\\/ul>|<\\/ol>|<\\/blockquote>)\\s*<br>/g, '$1')\n  // 清理 <p><br> 紧跟块级元素的情况（多余空行导致）\n  html = html.replace(/<p class=\"md-p\">(<br>\\s*)+(<ul|<ol|<blockquote|<pre|<hr)/g, '$2')\n  // 清理连续的 <br> 标签\n  html = html.replace(/(<br>\\s*){2,}/g, '<br>')\n  // 清理块级元素后紧跟的段落开始标签前的 <br>\n  html = html.replace(/(<\\/ol>|<\\/ul>|<\\/blockquote>)<br>(<p|<div)/g, '$1$2')\n\n  // 修复非连续有序列表的编号：当单项 <ol> 被段落内容隔开时，保持编号递增\n  const tokens = html.split(/(<ol class=\"md-ol\">(?:<li class=\"md-oli\"[^>]*>[\\s\\S]*?<\\/li>)+<\\/ol>)/g)\n  let olCounter = 0\n  let inSequence = false\n  for (let i = 0; i < tokens.length; i++) {\n    if (tokens[i].startsWith('<ol class=\"md-ol\">')) {\n      const liCount = (tokens[i].match(/<li class=\"md-oli\"/g) || []).length\n      if (liCount === 1) {\n        olCounter++\n        if (olCounter > 1) {\n          tokens[i] = tokens[i].replace('<ol class=\"md-ol\">', `<ol class=\"md-ol\" start=\"${olCounter}\">`)\n        }\n        inSequence = true\n      } else {\n        olCounter = 0\n        inSequence = false\n      }\n    } else if (inSequence) {\n      if (/<h[2-5]/.test(tokens[i])) {\n        olCounter = 0\n        inSequence = false\n      }\n    }\n  }\n  html = tokens.join('')\n\n  return html\n}\n\n// Chat Methods\nconst sendMessage = async () => {\n  if (!chatInput.value.trim() || isSending.value) return\n  \n  const message = chatInput.value.trim()\n  chatInput.value = ''\n  \n  // Add user message\n  chatHistory.value.push({\n    role: 'user',\n    content: message,\n    timestamp: new Date().toISOString()\n  })\n  \n  scrollToBottom()\n  isSending.value = true\n  \n  try {\n    if (chatTarget.value === 'report_agent') {\n      await sendToReportAgent(message)\n    } else {\n      await sendToAgent(message)\n    }\n  } catch (err) {\n    addLog(`发送失败: ${err.message}`)\n    chatHistory.value.push({\n      role: 'assistant',\n      content: `抱歉，发生了错误: ${err.message}`,\n      timestamp: new Date().toISOString()\n    })\n  } finally {\n    isSending.value = false\n    scrollToBottom()\n    // 自动保存对话记录到缓存\n    saveChatHistory()\n  }\n}\n\nconst sendToReportAgent = async (message) => {\n  addLog(`向 Report Agent 发送: ${message.substring(0, 50)}...`)\n  \n  // Build chat history for API\n  const historyForApi = chatHistory.value\n    .filter(msg => msg.role !== 'user' || msg.content !== message)\n    .slice(-10) // Keep last 10 messages\n    .map(msg => ({\n      role: msg.role,\n      content: msg.content\n    }))\n  \n  const res = await chatWithReport({\n    simulation_id: props.simulationId,\n    message: message,\n    chat_history: historyForApi\n  })\n  \n  if (res.success && res.data) {\n    chatHistory.value.push({\n      role: 'assistant',\n      content: res.data.response || res.data.answer || '无响应',\n      timestamp: new Date().toISOString()\n    })\n    addLog('Report Agent 已回复')\n  } else {\n    throw new Error(res.error || '请求失败')\n  }\n}\n\nconst sendToAgent = async (message) => {\n  if (!selectedAgent.value || selectedAgentIndex.value === null) {\n    throw new Error('请先选择一个模拟个体')\n  }\n  \n  addLog(`向 ${selectedAgent.value.username} 发送: ${message.substring(0, 50)}...`)\n  \n  // Build prompt with chat history\n  let prompt = message\n  if (chatHistory.value.length > 1) {\n    const historyContext = chatHistory.value\n      .filter(msg => msg.content !== message)\n      .slice(-6)\n      .map(msg => `${msg.role === 'user' ? '提问者' : '你'}：${msg.content}`)\n      .join('\\n')\n    prompt = `以下是我们之前的对话：\\n${historyContext}\\n\\n现在我的新问题是：${message}`\n  }\n  \n  const res = await interviewAgents({\n    simulation_id: props.simulationId,\n    interviews: [{\n      agent_id: selectedAgentIndex.value,\n      prompt: prompt\n    }]\n  })\n  \n  if (res.success && res.data) {\n    // 正确的数据路径: res.data.result.results 是一个对象字典\n    // 格式: {\"twitter_0\": {...}, \"reddit_0\": {...}} 或单平台 {\"reddit_0\": {...}}\n    const resultData = res.data.result || res.data\n    const resultsDict = resultData.results || resultData\n    \n    // 将对象字典转换为数组，优先获取 reddit 平台的回复\n    let responseContent = null\n    const agentId = selectedAgentIndex.value\n    \n    if (typeof resultsDict === 'object' && !Array.isArray(resultsDict)) {\n      // 优先使用 reddit 平台回复，其次 twitter\n      const redditKey = `reddit_${agentId}`\n      const twitterKey = `twitter_${agentId}`\n      const agentResult = resultsDict[redditKey] || resultsDict[twitterKey] || Object.values(resultsDict)[0]\n      if (agentResult) {\n        responseContent = agentResult.response || agentResult.answer\n      }\n    } else if (Array.isArray(resultsDict) && resultsDict.length > 0) {\n      // 兼容数组格式\n      responseContent = resultsDict[0].response || resultsDict[0].answer\n    }\n    \n    if (responseContent) {\n      chatHistory.value.push({\n        role: 'assistant',\n        content: responseContent,\n        timestamp: new Date().toISOString()\n      })\n      addLog(`${selectedAgent.value.username} 已回复`)\n    } else {\n      throw new Error('无响应数据')\n    }\n  } else {\n    throw new Error(res.error || '请求失败')\n  }\n}\n\nconst scrollToBottom = () => {\n  nextTick(() => {\n    if (chatMessages.value) {\n      chatMessages.value.scrollTop = chatMessages.value.scrollHeight\n    }\n  })\n}\n\n// Survey Methods\nconst toggleAgentSelection = (idx) => {\n  const newSet = new Set(selectedAgents.value)\n  if (newSet.has(idx)) {\n    newSet.delete(idx)\n  } else {\n    newSet.add(idx)\n  }\n  selectedAgents.value = newSet\n}\n\nconst selectAllAgents = () => {\n  const newSet = new Set()\n  profiles.value.forEach((_, idx) => newSet.add(idx))\n  selectedAgents.value = newSet\n}\n\nconst clearAgentSelection = () => {\n  selectedAgents.value = new Set()\n}\n\nconst submitSurvey = async () => {\n  if (selectedAgents.value.size === 0 || !surveyQuestion.value.trim()) return\n  \n  isSurveying.value = true\n  addLog(`发送问卷给 ${selectedAgents.value.size} 个对象...`)\n  \n  try {\n    const interviews = Array.from(selectedAgents.value).map(idx => ({\n      agent_id: idx,\n      prompt: surveyQuestion.value.trim()\n    }))\n    \n    const res = await interviewAgents({\n      simulation_id: props.simulationId,\n      interviews: interviews\n    })\n    \n    if (res.success && res.data) {\n      // 正确的数据路径: res.data.result.results 是一个对象字典\n      // 格式: {\"twitter_0\": {...}, \"reddit_0\": {...}, \"twitter_1\": {...}, ...}\n      const resultData = res.data.result || res.data\n      const resultsDict = resultData.results || resultData\n      \n      // 将对象字典转换为数组格式\n      const surveyResultsList = []\n      \n      for (const interview of interviews) {\n        const agentIdx = interview.agent_id\n        const agent = profiles.value[agentIdx]\n        \n        // 优先使用 reddit 平台回复，其次 twitter\n        let responseContent = '无响应'\n        \n        if (typeof resultsDict === 'object' && !Array.isArray(resultsDict)) {\n          const redditKey = `reddit_${agentIdx}`\n          const twitterKey = `twitter_${agentIdx}`\n          const agentResult = resultsDict[redditKey] || resultsDict[twitterKey]\n          if (agentResult) {\n            responseContent = agentResult.response || agentResult.answer || '无响应'\n          }\n        } else if (Array.isArray(resultsDict)) {\n          // 兼容数组格式\n          const matchedResult = resultsDict.find(r => r.agent_id === agentIdx)\n          if (matchedResult) {\n            responseContent = matchedResult.response || matchedResult.answer || '无响应'\n          }\n        }\n        \n        surveyResultsList.push({\n          agent_id: agentIdx,\n          agent_name: agent?.username || `Agent ${agentIdx}`,\n          profession: agent?.profession,\n          question: surveyQuestion.value.trim(),\n          answer: responseContent\n        })\n      }\n      \n      surveyResults.value = surveyResultsList\n      addLog(`收到 ${surveyResults.value.length} 条回复`)\n    } else {\n      throw new Error(res.error || '请求失败')\n    }\n  } catch (err) {\n    addLog(`问卷发送失败: ${err.message}`)\n  } finally {\n    isSurveying.value = false\n  }\n}\n\n// Load Report Data\nconst loadReportData = async () => {\n  if (!props.reportId) return\n  \n  try {\n    addLog(`加载报告数据: ${props.reportId}`)\n    \n    // Get report info\n    const reportRes = await getReport(props.reportId)\n    if (reportRes.success && reportRes.data) {\n      // Load agent logs to get report outline and sections\n      await loadAgentLogs()\n    }\n  } catch (err) {\n    addLog(`加载报告失败: ${err.message}`)\n  }\n}\n\nconst loadAgentLogs = async () => {\n  if (!props.reportId) return\n  \n  try {\n    const res = await getAgentLog(props.reportId, 0)\n    if (res.success && res.data) {\n      const logs = res.data.logs || []\n      \n      logs.forEach(log => {\n        if (log.action === 'planning_complete' && log.details?.outline) {\n          reportOutline.value = log.details.outline\n        }\n        \n        if (log.action === 'section_complete' && log.section_index < 100 && log.details?.content) {\n          generatedSections.value[log.section_index] = log.details.content\n        }\n      })\n      \n      addLog('报告数据加载完成')\n    }\n  } catch (err) {\n    addLog(`加载报告日志失败: ${err.message}`)\n  }\n}\n\nconst loadProfiles = async () => {\n  if (!props.simulationId) return\n  \n  try {\n    const res = await getSimulationProfilesRealtime(props.simulationId, 'reddit')\n    if (res.success && res.data) {\n      profiles.value = res.data.profiles || []\n      addLog(`加载了 ${profiles.value.length} 个模拟个体`)\n    }\n  } catch (err) {\n    addLog(`加载模拟个体失败: ${err.message}`)\n  }\n}\n\n// Click outside to close dropdown\nconst handleClickOutside = (e) => {\n  const dropdown = document.querySelector('.agent-dropdown')\n  if (dropdown && !dropdown.contains(e.target)) {\n    showAgentDropdown.value = false\n  }\n}\n\n// Lifecycle\nonMounted(() => {\n  addLog('Step5 深度互动初始化')\n  loadReportData()\n  loadProfiles()\n  document.addEventListener('click', handleClickOutside)\n})\n\nonUnmounted(() => {\n  document.removeEventListener('click', handleClickOutside)\n})\n\nwatch(() => props.reportId, (newId) => {\n  if (newId) {\n    loadReportData()\n  }\n}, { immediate: true })\n\nwatch(() => props.simulationId, (newId) => {\n  if (newId) {\n    loadProfiles()\n  }\n}, { immediate: true })\n</script>\n\n<style scoped>\n.interaction-panel {\n  height: 100%;\n  display: flex;\n  flex-direction: column;\n  background: #F8F9FA;\n  font-family: 'Inter', 'Noto Sans SC', system-ui, sans-serif;\n  overflow: hidden;\n}\n\n/* Utility Classes */\n.mono {\n  font-family: 'JetBrains Mono', 'SF Mono', 'Monaco', 'Consolas', monospace;\n}\n\n/* Main Split Layout */\n.main-split-layout {\n  flex: 1;\n  display: flex;\n  overflow: hidden;\n}\n\n/* Left Panel - Report Style (与 Step4Report.vue 完全一致) */\n.left-panel.report-style {\n  width: 45%;\n  min-width: 450px;\n  background: #FFFFFF;\n  border-right: 1px solid #E5E7EB;\n  overflow-y: auto;\n  display: flex;\n  flex-direction: column;\n  padding: 30px 50px 60px 50px;\n}\n\n.left-panel::-webkit-scrollbar {\n  width: 6px;\n}\n\n.left-panel::-webkit-scrollbar-track {\n  background: transparent;\n}\n\n.left-panel::-webkit-scrollbar-thumb {\n  background: transparent;\n  border-radius: 3px;\n  transition: background 0.3s ease;\n}\n\n.left-panel:hover::-webkit-scrollbar-thumb {\n  background: rgba(0, 0, 0, 0.15);\n}\n\n.left-panel::-webkit-scrollbar-thumb:hover {\n  background: rgba(0, 0, 0, 0.25);\n}\n\n/* Report Header */\n.report-content-wrapper {\n  max-width: 800px;\n  margin: 0 auto;\n  width: 100%;\n}\n\n.report-header-block {\n  margin-bottom: 30px;\n}\n\n.report-meta {\n  display: flex;\n  align-items: center;\n  gap: 12px;\n  margin-bottom: 24px;\n}\n\n.report-tag {\n  background: #000000;\n  color: #FFFFFF;\n  font-size: 11px;\n  font-weight: 700;\n  padding: 4px 8px;\n  letter-spacing: 0.05em;\n  text-transform: uppercase;\n}\n\n.report-id {\n  font-size: 11px;\n  color: #9CA3AF;\n  font-weight: 500;\n  letter-spacing: 0.02em;\n}\n\n.main-title {\n  font-family: 'Times New Roman', Times, serif;\n  font-size: 36px;\n  font-weight: 700;\n  color: #111827;\n  line-height: 1.2;\n  margin: 0 0 16px 0;\n  letter-spacing: -0.02em;\n}\n\n.sub-title {\n  font-family: 'Times New Roman', Times, serif;\n  font-size: 16px;\n  color: #6B7280;\n  font-style: italic;\n  line-height: 1.6;\n  margin: 0 0 30px 0;\n  font-weight: 400;\n}\n\n.header-divider {\n  height: 1px;\n  background: #E5E7EB;\n  width: 100%;\n}\n\n/* Sections List */\n.sections-list {\n  display: flex;\n  flex-direction: column;\n  gap: 32px;\n}\n\n.report-section-item {\n  display: flex;\n  flex-direction: column;\n  gap: 12px;\n}\n\n.section-header-row {\n  display: flex;\n  align-items: baseline;\n  gap: 12px;\n  transition: background-color 0.2s ease;\n  padding: 8px 12px;\n  margin: -8px -12px;\n  border-radius: 8px;\n}\n\n.section-header-row.clickable {\n  cursor: pointer;\n}\n\n.section-header-row.clickable:hover {\n  background-color: #F9FAFB;\n}\n\n.collapse-icon {\n  margin-left: auto;\n  color: #9CA3AF;\n  transition: transform 0.3s ease;\n  flex-shrink: 0;\n  align-self: center;\n}\n\n.collapse-icon.is-collapsed {\n  transform: rotate(-90deg);\n}\n\n.section-number {\n  font-family: 'JetBrains Mono', monospace;\n  font-size: 16px;\n  color: #E5E7EB;\n  font-weight: 500;\n  transition: color 0.3s ease;\n}\n\n.section-title {\n  font-family: 'Times New Roman', Times, serif;\n  font-size: 24px;\n  font-weight: 600;\n  color: #111827;\n  margin: 0;\n  transition: color 0.3s ease;\n}\n\n/* States */\n.report-section-item.is-pending .section-number {\n  color: #E5E7EB;\n}\n.report-section-item.is-pending .section-title {\n  color: #D1D5DB;\n}\n\n.report-section-item.is-active .section-number,\n.report-section-item.is-completed .section-number {\n  color: #9CA3AF;\n}\n\n.report-section-item.is-active .section-title,\n.report-section-item.is-completed .section-title {\n  color: #111827;\n}\n\n.section-body {\n  padding-left: 28px;\n  overflow: hidden;\n}\n\n/* Generated Content */\n.generated-content {\n  font-family: 'Inter', 'Noto Sans SC', system-ui, sans-serif;\n  font-size: 14px;\n  line-height: 1.8;\n  color: #374151;\n}\n\n.generated-content :deep(p) {\n  margin-bottom: 1em;\n}\n\n.generated-content :deep(.md-h2),\n.generated-content :deep(.md-h3),\n.generated-content :deep(.md-h4) {\n  font-family: 'Times New Roman', Times, serif;\n  color: #111827;\n  margin-top: 1.5em;\n  margin-bottom: 0.8em;\n  font-weight: 700;\n}\n\n.generated-content :deep(.md-h2) { font-size: 20px; border-bottom: 1px solid #F3F4F6; padding-bottom: 8px; }\n.generated-content :deep(.md-h3) { font-size: 18px; }\n.generated-content :deep(.md-h4) { font-size: 16px; }\n\n.generated-content :deep(.md-ul),\n.generated-content :deep(.md-ol) {\n  padding-left: 20px;\n  margin-bottom: 1em;\n}\n\n.generated-content :deep(.md-li) {\n  margin-bottom: 0.5em;\n}\n\n.generated-content :deep(.md-quote) {\n  border-left: 3px solid #E5E7EB;\n  padding-left: 16px;\n  margin: 1.5em 0;\n  color: #6B7280;\n  font-style: italic;\n  font-family: 'Times New Roman', Times, serif;\n}\n\n.generated-content :deep(.code-block) {\n  background: #F9FAFB;\n  padding: 12px;\n  border-radius: 6px;\n  font-family: 'JetBrains Mono', monospace;\n  font-size: 12px;\n  overflow-x: auto;\n  margin: 1em 0;\n  border: 1px solid #E5E7EB;\n}\n\n.generated-content :deep(strong) {\n  font-weight: 600;\n  color: #111827;\n}\n\n/* Loading State */\n.loading-state {\n  display: flex;\n  align-items: center;\n  gap: 10px;\n  color: #6B7280;\n  font-size: 14px;\n  margin-top: 4px;\n}\n\n.loading-icon {\n  width: 18px;\n  height: 18px;\n  animation: spin 1s linear infinite;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n}\n\n.loading-text {\n  font-family: 'Times New Roman', Times, serif;\n  font-size: 15px;\n  color: #4B5563;\n}\n\n@keyframes spin {\n  to { transform: rotate(360deg); }\n}\n\n/* Content Styles Override */\n.generated-content :deep(.md-h2) {\n  font-family: 'Times New Roman', Times, serif;\n  font-size: 18px;\n  margin-top: 0;\n}\n\n/* Waiting Placeholder */\n.waiting-placeholder {\n  flex: 1;\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  justify-content: center;\n  gap: 20px;\n  padding: 40px;\n  color: #9CA3AF;\n}\n\n.waiting-animation {\n  position: relative;\n  width: 48px;\n  height: 48px;\n}\n\n.waiting-ring {\n  position: absolute;\n  width: 100%;\n  height: 100%;\n  border: 2px solid #E5E7EB;\n  border-radius: 50%;\n  animation: ripple 2s cubic-bezier(0.4, 0, 0.2, 1) infinite;\n}\n\n.waiting-ring:nth-child(2) {\n  animation-delay: 0.4s;\n}\n\n.waiting-ring:nth-child(3) {\n  animation-delay: 0.8s;\n}\n\n@keyframes ripple {\n  0% { transform: scale(0.5); opacity: 1; }\n  100% { transform: scale(2); opacity: 0; }\n}\n\n.waiting-text {\n  font-size: 14px;\n}\n\n/* Right Panel - Interaction */\n.right-panel {\n  flex: 1;\n  display: flex;\n  flex-direction: column;\n  background: #FFFFFF;\n  overflow: hidden;\n}\n\n/* Action Bar - Professional Design */\n.action-bar {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  padding: 14px 20px;\n  border-bottom: 1px solid #E5E7EB;\n  background: linear-gradient(180deg, #FFFFFF 0%, #FAFBFC 100%);\n  gap: 16px;\n}\n\n.action-bar-header {\n  display: flex;\n  align-items: center;\n  gap: 12px;\n  min-width: 160px;\n}\n\n.action-bar-icon {\n  color: #1F2937;\n  flex-shrink: 0;\n}\n\n.action-bar-text {\n  display: flex;\n  flex-direction: column;\n  gap: 2px;\n}\n\n.action-bar-title {\n  font-size: 13px;\n  font-weight: 600;\n  color: #1F2937;\n  letter-spacing: -0.01em;\n}\n\n.action-bar-subtitle {\n  font-size: 11px;\n  color: #9CA3AF;\n}\n\n.action-bar-subtitle.mono {\n  font-family: 'JetBrains Mono', 'SF Mono', monospace;\n}\n\n.action-bar-tabs {\n  display: flex;\n  align-items: center;\n  gap: 6px;\n  flex: 1;\n  justify-content: flex-end;\n}\n\n.tab-pill {\n  display: flex;\n  align-items: center;\n  gap: 6px;\n  padding: 8px 14px;\n  font-size: 12px;\n  font-weight: 500;\n  color: #6B7280;\n  background: #F3F4F6;\n  border: 1px solid transparent;\n  border-radius: 20px;\n  cursor: pointer;\n  transition: all 0.2s ease;\n  white-space: nowrap;\n}\n\n.tab-pill:hover {\n  background: #E5E7EB;\n  color: #374151;\n}\n\n.tab-pill.active {\n  background: #1F2937;\n  color: #FFFFFF;\n  box-shadow: 0 2px 8px rgba(31, 41, 55, 0.15);\n}\n\n.tab-pill svg {\n  flex-shrink: 0;\n  opacity: 0.7;\n}\n\n.tab-pill.active svg {\n  opacity: 1;\n}\n\n.tab-divider {\n  width: 1px;\n  height: 24px;\n  background: #E5E7EB;\n  margin: 0 6px;\n}\n\n.agent-pill {\n  width: 200px;\n  justify-content: space-between;\n}\n\n.agent-pill span {\n  flex: 1;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n  text-align: left;\n}\n\n.survey-pill {\n  background: #ECFDF5;\n  color: #047857;\n}\n\n.survey-pill:hover {\n  background: #D1FAE5;\n  color: #065F46;\n}\n\n.survey-pill.active {\n  background: #047857;\n  color: #FFFFFF;\n  box-shadow: 0 2px 8px rgba(4, 120, 87, 0.2);\n}\n\n/* Interaction Header */\n.interaction-header {\n  padding: 16px 24px;\n  border-bottom: 1px solid #E5E7EB;\n  background: #FAFAFA;\n}\n\n.tab-switcher {\n  display: flex;\n  gap: 8px;\n}\n\n.tab-btn {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  padding: 10px 20px;\n  font-size: 13px;\n  font-weight: 600;\n  color: #6B7280;\n  background: transparent;\n  border: 1px solid #E5E7EB;\n  border-radius: 6px;\n  cursor: pointer;\n  transition: all 0.2s ease;\n}\n\n.tab-btn:hover {\n  background: #F9FAFB;\n  border-color: #D1D5DB;\n}\n\n.tab-btn.active {\n  background: #1F2937;\n  color: #FFFFFF;\n  border-color: #1F2937;\n}\n\n.tab-btn svg {\n  flex-shrink: 0;\n}\n\n/* Chat Container */\n.chat-container {\n  flex: 1;\n  display: flex;\n  flex-direction: column;\n  overflow: hidden;\n}\n\n/* Report Agent Tools Card */\n.report-agent-tools-card {\n  border-bottom: 1px solid #E5E7EB;\n  background: linear-gradient(135deg, #F8FAFC 0%, #F1F5F9 100%);\n}\n\n.tools-card-header {\n  display: flex;\n  align-items: center;\n  gap: 12px;\n  padding: 14px 20px;\n}\n\n.tools-card-avatar {\n  width: 44px;\n  height: 44px;\n  min-width: 44px;\n  min-height: 44px;\n  background: linear-gradient(135deg, #1F2937 0%, #374151 100%);\n  color: #FFFFFF;\n  border-radius: 50%;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  font-size: 18px;\n  font-weight: 600;\n  flex-shrink: 0;\n  box-shadow: 0 2px 8px rgba(31, 41, 55, 0.2);\n}\n\n.tools-card-info {\n  flex: 1;\n  min-width: 0;\n}\n\n.tools-card-name {\n  font-size: 15px;\n  font-weight: 600;\n  color: #1F2937;\n  margin-bottom: 2px;\n}\n\n.tools-card-subtitle {\n  font-size: 12px;\n  color: #6B7280;\n}\n\n.tools-card-toggle {\n  width: 28px;\n  height: 28px;\n  background: #FFFFFF;\n  border: 1px solid #E5E7EB;\n  border-radius: 6px;\n  cursor: pointer;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  color: #6B7280;\n  transition: all 0.2s ease;\n  flex-shrink: 0;\n}\n\n.tools-card-toggle:hover {\n  background: #F9FAFB;\n  border-color: #D1D5DB;\n}\n\n.tools-card-toggle svg {\n  transition: transform 0.3s ease;\n}\n\n.tools-card-toggle svg.is-expanded {\n  transform: rotate(180deg);\n}\n\n.tools-card-body {\n  padding: 0 20px 16px 20px;\n}\n\n.tools-grid {\n  display: grid;\n  grid-template-columns: repeat(2, 1fr);\n  gap: 10px;\n}\n\n.tool-item {\n  display: flex;\n  gap: 10px;\n  padding: 12px;\n  background: #FFFFFF;\n  border-radius: 10px;\n  border: 1px solid #E5E7EB;\n  transition: all 0.2s ease;\n}\n\n.tool-item:hover {\n  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);\n}\n\n.tool-icon-wrapper {\n  width: 32px;\n  height: 32px;\n  min-width: 32px;\n  border-radius: 8px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  flex-shrink: 0;\n}\n\n.tool-purple .tool-icon-wrapper {\n  background: rgba(139, 92, 246, 0.1);\n  color: #8B5CF6;\n}\n\n.tool-blue .tool-icon-wrapper {\n  background: rgba(59, 130, 246, 0.1);\n  color: #3B82F6;\n}\n\n.tool-orange .tool-icon-wrapper {\n  background: rgba(249, 115, 22, 0.1);\n  color: #F97316;\n}\n\n.tool-green .tool-icon-wrapper {\n  background: rgba(34, 197, 94, 0.1);\n  color: #22C55E;\n}\n\n.tool-content {\n  flex: 1;\n  min-width: 0;\n}\n\n.tool-name {\n  font-size: 12px;\n  font-weight: 600;\n  color: #1F2937;\n  margin-bottom: 4px;\n}\n\n.tool-desc {\n  font-size: 11px;\n  color: #6B7280;\n  line-height: 1.4;\n  display: -webkit-box;\n  -webkit-line-clamp: 2;\n  -webkit-box-orient: vertical;\n  overflow: hidden;\n}\n\n/* Agent Profile Card */\n.agent-profile-card {\n  border-bottom: 1px solid #E5E7EB;\n  background: linear-gradient(135deg, #F8FAFC 0%, #F1F5F9 100%);\n}\n\n.profile-card-header {\n  display: flex;\n  align-items: center;\n  gap: 12px;\n  padding: 14px 20px;\n}\n\n.profile-card-avatar {\n  width: 44px;\n  height: 44px;\n  min-width: 44px;\n  min-height: 44px;\n  background: linear-gradient(135deg, #1F2937 0%, #374151 100%);\n  color: #FFFFFF;\n  border-radius: 50%;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  font-size: 18px;\n  font-weight: 600;\n  flex-shrink: 0;\n  box-shadow: 0 2px 8px rgba(31, 41, 55, 0.2);\n}\n\n.profile-card-info {\n  flex: 1;\n  min-width: 0;\n}\n\n.profile-card-name {\n  font-size: 15px;\n  font-weight: 600;\n  color: #1F2937;\n  margin-bottom: 2px;\n}\n\n.profile-card-meta {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  font-size: 12px;\n  color: #6B7280;\n}\n\n.profile-card-handle {\n  color: #9CA3AF;\n}\n\n.profile-card-profession {\n  padding: 2px 8px;\n  background: #E5E7EB;\n  border-radius: 4px;\n  font-size: 11px;\n  font-weight: 500;\n}\n\n.profile-card-toggle {\n  width: 28px;\n  height: 28px;\n  background: #FFFFFF;\n  border: 1px solid #E5E7EB;\n  border-radius: 6px;\n  cursor: pointer;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  color: #6B7280;\n  transition: all 0.2s ease;\n  flex-shrink: 0;\n}\n\n.profile-card-toggle:hover {\n  background: #F9FAFB;\n  border-color: #D1D5DB;\n}\n\n.profile-card-toggle svg {\n  transition: transform 0.3s ease;\n}\n\n.profile-card-toggle svg.is-expanded {\n  transform: rotate(180deg);\n}\n\n.profile-card-body {\n  padding: 0 20px 16px 20px;\n  display: flex;\n  flex-direction: column;\n  gap: 12px;\n}\n\n.profile-card-label {\n  font-size: 11px;\n  font-weight: 600;\n  color: #9CA3AF;\n  text-transform: uppercase;\n  letter-spacing: 0.05em;\n  margin-bottom: 6px;\n}\n\n.profile-card-bio {\n  background: #FFFFFF;\n  padding: 12px 14px;\n  border-radius: 8px;\n  border: 1px solid #E5E7EB;\n}\n\n.profile-card-bio p {\n  margin: 0;\n  font-size: 13px;\n  line-height: 1.6;\n  color: #4B5563;\n}\n\n/* Target Selector */\n.target-selector {\n  padding: 16px 24px;\n  border-bottom: 1px solid #E5E7EB;\n}\n\n.selector-label {\n  font-size: 11px;\n  font-weight: 600;\n  color: #9CA3AF;\n  text-transform: uppercase;\n  letter-spacing: 0.05em;\n  margin-bottom: 10px;\n}\n\n.selector-options {\n  display: flex;\n  gap: 12px;\n}\n\n.target-option {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  padding: 10px 16px;\n  font-size: 13px;\n  font-weight: 500;\n  color: #374151;\n  background: #F9FAFB;\n  border: 1px solid #E5E7EB;\n  border-radius: 6px;\n  cursor: pointer;\n  transition: all 0.2s ease;\n}\n\n.target-option:hover {\n  border-color: #D1D5DB;\n}\n\n.target-option.active {\n  background: #1F2937;\n  color: #FFFFFF;\n  border-color: #1F2937;\n}\n\n/* Agent Dropdown */\n.agent-dropdown {\n  position: relative;\n}\n\n.dropdown-arrow {\n  margin-left: 4px;\n  transition: transform 0.2s ease;\n  opacity: 0.6;\n}\n\n.dropdown-arrow.open {\n  transform: rotate(180deg);\n}\n\n.dropdown-menu {\n  position: absolute;\n  top: calc(100% + 6px);\n  left: 50%;\n  transform: translateX(-50%);\n  min-width: 240px;\n  background: #FFFFFF;\n  border: 1px solid #E5E7EB;\n  border-radius: 12px;\n  box-shadow: 0 12px 40px rgba(0, 0, 0, 0.12), 0 4px 12px rgba(0, 0, 0, 0.06);\n  max-height: 320px;\n  overflow-y: auto;\n  z-index: 100;\n}\n\n.dropdown-header {\n  padding: 12px 16px 8px;\n  font-size: 11px;\n  font-weight: 600;\n  color: #9CA3AF;\n  text-transform: uppercase;\n  letter-spacing: 0.05em;\n  border-bottom: 1px solid #F3F4F6;\n}\n\n.dropdown-item {\n  display: flex;\n  align-items: center;\n  gap: 12px;\n  padding: 10px 16px;\n  cursor: pointer;\n  transition: all 0.15s ease;\n  border-left: 3px solid transparent;\n}\n\n.dropdown-item:hover {\n  background: #F9FAFB;\n  border-left-color: #1F2937;\n}\n\n.dropdown-item:first-of-type {\n  margin-top: 4px;\n}\n\n.dropdown-item:last-child {\n  margin-bottom: 4px;\n}\n\n.agent-avatar {\n  width: 32px;\n  height: 32px;\n  min-width: 32px;\n  min-height: 32px;\n  background: linear-gradient(135deg, #1F2937 0%, #374151 100%);\n  color: #FFFFFF;\n  border-radius: 50%;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  font-size: 12px;\n  font-weight: 600;\n  flex-shrink: 0;\n  box-shadow: 0 2px 4px rgba(31, 41, 55, 0.1);\n}\n\n.agent-info {\n  display: flex;\n  flex-direction: column;\n  gap: 2px;\n  flex: 1;\n  min-width: 0;\n}\n\n.agent-name {\n  font-size: 13px;\n  font-weight: 600;\n  color: #1F2937;\n  white-space: nowrap;\n  overflow: hidden;\n  text-overflow: ellipsis;\n}\n\n.agent-role {\n  font-size: 11px;\n  color: #9CA3AF;\n  white-space: nowrap;\n  overflow: hidden;\n  text-overflow: ellipsis;\n}\n\n/* Chat Messages */\n.chat-messages {\n  flex: 1;\n  overflow-y: auto;\n  padding: 24px;\n  display: flex;\n  flex-direction: column;\n  gap: 20px;\n}\n\n.chat-empty {\n  flex: 1;\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  justify-content: center;\n  gap: 16px;\n  color: #9CA3AF;\n}\n\n.empty-icon {\n  opacity: 0.3;\n}\n\n.empty-text {\n  font-size: 14px;\n  text-align: center;\n  max-width: 280px;\n  line-height: 1.6;\n}\n\n.chat-message {\n  display: flex;\n  gap: 12px;\n}\n\n.chat-message.user {\n  flex-direction: row-reverse;\n}\n\n.message-avatar {\n  width: 36px;\n  height: 36px;\n  min-width: 36px;\n  min-height: 36px;\n  border-radius: 50%;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  font-size: 14px;\n  font-weight: 600;\n  flex-shrink: 0;\n}\n\n.chat-message.user .message-avatar {\n  background: #1F2937;\n  color: #FFFFFF;\n}\n\n.chat-message.assistant .message-avatar {\n  background: #F3F4F6;\n  color: #374151;\n}\n\n.message-content {\n  max-width: 70%;\n  display: flex;\n  flex-direction: column;\n  gap: 6px;\n}\n\n.chat-message.user .message-content {\n  align-items: flex-end;\n}\n\n.message-header {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n}\n\n.chat-message.user .message-header {\n  flex-direction: row-reverse;\n}\n\n.sender-name {\n  font-size: 12px;\n  font-weight: 600;\n  color: #374151;\n}\n\n.message-time {\n  font-size: 11px;\n  color: #9CA3AF;\n}\n\n.message-text {\n  padding: 10px 14px;\n  border-radius: 12px;\n  font-size: 14px;\n  line-height: 1.5;\n}\n\n.chat-message.user .message-text {\n  background: #1F2937;\n  color: #FFFFFF;\n  border-bottom-right-radius: 4px;\n}\n\n.chat-message.assistant .message-text {\n  background: #F3F4F6;\n  color: #374151;\n  border-bottom-left-radius: 4px;\n}\n\n.message-text :deep(.md-p) {\n  margin: 0;\n}\n\n.message-text :deep(.md-p:last-child) {\n  margin-bottom: 0;\n}\n\n/* 修复有序列表编号 - 使用 CSS 计数器让多个 ol 连续编号 */\n.message-text {\n  counter-reset: list-counter;\n}\n\n.message-text :deep(.md-ol) {\n  list-style: none;\n  padding-left: 0;\n  margin: 8px 0;\n}\n\n.message-text :deep(.md-oli) {\n  counter-increment: list-counter;\n  display: flex;\n  gap: 8px;\n  margin: 4px 0;\n}\n\n.message-text :deep(.md-oli)::before {\n  content: counter(list-counter) \".\";\n  font-weight: 600;\n  color: #374151;\n  min-width: 20px;\n  flex-shrink: 0;\n}\n\n/* 无序列表样式 */\n.message-text :deep(.md-ul) {\n  padding-left: 20px;\n  margin: 8px 0;\n}\n\n.message-text :deep(.md-li) {\n  margin: 4px 0;\n}\n\n/* Typing Indicator */\n.typing-indicator {\n  display: flex;\n  gap: 4px;\n  padding: 10px 14px;\n  background: #F3F4F6;\n  border-radius: 12px;\n  border-bottom-left-radius: 4px;\n}\n\n.typing-indicator span {\n  width: 8px;\n  height: 8px;\n  background: #9CA3AF;\n  border-radius: 50%;\n  animation: typing 1.4s infinite ease-in-out;\n}\n\n.typing-indicator span:nth-child(1) { animation-delay: 0s; }\n.typing-indicator span:nth-child(2) { animation-delay: 0.2s; }\n.typing-indicator span:nth-child(3) { animation-delay: 0.4s; }\n\n@keyframes typing {\n  0%, 60%, 100% { transform: translateY(0); }\n  30% { transform: translateY(-8px); }\n}\n\n/* Chat Input */\n.chat-input-area {\n  padding: 16px 24px;\n  border-top: 1px solid #E5E7EB;\n  display: flex;\n  gap: 12px;\n  align-items: flex-end;\n}\n\n.chat-input {\n  flex: 1;\n  padding: 12px 16px;\n  font-size: 14px;\n  border: 1px solid #E5E7EB;\n  border-radius: 8px;\n  resize: none;\n  font-family: inherit;\n  line-height: 1.5;\n  transition: border-color 0.2s ease;\n}\n\n.chat-input:focus {\n  outline: none;\n  border-color: #1F2937;\n}\n\n.chat-input:disabled {\n  background: #F9FAFB;\n  cursor: not-allowed;\n}\n\n.send-btn {\n  width: 44px;\n  height: 44px;\n  background: #1F2937;\n  color: #FFFFFF;\n  border: none;\n  border-radius: 8px;\n  cursor: pointer;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  transition: background 0.2s ease;\n}\n\n.send-btn:hover:not(:disabled) {\n  background: #374151;\n}\n\n.send-btn:disabled {\n  background: #E5E7EB;\n  color: #9CA3AF;\n  cursor: not-allowed;\n}\n\n/* Survey Container */\n.survey-container {\n  flex: 1;\n  display: flex;\n  flex-direction: column;\n  overflow: hidden;\n}\n\n.survey-setup {\n  flex: 1;\n  display: flex;\n  flex-direction: column;\n  padding: 24px;\n  border-bottom: 1px solid #E5E7EB;\n  overflow: hidden;\n}\n\n.setup-section {\n  margin-bottom: 24px;\n}\n\n.setup-section:first-child {\n  flex: 1;\n  display: flex;\n  flex-direction: column;\n  overflow: hidden;\n  min-height: 0;\n}\n\n.setup-section:last-child {\n  margin-bottom: 0;\n}\n\n.section-header {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  margin-bottom: 12px;\n}\n\n.setup-section .section-header .section-title {\n  font-size: 13px;\n  font-weight: 600;\n  color: #374151;\n}\n\n.selection-count {\n  font-size: 12px;\n  color: #9CA3AF;\n}\n\n/* Agents Grid */\n.agents-grid {\n  display: grid;\n  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));\n  gap: 10px;\n  flex: 1;\n  overflow-y: auto;\n  padding: 4px;\n  align-content: start;\n}\n\n.agent-checkbox {\n  display: flex;\n  align-items: center;\n  gap: 10px;\n  padding: 10px 12px;\n  background: #F9FAFB;\n  border: 1px solid #E5E7EB;\n  border-radius: 8px;\n  cursor: pointer;\n  transition: all 0.2s ease;\n}\n\n.agent-checkbox:hover {\n  border-color: #D1D5DB;\n}\n\n.agent-checkbox.checked {\n  background: #F0FDF4;\n  border-color: #10B981;\n}\n\n.agent-checkbox input {\n  display: none;\n}\n\n.checkbox-avatar {\n  width: 28px;\n  height: 28px;\n  min-width: 28px;\n  min-height: 28px;\n  background: #E5E7EB;\n  color: #374151;\n  border-radius: 50%;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  font-size: 11px;\n  font-weight: 600;\n  flex-shrink: 0;\n}\n\n.agent-checkbox.checked .checkbox-avatar {\n  background: #10B981;\n  color: #FFFFFF;\n}\n\n.checkbox-info {\n  flex: 1;\n  min-width: 0;\n}\n\n.checkbox-name {\n  display: block;\n  font-size: 12px;\n  font-weight: 600;\n  color: #1F2937;\n  white-space: nowrap;\n  overflow: hidden;\n  text-overflow: ellipsis;\n}\n\n.checkbox-role {\n  display: block;\n  font-size: 10px;\n  color: #9CA3AF;\n  white-space: nowrap;\n  overflow: hidden;\n  text-overflow: ellipsis;\n}\n\n.checkbox-indicator {\n  width: 20px;\n  height: 20px;\n  border: 2px solid #E5E7EB;\n  border-radius: 4px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  flex-shrink: 0;\n  transition: all 0.2s ease;\n}\n\n.agent-checkbox.checked .checkbox-indicator {\n  background: #10B981;\n  border-color: #10B981;\n  color: #FFFFFF;\n}\n\n.checkbox-indicator svg {\n  opacity: 0;\n  transform: scale(0.5);\n  transition: all 0.2s ease;\n}\n\n.agent-checkbox.checked .checkbox-indicator svg {\n  opacity: 1;\n  transform: scale(1);\n}\n\n.selection-actions {\n  display: flex;\n  gap: 8px;\n  margin-top: 12px;\n}\n\n.action-link {\n  font-size: 12px;\n  color: #6B7280;\n  background: none;\n  border: none;\n  cursor: pointer;\n  padding: 0;\n}\n\n.action-link:hover {\n  color: #1F2937;\n  text-decoration: underline;\n}\n\n.action-divider {\n  color: #E5E7EB;\n}\n\n/* Survey Input */\n.survey-input {\n  width: 100%;\n  padding: 14px 16px;\n  font-size: 14px;\n  border: 1px solid #E5E7EB;\n  border-radius: 8px;\n  resize: none;\n  font-family: inherit;\n  line-height: 1.5;\n  transition: border-color 0.2s ease;\n}\n\n.survey-input:focus {\n  outline: none;\n  border-color: #1F2937;\n}\n\n.survey-submit-btn {\n  width: 100%;\n  padding: 14px 24px;\n  font-size: 14px;\n  font-weight: 600;\n  color: #FFFFFF;\n  background: #1F2937;\n  border: none;\n  border-radius: 8px;\n  cursor: pointer;\n  transition: background 0.2s ease;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  gap: 8px;\n  margin-top: 20px;\n}\n\n.survey-submit-btn:hover:not(:disabled) {\n  background: #374151;\n}\n\n.survey-submit-btn:disabled {\n  background: #E5E7EB;\n  color: #9CA3AF;\n  cursor: not-allowed;\n}\n\n.loading-spinner {\n  width: 18px;\n  height: 18px;\n  border: 2px solid rgba(255, 255, 255, 0.3);\n  border-top-color: #FFFFFF;\n  border-radius: 50%;\n  animation: spin 0.8s linear infinite;\n}\n\n@keyframes spin {\n  to { transform: rotate(360deg); }\n}\n\n/* Survey Results */\n.survey-results {\n  flex: 1;\n  overflow-y: auto;\n  padding: 24px;\n}\n\n.results-header {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  margin-bottom: 20px;\n}\n\n.results-title {\n  font-size: 14px;\n  font-weight: 600;\n  color: #1F2937;\n}\n\n.results-count {\n  font-size: 12px;\n  color: #9CA3AF;\n}\n\n.results-list {\n  display: flex;\n  flex-direction: column;\n  gap: 16px;\n}\n\n.result-card {\n  background: #F9FAFB;\n  border: 1px solid #E5E7EB;\n  border-radius: 12px;\n  padding: 20px;\n}\n\n.result-header {\n  display: flex;\n  align-items: center;\n  gap: 12px;\n  margin-bottom: 12px;\n}\n\n.result-avatar {\n  width: 36px;\n  height: 36px;\n  min-width: 36px;\n  min-height: 36px;\n  background: #1F2937;\n  color: #FFFFFF;\n  border-radius: 50%;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  font-size: 14px;\n  font-weight: 600;\n  flex-shrink: 0;\n}\n\n.result-info {\n  display: flex;\n  flex-direction: column;\n  gap: 2px;\n}\n\n.result-name {\n  font-size: 14px;\n  font-weight: 600;\n  color: #1F2937;\n}\n\n.result-role {\n  font-size: 12px;\n  color: #9CA3AF;\n}\n\n.result-question {\n  display: flex;\n  align-items: flex-start;\n  gap: 8px;\n  padding: 12px 14px;\n  background: #FFFFFF;\n  border-radius: 8px;\n  margin-bottom: 12px;\n  font-size: 13px;\n  color: #6B7280;\n}\n\n.result-question svg {\n  flex-shrink: 0;\n  margin-top: 2px;\n}\n\n.result-answer {\n  font-size: 14px;\n  line-height: 1.7;\n  color: #374151;\n}\n\n/* Markdown Styles */\n:deep(.md-p) {\n  margin: 0 0 12px 0;\n}\n\n:deep(.md-h2) {\n  font-size: 20px;\n  font-weight: 700;\n  color: #1F2937;\n  margin: 24px 0 12px 0;\n}\n\n:deep(.md-h3) {\n  font-size: 16px;\n  font-weight: 600;\n  color: #374151;\n  margin: 20px 0 10px 0;\n}\n\n:deep(.md-h4) {\n  font-size: 14px;\n  font-weight: 600;\n  color: #4B5563;\n  margin: 16px 0 8px 0;\n}\n\n:deep(.md-h5) {\n  font-size: 13px;\n  font-weight: 600;\n  color: #6B7280;\n  margin: 12px 0 6px 0;\n}\n\n:deep(.md-ul), :deep(.md-ol) {\n  margin: 12px 0;\n  padding-left: 24px;\n}\n\n:deep(.md-li), :deep(.md-oli) {\n  margin: 6px 0;\n}\n\n/* 聊天/问卷区域的引用样式 */\n.chat-messages :deep(.md-quote),\n.result-answer :deep(.md-quote) {\n  margin: 12px 0;\n  padding: 12px 16px;\n  background: #F9FAFB;\n  border-left: 3px solid #1F2937;\n  color: #4B5563;\n}\n\n:deep(.code-block) {\n  margin: 12px 0;\n  padding: 12px 16px;\n  background: #1F2937;\n  border-radius: 6px;\n  overflow-x: auto;\n}\n\n:deep(.code-block code) {\n  font-family: 'JetBrains Mono', monospace;\n  font-size: 13px;\n  color: #E5E7EB;\n}\n\n:deep(.inline-code) {\n  font-family: 'JetBrains Mono', monospace;\n  font-size: 13px;\n  background: #F3F4F6;\n  padding: 2px 6px;\n  border-radius: 4px;\n  color: #1F2937;\n}\n\n:deep(.md-hr) {\n  border: none;\n  border-top: 1px solid #E5E7EB;\n  margin: 24px 0;\n}\n</style>\n"
  },
  {
    "path": "frontend/src/main.js",
    "content": "import { createApp } from 'vue'\nimport App from './App.vue'\nimport router from './router'\n\nconst app = createApp(App)\n\napp.use(router)\n\napp.mount('#app')\n"
  },
  {
    "path": "frontend/src/router/index.js",
    "content": "import { createRouter, createWebHistory } from 'vue-router'\nimport Home from '../views/Home.vue'\nimport Process from '../views/MainView.vue'\nimport SimulationView from '../views/SimulationView.vue'\nimport SimulationRunView from '../views/SimulationRunView.vue'\nimport ReportView from '../views/ReportView.vue'\nimport InteractionView from '../views/InteractionView.vue'\n\nconst routes = [\n  {\n    path: '/',\n    name: 'Home',\n    component: Home\n  },\n  {\n    path: '/process/:projectId',\n    name: 'Process',\n    component: Process,\n    props: true\n  },\n  {\n    path: '/simulation/:simulationId',\n    name: 'Simulation',\n    component: SimulationView,\n    props: true\n  },\n  {\n    path: '/simulation/:simulationId/start',\n    name: 'SimulationRun',\n    component: SimulationRunView,\n    props: true\n  },\n  {\n    path: '/report/:reportId',\n    name: 'Report',\n    component: ReportView,\n    props: true\n  },\n  {\n    path: '/interaction/:reportId',\n    name: 'Interaction',\n    component: InteractionView,\n    props: true\n  }\n]\n\nconst router = createRouter({\n  history: createWebHistory(),\n  routes\n})\n\nexport default router\n"
  },
  {
    "path": "frontend/src/store/pendingUpload.js",
    "content": "/**\n * 临时存储待上传的文件和需求\n * 用于首页点击启动引擎后立即跳转，在Process页面再进行API调用\n */\nimport { reactive } from 'vue'\n\nconst state = reactive({\n  files: [],\n  simulationRequirement: '',\n  isPending: false\n})\n\nexport function setPendingUpload(files, requirement) {\n  state.files = files\n  state.simulationRequirement = requirement\n  state.isPending = true\n}\n\nexport function getPendingUpload() {\n  return {\n    files: state.files,\n    simulationRequirement: state.simulationRequirement,\n    isPending: state.isPending\n  }\n}\n\nexport function clearPendingUpload() {\n  state.files = []\n  state.simulationRequirement = ''\n  state.isPending = false\n}\n\nexport default state\n"
  },
  {
    "path": "frontend/src/views/Home.vue",
    "content": "<template>\n  <div class=\"home-container\">\n    <!-- 顶部导航栏 -->\n    <nav class=\"navbar\">\n      <div class=\"nav-brand\">MIROFISH</div>\n      <div class=\"nav-links\">\n        <a href=\"https://github.com/666ghj/MiroFish\" target=\"_blank\" class=\"github-link\">\n          访问我们的Github主页 <span class=\"arrow\">↗</span>\n        </a>\n      </div>\n    </nav>\n\n    <div class=\"main-content\">\n      <!-- 上半部分：Hero 区域 -->\n      <section class=\"hero-section\">\n        <div class=\"hero-left\">\n          <div class=\"tag-row\">\n            <span class=\"orange-tag\">简洁通用的群体智能引擎</span>\n            <span class=\"version-text\">/ v0.1-预览版</span>\n          </div>\n          \n          <h1 class=\"main-title\">\n            上传任意报告<br>\n            <span class=\"gradient-text\">即刻推演未来</span>\n          </h1>\n          \n          <div class=\"hero-desc\">\n            <p>\n              即使只有一段文字，<span class=\"highlight-bold\">MiroFish</span> 也能基于其中的现实种子，全自动生成与之对应的至多<span class=\"highlight-orange\">百万级Agent</span>构成的平行世界。通过上帝视角注入变量，在复杂的群体交互中寻找动态环境下的<span class=\"highlight-code\">“局部最优解”</span>\n            </p>\n            <p class=\"slogan-text\">\n              让未来在 Agent 群中预演，让决策在百战后胜出<span class=\"blinking-cursor\">_</span>\n            </p>\n          </div>\n           \n          <div class=\"decoration-square\"></div>\n        </div>\n        \n        <div class=\"hero-right\">\n          <!-- Logo 区域 -->\n          <div class=\"logo-container\">\n            <img src=\"../assets/logo/MiroFish_logo_left.jpeg\" alt=\"MiroFish Logo\" class=\"hero-logo\" />\n          </div>\n          \n          <button class=\"scroll-down-btn\" @click=\"scrollToBottom\">\n            ↓\n          </button>\n        </div>\n      </section>\n\n      <!-- 下半部分：双栏布局 -->\n      <section class=\"dashboard-section\">\n        <!-- 左栏：状态与步骤 -->\n        <div class=\"left-panel\">\n          <div class=\"panel-header\">\n            <span class=\"status-dot\">■</span> 系统状态\n          </div>\n          \n          <h2 class=\"section-title\">准备就绪</h2>\n          <p class=\"section-desc\">\n            预测引擎待命中，可上传多份非结构化数据以初始化模拟序列\n          </p>\n          \n          <!-- 数据指标卡片 -->\n          <div class=\"metrics-row\">\n            <div class=\"metric-card\">\n              <div class=\"metric-value\">低成本</div>\n              <div class=\"metric-label\">常规模拟平均5$/次</div>\n            </div>\n            <div class=\"metric-card\">\n              <div class=\"metric-value\">高可用</div>\n              <div class=\"metric-label\">最多百万级Agent模拟</div>\n            </div>\n          </div>\n\n          <!-- 项目模拟步骤介绍 (新增区域) -->\n          <div class=\"steps-container\">\n            <div class=\"steps-header\">\n               <span class=\"diamond-icon\">◇</span> 工作流序列\n            </div>\n            <div class=\"workflow-list\">\n              <div class=\"workflow-item\">\n                <span class=\"step-num\">01</span>\n                <div class=\"step-info\">\n                  <div class=\"step-title\">图谱构建</div>\n                  <div class=\"step-desc\">现实种子提取 & 个体与群体记忆注入 & GraphRAG构建</div>\n                </div>\n              </div>\n              <div class=\"workflow-item\">\n                <span class=\"step-num\">02</span>\n                <div class=\"step-info\">\n                  <div class=\"step-title\">环境搭建</div>\n                  <div class=\"step-desc\">实体关系抽取 & 人设生成 & 环境配置Agent注入仿真参数</div>\n                </div>\n              </div>\n              <div class=\"workflow-item\">\n                <span class=\"step-num\">03</span>\n                <div class=\"step-info\">\n                  <div class=\"step-title\">开始模拟</div>\n                  <div class=\"step-desc\">双平台并行模拟 & 自动解析预测需求 & 动态更新时序记忆</div>\n                </div>\n              </div>\n              <div class=\"workflow-item\">\n                <span class=\"step-num\">04</span>\n                <div class=\"step-info\">\n                  <div class=\"step-title\">报告生成</div>\n                  <div class=\"step-desc\">ReportAgent拥有丰富的工具集与模拟后环境进行深度交互</div>\n                </div>\n              </div>\n              <div class=\"workflow-item\">\n                <span class=\"step-num\">05</span>\n                <div class=\"step-info\">\n                  <div class=\"step-title\">深度互动</div>\n                  <div class=\"step-desc\">与模拟世界中的任意一位进行对话 & 与ReportAgent进行对话</div>\n                </div>\n              </div>\n            </div>\n          </div>\n        </div>\n\n        <!-- 右栏：交互控制台 -->\n        <div class=\"right-panel\">\n          <div class=\"console-box\">\n            <!-- 上传区域 -->\n            <div class=\"console-section\">\n              <div class=\"console-header\">\n                <span class=\"console-label\">01 / 现实种子</span>\n                <span class=\"console-meta\">支持格式: PDF, MD, TXT</span>\n              </div>\n              \n              <div \n                class=\"upload-zone\"\n                :class=\"{ 'drag-over': isDragOver, 'has-files': files.length > 0 }\"\n                @dragover.prevent=\"handleDragOver\"\n                @dragleave.prevent=\"handleDragLeave\"\n                @drop.prevent=\"handleDrop\"\n                @click=\"triggerFileInput\"\n              >\n                <input\n                  ref=\"fileInput\"\n                  type=\"file\"\n                  multiple\n                  accept=\".pdf,.md,.txt\"\n                  @change=\"handleFileSelect\"\n                  style=\"display: none\"\n                  :disabled=\"loading\"\n                />\n                \n                <div v-if=\"files.length === 0\" class=\"upload-placeholder\">\n                  <div class=\"upload-icon\">↑</div>\n                  <div class=\"upload-title\">拖拽文件上传</div>\n                  <div class=\"upload-hint\">或点击浏览文件系统</div>\n                </div>\n                \n                <div v-else class=\"file-list\">\n                  <div v-for=\"(file, index) in files\" :key=\"index\" class=\"file-item\">\n                    <span class=\"file-icon\">📄</span>\n                    <span class=\"file-name\">{{ file.name }}</span>\n                    <button @click.stop=\"removeFile(index)\" class=\"remove-btn\">×</button>\n                  </div>\n                </div>\n              </div>\n            </div>\n\n            <!-- 分割线 -->\n            <div class=\"console-divider\">\n              <span>输入参数</span>\n            </div>\n\n            <!-- 输入区域 -->\n            <div class=\"console-section\">\n              <div class=\"console-header\">\n                <span class=\"console-label\">>_ 02 / 模拟提示词</span>\n              </div>\n              <div class=\"input-wrapper\">\n                <textarea\n                  v-model=\"formData.simulationRequirement\"\n                  class=\"code-input\"\n                  placeholder=\"// 用自然语言输入模拟或预测需求（例.武大若发布撤销肖某处分的公告，会引发什么舆情走向）\"\n                  rows=\"6\"\n                  :disabled=\"loading\"\n                ></textarea>\n                <div class=\"model-badge\">引擎: MiroFish-V1.0</div>\n              </div>\n            </div>\n\n            <!-- 启动按钮 -->\n            <div class=\"console-section btn-section\">\n              <button \n                class=\"start-engine-btn\"\n                @click=\"startSimulation\"\n                :disabled=\"!canSubmit || loading\"\n              >\n                <span v-if=\"!loading\">启动引擎</span>\n                <span v-else>初始化中...</span>\n                <span class=\"btn-arrow\">→</span>\n              </button>\n            </div>\n          </div>\n        </div>\n      </section>\n\n      <!-- 历史项目数据库 -->\n      <HistoryDatabase />\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { ref, computed } from 'vue'\nimport { useRouter } from 'vue-router'\nimport HistoryDatabase from '../components/HistoryDatabase.vue'\n\nconst router = useRouter()\n\n// 表单数据\nconst formData = ref({\n  simulationRequirement: ''\n})\n\n// 文件列表\nconst files = ref([])\n\n// 状态\nconst loading = ref(false)\nconst error = ref('')\nconst isDragOver = ref(false)\n\n// 文件输入引用\nconst fileInput = ref(null)\n\n// 计算属性:是否可以提交\nconst canSubmit = computed(() => {\n  return formData.value.simulationRequirement.trim() !== '' && files.value.length > 0\n})\n\n// 触发文件选择\nconst triggerFileInput = () => {\n  if (!loading.value) {\n    fileInput.value?.click()\n  }\n}\n\n// 处理文件选择\nconst handleFileSelect = (event) => {\n  const selectedFiles = Array.from(event.target.files)\n  addFiles(selectedFiles)\n}\n\n// 处理拖拽相关\nconst handleDragOver = (e) => {\n  if (!loading.value) {\n    isDragOver.value = true\n  }\n}\n\nconst handleDragLeave = (e) => {\n  isDragOver.value = false\n}\n\nconst handleDrop = (e) => {\n  isDragOver.value = false\n  if (loading.value) return\n  \n  const droppedFiles = Array.from(e.dataTransfer.files)\n  addFiles(droppedFiles)\n}\n\n// 添加文件\nconst addFiles = (newFiles) => {\n  const validFiles = newFiles.filter(file => {\n    const ext = file.name.split('.').pop().toLowerCase()\n    return ['pdf', 'md', 'txt'].includes(ext)\n  })\n  files.value.push(...validFiles)\n}\n\n// 移除文件\nconst removeFile = (index) => {\n  files.value.splice(index, 1)\n}\n\n// 滚动到底部\nconst scrollToBottom = () => {\n  window.scrollTo({\n    top: document.body.scrollHeight,\n    behavior: 'smooth'\n  })\n}\n\n// 开始模拟 - 立即跳转，API调用在Process页面进行\nconst startSimulation = () => {\n  if (!canSubmit.value || loading.value) return\n  \n  // 存储待上传的数据\n  import('../store/pendingUpload.js').then(({ setPendingUpload }) => {\n    setPendingUpload(files.value, formData.value.simulationRequirement)\n    \n    // 立即跳转到Process页面（使用特殊标识表示新建项目）\n    router.push({\n      name: 'Process',\n      params: { projectId: 'new' }\n    })\n  })\n}\n</script>\n\n<style scoped>\n/* 全局变量与重置 */\n:root {\n  --black: #000000;\n  --white: #FFFFFF;\n  --orange: #FF4500;\n  --gray-light: #F5F5F5;\n  --gray-text: #666666;\n  --border: #E5E5E5;\n  /* \n    使用 Space Grotesk 作为主要标题字体，JetBrains Mono 作为代码/标签字体\n    确保已在 index.html 引入这些 Google Fonts \n  */\n  --font-mono: 'JetBrains Mono', monospace;\n  --font-sans: 'Space Grotesk', 'Noto Sans SC', system-ui, sans-serif;\n  --font-cn: 'Noto Sans SC', system-ui, sans-serif;\n}\n\n.home-container {\n  min-height: 100vh;\n  background: var(--white);\n  font-family: var(--font-sans);\n  color: var(--black);\n}\n\n/* 顶部导航 */\n.navbar {\n  height: 60px;\n  background: var(--black);\n  color: var(--white);\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  padding: 0 40px;\n}\n\n.nav-brand {\n  font-family: var(--font-mono);\n  font-weight: 800;\n  letter-spacing: 1px;\n  font-size: 1.2rem;\n}\n\n.nav-links {\n  display: flex;\n  align-items: center;\n}\n\n.github-link {\n  color: var(--white);\n  text-decoration: none;\n  font-family: var(--font-mono);\n  font-size: 0.9rem;\n  font-weight: 500;\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  transition: opacity 0.2s;\n}\n\n.github-link:hover {\n  opacity: 0.8;\n}\n\n.arrow {\n  font-family: sans-serif;\n}\n\n/* 主要内容区 */\n.main-content {\n  max-width: 1400px;\n  margin: 0 auto;\n  padding: 60px 40px;\n}\n\n/* Hero 区域 */\n.hero-section {\n  display: flex;\n  justify-content: space-between;\n  margin-bottom: 80px;\n  position: relative;\n}\n\n.hero-left {\n  flex: 1;\n  padding-right: 60px;\n}\n\n.tag-row {\n  display: flex;\n  align-items: center;\n  gap: 15px;\n  margin-bottom: 25px;\n  font-family: var(--font-mono);\n  font-size: 0.8rem;\n}\n\n.orange-tag {\n  background: var(--orange);\n  color: var(--white);\n  padding: 4px 10px;\n  font-weight: 700;\n  letter-spacing: 1px;\n  font-size: 0.75rem;\n}\n\n.version-text {\n  color: #999;\n  font-weight: 500;\n  letter-spacing: 0.5px;\n}\n\n.main-title {\n  font-size: 4.5rem;\n  line-height: 1.2;\n  font-weight: 500;\n  margin: 0 0 40px 0;\n  letter-spacing: -2px;\n  color: var(--black);\n}\n\n.gradient-text {\n  background: linear-gradient(90deg, #000000 0%, #444444 100%);\n  -webkit-background-clip: text;\n  -webkit-text-fill-color: transparent;\n  display: inline-block;\n}\n\n.hero-desc {\n  font-size: 1.05rem;\n  line-height: 1.8;\n  color: var(--gray-text);\n  max-width: 640px;\n  margin-bottom: 50px;\n  font-weight: 400;\n  text-align: justify;\n}\n\n.hero-desc p {\n  margin-bottom: 1.5rem;\n}\n\n.highlight-bold {\n  color: var(--black);\n  font-weight: 700;\n}\n\n.highlight-orange {\n  color: var(--orange);\n  font-weight: 700;\n  font-family: var(--font-mono);\n}\n\n.highlight-code {\n  background: rgba(0, 0, 0, 0.05);\n  padding: 2px 6px;\n  border-radius: 2px;\n  font-family: var(--font-mono);\n  font-size: 0.9em;\n  color: var(--black);\n  font-weight: 600;\n}\n\n.slogan-text {\n  font-size: 1.2rem;\n  font-weight: 520;\n  color: var(--black);\n  letter-spacing: 1px;\n  border-left: 3px solid var(--orange);\n  padding-left: 15px;\n  margin-top: 20px;\n}\n\n.blinking-cursor {\n  color: var(--orange);\n  animation: blink 1s step-end infinite;\n  font-weight: 700;\n}\n\n@keyframes blink {\n  0%, 100% { opacity: 1; }\n  50% { opacity: 0; }\n}\n\n.decoration-square {\n  width: 16px;\n  height: 16px;\n  background: var(--orange);\n}\n\n.hero-right {\n  flex: 0.8;\n  display: flex;\n  flex-direction: column;\n  justify-content: space-between;\n  align-items: flex-end;\n}\n\n.logo-container {\n  width: 100%;\n  display: flex;\n  justify-content: flex-end;\n  padding-right: 40px;\n}\n\n.hero-logo {\n  max-width: 500px; /* 调整logo大小 */\n  width: 100%;\n}\n\n.scroll-down-btn {\n  width: 40px;\n  height: 40px;\n  border: 1px solid var(--border);\n  background: transparent;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  cursor: pointer;\n  color: var(--orange);\n  font-size: 1.2rem;\n  transition: all 0.2s;\n}\n\n.scroll-down-btn:hover {\n  border-color: var(--orange);\n}\n\n/* Dashboard 双栏布局 */\n.dashboard-section {\n  display: flex;\n  gap: 60px;\n  border-top: 1px solid var(--border);\n  padding-top: 60px;\n  align-items: flex-start;\n}\n\n.dashboard-section .left-panel,\n.dashboard-section .right-panel {\n  display: flex;\n  flex-direction: column;\n}\n\n/* 左侧面板 */\n.left-panel {\n  flex: 0.8;\n}\n\n.panel-header {\n  font-family: var(--font-mono);\n  font-size: 0.8rem;\n  color: #999;\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  margin-bottom: 20px;\n}\n\n.status-dot {\n  color: var(--orange);\n  font-size: 0.8rem;\n}\n\n.section-title {\n  font-size: 2rem;\n  font-weight: 520;\n  margin: 0 0 15px 0;\n}\n\n.section-desc {\n  color: var(--gray-text);\n  margin-bottom: 25px;\n  line-height: 1.6;\n}\n\n.metrics-row {\n  display: flex;\n  gap: 20px;\n  margin-bottom: 15px;\n}\n\n.metric-card {\n  border: 1px solid var(--border);\n  padding: 20px 30px;\n  min-width: 150px;\n}\n\n.metric-value {\n  font-family: var(--font-mono);\n  font-size: 1.8rem;\n  font-weight: 520;\n  margin-bottom: 5px;\n}\n\n.metric-label {\n  font-size: 0.85rem;\n  color: #999;\n}\n\n/* 项目模拟步骤介绍 */\n.steps-container {\n  border: 1px solid var(--border);\n  padding: 30px;\n  position: relative;\n}\n\n.steps-header {\n  font-family: var(--font-mono);\n  font-size: 0.8rem;\n  color: #999;\n  margin-bottom: 25px;\n  display: flex;\n  align-items: center;\n  gap: 8px;\n}\n\n.diamond-icon {\n  font-size: 1.2rem;\n  line-height: 1;\n}\n\n.workflow-list {\n  display: flex;\n  flex-direction: column;\n  gap: 20px;\n}\n\n.workflow-item {\n  display: flex;\n  align-items: flex-start;\n  gap: 20px;\n}\n\n.step-num {\n  font-family: var(--font-mono);\n  font-weight: 700;\n  color: var(--black);\n  opacity: 0.3;\n}\n\n.step-info {\n  flex: 1;\n}\n\n.step-title {\n  font-weight: 520;\n  font-size: 1rem;\n  margin-bottom: 4px;\n}\n\n.step-desc {\n  font-size: 0.85rem;\n  color: var(--gray-text);\n}\n\n/* 右侧交互控制台 */\n.right-panel {\n  flex: 1.2;\n}\n\n.console-box {\n  border: 1px solid #CCC; /* 外部实线 */\n  padding: 8px; /* 内边距形成双重边框感 */\n}\n\n.console-section {\n  padding: 20px;\n}\n\n.console-section.btn-section {\n  padding-top: 0;\n}\n\n.console-header {\n  display: flex;\n  justify-content: space-between;\n  margin-bottom: 15px;\n  font-family: var(--font-mono);\n  font-size: 0.75rem;\n  color: #666;\n}\n\n.upload-zone {\n  border: 1px dashed #CCC;\n  height: 200px;\n  overflow-y: auto;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  cursor: pointer;\n  transition: all 0.3s;\n  background: #FAFAFA;\n}\n\n.upload-zone.has-files {\n  align-items: flex-start;\n}\n\n.upload-zone:hover {\n  background: #F0F0F0;\n  border-color: #999;\n}\n\n.upload-placeholder {\n  text-align: center;\n}\n\n.upload-icon {\n  width: 40px;\n  height: 40px;\n  border: 1px solid #DDD;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  margin: 0 auto 15px;\n  color: #999;\n}\n\n.upload-title {\n  font-weight: 500;\n  font-size: 0.9rem;\n  margin-bottom: 5px;\n}\n\n.upload-hint {\n  font-family: var(--font-mono);\n  font-size: 0.75rem;\n  color: #999;\n}\n\n.file-list {\n  width: 100%;\n  padding: 15px;\n  display: flex;\n  flex-direction: column;\n  gap: 10px;\n}\n\n.file-item {\n  display: flex;\n  align-items: center;\n  background: var(--white);\n  padding: 8px 12px;\n  border: 1px solid #EEE;\n  font-family: var(--font-mono);\n  font-size: 0.85rem;\n}\n\n.file-name {\n  flex: 1;\n  margin: 0 10px;\n}\n\n.remove-btn {\n  background: none;\n  border: none;\n  cursor: pointer;\n  font-size: 1.2rem;\n  color: #999;\n}\n\n.console-divider {\n  display: flex;\n  align-items: center;\n  margin: 10px 0;\n}\n\n.console-divider::before,\n.console-divider::after {\n  content: '';\n  flex: 1;\n  height: 1px;\n  background: #EEE;\n}\n\n.console-divider span {\n  padding: 0 15px;\n  font-family: var(--font-mono);\n  font-size: 0.7rem;\n  color: #BBB;\n  letter-spacing: 1px;\n}\n\n.input-wrapper {\n  position: relative;\n  border: 1px solid #DDD;\n  background: #FAFAFA;\n}\n\n.code-input {\n  width: 100%;\n  border: none;\n  background: transparent;\n  padding: 20px;\n  font-family: var(--font-mono);\n  font-size: 0.9rem;\n  line-height: 1.6;\n  resize: vertical;\n  outline: none;\n  min-height: 150px;\n}\n\n.model-badge {\n  position: absolute;\n  bottom: 10px;\n  right: 15px;\n  font-family: var(--font-mono);\n  font-size: 0.7rem;\n  color: #AAA;\n}\n\n.start-engine-btn {\n  width: 100%;\n  background: var(--black);\n  color: var(--white);\n  border: none;\n  padding: 20px;\n  font-family: var(--font-mono);\n  font-weight: 700;\n  font-size: 1.1rem;\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  cursor: pointer;\n  transition: all 0.3s ease;\n  letter-spacing: 1px;\n  position: relative;\n  overflow: hidden;\n}\n\n/* 可点击状态（非禁用） */\n.start-engine-btn:not(:disabled) {\n  background: var(--black);\n  border: 1px solid var(--black);\n  animation: pulse-border 2s infinite;\n}\n\n.start-engine-btn:hover:not(:disabled) {\n  background: var(--orange);\n  border-color: var(--orange);\n  transform: translateY(-2px);\n}\n\n.start-engine-btn:active:not(:disabled) {\n  transform: translateY(0);\n}\n\n.start-engine-btn:disabled {\n  background: #E5E5E5;\n  color: #999;\n  cursor: not-allowed;\n  transform: none;\n  border: 1px solid #E5E5E5;\n}\n\n/* 引导动画：微妙的边框脉冲 */\n@keyframes pulse-border {\n  0% { box-shadow: 0 0 0 0 rgba(0, 0, 0, 0.2); }\n  70% { box-shadow: 0 0 0 6px rgba(0, 0, 0, 0); }\n  100% { box-shadow: 0 0 0 0 rgba(0, 0, 0, 0); }\n}\n\n/* 响应式适配 */\n@media (max-width: 1024px) {\n  .dashboard-section {\n    flex-direction: column;\n  }\n  \n  .hero-section {\n    flex-direction: column;\n  }\n  \n  .hero-left {\n    padding-right: 0;\n    margin-bottom: 40px;\n  }\n  \n  .hero-logo {\n    max-width: 200px;\n    margin-bottom: 20px;\n  }\n}\n</style>\n"
  },
  {
    "path": "frontend/src/views/InteractionView.vue",
    "content": "<template>\n  <div class=\"main-view\">\n    <!-- Header -->\n    <header class=\"app-header\">\n      <div class=\"header-left\">\n        <div class=\"brand\" @click=\"router.push('/')\">MIROFISH</div>\n      </div>\n      \n      <div class=\"header-center\">\n        <div class=\"view-switcher\">\n          <button \n            v-for=\"mode in ['graph', 'split', 'workbench']\" \n            :key=\"mode\"\n            class=\"switch-btn\"\n            :class=\"{ active: viewMode === mode }\"\n            @click=\"viewMode = mode\"\n          >\n            {{ { graph: '图谱', split: '双栏', workbench: '工作台' }[mode] }}\n          </button>\n        </div>\n      </div>\n\n      <div class=\"header-right\">\n        <div class=\"workflow-step\">\n          <span class=\"step-num\">Step 5/5</span>\n          <span class=\"step-name\">深度互动</span>\n        </div>\n        <div class=\"step-divider\"></div>\n        <span class=\"status-indicator\" :class=\"statusClass\">\n          <span class=\"dot\"></span>\n          {{ statusText }}\n        </span>\n      </div>\n    </header>\n\n    <!-- Main Content Area -->\n    <main class=\"content-area\">\n      <!-- Left Panel: Graph -->\n      <div class=\"panel-wrapper left\" :style=\"leftPanelStyle\">\n        <GraphPanel \n          :graphData=\"graphData\"\n          :loading=\"graphLoading\"\n          :currentPhase=\"5\"\n          :isSimulating=\"false\"\n          @refresh=\"refreshGraph\"\n          @toggle-maximize=\"toggleMaximize('graph')\"\n        />\n      </div>\n\n      <!-- Right Panel: Step5 深度互动 -->\n      <div class=\"panel-wrapper right\" :style=\"rightPanelStyle\">\n        <Step5Interaction\n          :reportId=\"currentReportId\"\n          :simulationId=\"simulationId\"\n          :systemLogs=\"systemLogs\"\n          @add-log=\"addLog\"\n          @update-status=\"updateStatus\"\n        />\n      </div>\n    </main>\n  </div>\n</template>\n\n<script setup>\nimport { ref, computed, onMounted, watch } from 'vue'\nimport { useRoute, useRouter } from 'vue-router'\nimport GraphPanel from '../components/GraphPanel.vue'\nimport Step5Interaction from '../components/Step5Interaction.vue'\nimport { getProject, getGraphData } from '../api/graph'\nimport { getSimulation } from '../api/simulation'\nimport { getReport } from '../api/report'\n\nconst route = useRoute()\nconst router = useRouter()\n\n// Props\nconst props = defineProps({\n  reportId: String\n})\n\n// Layout State - 默认切换到工作台视角\nconst viewMode = ref('workbench')\n\n// Data State\nconst currentReportId = ref(route.params.reportId)\nconst simulationId = ref(null)\nconst projectData = ref(null)\nconst graphData = ref(null)\nconst graphLoading = ref(false)\nconst systemLogs = ref([])\nconst currentStatus = ref('ready') // ready | processing | completed | error\n\n// --- Computed Layout Styles ---\nconst leftPanelStyle = computed(() => {\n  if (viewMode.value === 'graph') return { width: '100%', opacity: 1, transform: 'translateX(0)' }\n  if (viewMode.value === 'workbench') return { width: '0%', opacity: 0, transform: 'translateX(-20px)' }\n  return { width: '50%', opacity: 1, transform: 'translateX(0)' }\n})\n\nconst rightPanelStyle = computed(() => {\n  if (viewMode.value === 'workbench') return { width: '100%', opacity: 1, transform: 'translateX(0)' }\n  if (viewMode.value === 'graph') return { width: '0%', opacity: 0, transform: 'translateX(20px)' }\n  return { width: '50%', opacity: 1, transform: 'translateX(0)' }\n})\n\n// --- Status Computed ---\nconst statusClass = computed(() => {\n  return currentStatus.value\n})\n\nconst statusText = computed(() => {\n  if (currentStatus.value === 'error') return 'Error'\n  if (currentStatus.value === 'completed') return 'Completed'\n  if (currentStatus.value === 'processing') return 'Processing'\n  return 'Ready'\n})\n\n// --- Helpers ---\nconst addLog = (msg) => {\n  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')\n  systemLogs.value.push({ time, msg })\n  if (systemLogs.value.length > 200) {\n    systemLogs.value.shift()\n  }\n}\n\nconst updateStatus = (status) => {\n  currentStatus.value = status\n}\n\n// --- Layout Methods ---\nconst toggleMaximize = (target) => {\n  if (viewMode.value === target) {\n    viewMode.value = 'split'\n  } else {\n    viewMode.value = target\n  }\n}\n\n// --- Data Logic ---\nconst loadReportData = async () => {\n  try {\n    addLog(`加载报告数据: ${currentReportId.value}`)\n    \n    // 获取 report 信息以获取 simulation_id\n    const reportRes = await getReport(currentReportId.value)\n    if (reportRes.success && reportRes.data) {\n      const reportData = reportRes.data\n      simulationId.value = reportData.simulation_id\n      \n      if (simulationId.value) {\n        // 获取 simulation 信息\n        const simRes = await getSimulation(simulationId.value)\n        if (simRes.success && simRes.data) {\n          const simData = simRes.data\n          \n          // 获取 project 信息\n          if (simData.project_id) {\n            const projRes = await getProject(simData.project_id)\n            if (projRes.success && projRes.data) {\n              projectData.value = projRes.data\n              addLog(`项目加载成功: ${projRes.data.project_id}`)\n              \n              // 获取 graph 数据\n              if (projRes.data.graph_id) {\n                await loadGraph(projRes.data.graph_id)\n              }\n            }\n          }\n        }\n      }\n    } else {\n      addLog(`获取报告信息失败: ${reportRes.error || '未知错误'}`)\n    }\n  } catch (err) {\n    addLog(`加载异常: ${err.message}`)\n  }\n}\n\nconst loadGraph = async (graphId) => {\n  graphLoading.value = true\n  \n  try {\n    const res = await getGraphData(graphId)\n    if (res.success) {\n      graphData.value = res.data\n      addLog('图谱数据加载成功')\n    }\n  } catch (err) {\n    addLog(`图谱加载失败: ${err.message}`)\n  } finally {\n    graphLoading.value = false\n  }\n}\n\nconst refreshGraph = () => {\n  if (projectData.value?.graph_id) {\n    loadGraph(projectData.value.graph_id)\n  }\n}\n\n// Watch route params\nwatch(() => route.params.reportId, (newId) => {\n  if (newId && newId !== currentReportId.value) {\n    currentReportId.value = newId\n    loadReportData()\n  }\n}, { immediate: true })\n\nonMounted(() => {\n  addLog('InteractionView 初始化')\n  loadReportData()\n})\n</script>\n\n<style scoped>\n.main-view {\n  height: 100vh;\n  display: flex;\n  flex-direction: column;\n  background: #FFF;\n  overflow: hidden;\n  font-family: 'Space Grotesk', 'Noto Sans SC', system-ui, sans-serif;\n}\n\n/* Header */\n.app-header {\n  height: 60px;\n  border-bottom: 1px solid #EAEAEA;\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  padding: 0 24px;\n  background: #FFF;\n  z-index: 100;\n  position: relative;\n}\n\n.header-center {\n  position: absolute;\n  left: 50%;\n  transform: translateX(-50%);\n}\n\n.brand {\n  font-family: 'JetBrains Mono', monospace;\n  font-weight: 800;\n  font-size: 18px;\n  letter-spacing: 1px;\n  cursor: pointer;\n}\n\n.view-switcher {\n  display: flex;\n  background: #F5F5F5;\n  padding: 4px;\n  border-radius: 6px;\n  gap: 4px;\n}\n\n.switch-btn {\n  border: none;\n  background: transparent;\n  padding: 6px 16px;\n  font-size: 12px;\n  font-weight: 600;\n  color: #666;\n  border-radius: 4px;\n  cursor: pointer;\n  transition: all 0.2s;\n}\n\n.switch-btn.active {\n  background: #FFF;\n  color: #000;\n  box-shadow: 0 2px 4px rgba(0,0,0,0.05);\n}\n\n.header-right {\n  display: flex;\n  align-items: center;\n  gap: 16px;\n}\n\n.workflow-step {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  font-size: 14px;\n}\n\n.step-num {\n  font-family: 'JetBrains Mono', monospace;\n  font-weight: 700;\n  color: #999;\n}\n\n.step-name {\n  font-weight: 700;\n  color: #000;\n}\n\n.step-divider {\n  width: 1px;\n  height: 14px;\n  background-color: #E0E0E0;\n}\n\n.status-indicator {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  font-size: 12px;\n  color: #666;\n  font-weight: 500;\n}\n\n.dot {\n  width: 8px;\n  height: 8px;\n  border-radius: 50%;\n  background: #CCC;\n}\n\n.status-indicator.ready .dot { background: #4CAF50; }\n.status-indicator.processing .dot { background: #FF9800; animation: pulse 1s infinite; }\n.status-indicator.completed .dot { background: #4CAF50; }\n.status-indicator.error .dot { background: #F44336; }\n\n@keyframes pulse { 50% { opacity: 0.5; } }\n\n/* Content */\n.content-area {\n  flex: 1;\n  display: flex;\n  position: relative;\n  overflow: hidden;\n}\n\n.panel-wrapper {\n  height: 100%;\n  overflow: hidden;\n  transition: width 0.4s cubic-bezier(0.25, 0.8, 0.25, 1), opacity 0.3s ease, transform 0.3s ease;\n  will-change: width, opacity, transform;\n}\n\n.panel-wrapper.left {\n  border-right: 1px solid #EAEAEA;\n}\n</style>\n"
  },
  {
    "path": "frontend/src/views/MainView.vue",
    "content": "<template>\n  <div class=\"main-view\">\n    <!-- Header -->\n    <header class=\"app-header\">\n      <div class=\"header-left\">\n        <div class=\"brand\" @click=\"router.push('/')\">MIROFISH</div>\n      </div>\n      \n      <div class=\"header-center\">\n        <div class=\"view-switcher\">\n          <button \n            v-for=\"mode in ['graph', 'split', 'workbench']\" \n            :key=\"mode\"\n            class=\"switch-btn\"\n            :class=\"{ active: viewMode === mode }\"\n            @click=\"viewMode = mode\"\n          >\n            {{ { graph: '图谱', split: '双栏', workbench: '工作台' }[mode] }}\n          </button>\n        </div>\n      </div>\n\n      <div class=\"header-right\">\n        <div class=\"workflow-step\">\n          <span class=\"step-num\">Step {{ currentStep }}/5</span>\n          <span class=\"step-name\">{{ stepNames[currentStep - 1] }}</span>\n        </div>\n        <div class=\"step-divider\"></div>\n        <span class=\"status-indicator\" :class=\"statusClass\">\n          <span class=\"dot\"></span>\n          {{ statusText }}\n        </span>\n      </div>\n    </header>\n\n    <!-- Main Content Area -->\n    <main class=\"content-area\">\n      <!-- Left Panel: Graph -->\n      <div class=\"panel-wrapper left\" :style=\"leftPanelStyle\">\n        <GraphPanel \n          :graphData=\"graphData\"\n          :loading=\"graphLoading\"\n          :currentPhase=\"currentPhase\"\n          @refresh=\"refreshGraph\"\n          @toggle-maximize=\"toggleMaximize('graph')\"\n        />\n      </div>\n\n      <!-- Right Panel: Step Components -->\n      <div class=\"panel-wrapper right\" :style=\"rightPanelStyle\">\n        <!-- Step 1: 图谱构建 -->\n        <Step1GraphBuild \n          v-if=\"currentStep === 1\"\n          :currentPhase=\"currentPhase\"\n          :projectData=\"projectData\"\n          :ontologyProgress=\"ontologyProgress\"\n          :buildProgress=\"buildProgress\"\n          :graphData=\"graphData\"\n          :systemLogs=\"systemLogs\"\n          @next-step=\"handleNextStep\"\n        />\n        <!-- Step 2: 环境搭建 -->\n        <Step2EnvSetup\n          v-else-if=\"currentStep === 2\"\n          :projectData=\"projectData\"\n          :graphData=\"graphData\"\n          :systemLogs=\"systemLogs\"\n          @go-back=\"handleGoBack\"\n          @next-step=\"handleNextStep\"\n          @add-log=\"addLog\"\n        />\n      </div>\n    </main>\n  </div>\n</template>\n\n<script setup>\nimport { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'\nimport { useRoute, useRouter } from 'vue-router'\nimport GraphPanel from '../components/GraphPanel.vue'\nimport Step1GraphBuild from '../components/Step1GraphBuild.vue'\nimport Step2EnvSetup from '../components/Step2EnvSetup.vue'\nimport { generateOntology, getProject, buildGraph, getTaskStatus, getGraphData } from '../api/graph'\nimport { getPendingUpload, clearPendingUpload } from '../store/pendingUpload'\n\nconst route = useRoute()\nconst router = useRouter()\n\n// Layout State\nconst viewMode = ref('split') // graph | split | workbench\n\n// Step State\nconst currentStep = ref(1) // 1: 图谱构建, 2: 环境搭建, 3: 开始模拟, 4: 报告生成, 5: 深度互动\nconst stepNames = ['图谱构建', '环境搭建', '开始模拟', '报告生成', '深度互动']\n\n// Data State\nconst currentProjectId = ref(route.params.projectId)\nconst loading = ref(false)\nconst graphLoading = ref(false)\nconst error = ref('')\nconst projectData = ref(null)\nconst graphData = ref(null)\nconst currentPhase = ref(-1) // -1: Upload, 0: Ontology, 1: Build, 2: Complete\nconst ontologyProgress = ref(null)\nconst buildProgress = ref(null)\nconst systemLogs = ref([])\n\n// Polling timers\nlet pollTimer = null\nlet graphPollTimer = null\n\n// --- Computed Layout Styles ---\nconst leftPanelStyle = computed(() => {\n  if (viewMode.value === 'graph') return { width: '100%', opacity: 1, transform: 'translateX(0)' }\n  if (viewMode.value === 'workbench') return { width: '0%', opacity: 0, transform: 'translateX(-20px)' }\n  return { width: '50%', opacity: 1, transform: 'translateX(0)' }\n})\n\nconst rightPanelStyle = computed(() => {\n  if (viewMode.value === 'workbench') return { width: '100%', opacity: 1, transform: 'translateX(0)' }\n  if (viewMode.value === 'graph') return { width: '0%', opacity: 0, transform: 'translateX(20px)' }\n  return { width: '50%', opacity: 1, transform: 'translateX(0)' }\n})\n\n// --- Status Computed ---\nconst statusClass = computed(() => {\n  if (error.value) return 'error'\n  if (currentPhase.value >= 2) return 'completed'\n  return 'processing'\n})\n\nconst statusText = computed(() => {\n  if (error.value) return 'Error'\n  if (currentPhase.value >= 2) return 'Ready'\n  if (currentPhase.value === 1) return 'Building Graph'\n  if (currentPhase.value === 0) return 'Generating Ontology'\n  return 'Initializing'\n})\n\n// --- Helpers ---\nconst addLog = (msg) => {\n  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')\n  systemLogs.value.push({ time, msg })\n  // Keep last 100 logs\n  if (systemLogs.value.length > 100) {\n    systemLogs.value.shift()\n  }\n}\n\n// --- Layout Methods ---\nconst toggleMaximize = (target) => {\n  if (viewMode.value === target) {\n    viewMode.value = 'split'\n  } else {\n    viewMode.value = target\n  }\n}\n\nconst handleNextStep = (params = {}) => {\n  if (currentStep.value < 5) {\n    currentStep.value++\n    addLog(`进入 Step ${currentStep.value}: ${stepNames[currentStep.value - 1]}`)\n    \n    // 如果是从 Step 2 进入 Step 3，记录模拟轮数配置\n    if (currentStep.value === 3 && params.maxRounds) {\n      addLog(`自定义模拟轮数: ${params.maxRounds} 轮`)\n    }\n  }\n}\n\nconst handleGoBack = () => {\n  if (currentStep.value > 1) {\n    currentStep.value--\n    addLog(`返回 Step ${currentStep.value}: ${stepNames[currentStep.value - 1]}`)\n  }\n}\n\n// --- Data Logic ---\n\nconst initProject = async () => {\n  addLog('Project view initialized.')\n  if (currentProjectId.value === 'new') {\n    await handleNewProject()\n  } else {\n    await loadProject()\n  }\n}\n\nconst handleNewProject = async () => {\n  const pending = getPendingUpload()\n  if (!pending.isPending || pending.files.length === 0) {\n    error.value = 'No pending files found.'\n    addLog('Error: No pending files found for new project.')\n    return\n  }\n  \n  try {\n    loading.value = true\n    currentPhase.value = 0\n    ontologyProgress.value = { message: 'Uploading and analyzing docs...' }\n    addLog('Starting ontology generation: Uploading files...')\n    \n    const formData = new FormData()\n    pending.files.forEach(f => formData.append('files', f))\n    formData.append('simulation_requirement', pending.simulationRequirement)\n    \n    const res = await generateOntology(formData)\n    if (res.success) {\n      clearPendingUpload()\n      currentProjectId.value = res.data.project_id\n      projectData.value = res.data\n      \n      router.replace({ name: 'Process', params: { projectId: res.data.project_id } })\n      ontologyProgress.value = null\n      addLog(`Ontology generated successfully for project ${res.data.project_id}`)\n      await startBuildGraph()\n    } else {\n      error.value = res.error || 'Ontology generation failed'\n      addLog(`Error generating ontology: ${error.value}`)\n    }\n  } catch (err) {\n    error.value = err.message\n    addLog(`Exception in handleNewProject: ${err.message}`)\n  } finally {\n    loading.value = false\n  }\n}\n\nconst loadProject = async () => {\n  try {\n    loading.value = true\n    addLog(`Loading project ${currentProjectId.value}...`)\n    const res = await getProject(currentProjectId.value)\n    if (res.success) {\n      projectData.value = res.data\n      updatePhaseByStatus(res.data.status)\n      addLog(`Project loaded. Status: ${res.data.status}`)\n      \n      if (res.data.status === 'ontology_generated' && !res.data.graph_id) {\n        await startBuildGraph()\n      } else if (res.data.status === 'graph_building' && res.data.graph_build_task_id) {\n        currentPhase.value = 1\n        startPollingTask(res.data.graph_build_task_id)\n        startGraphPolling()\n      } else if (res.data.status === 'graph_completed' && res.data.graph_id) {\n        currentPhase.value = 2\n        await loadGraph(res.data.graph_id)\n      }\n    } else {\n      error.value = res.error\n      addLog(`Error loading project: ${res.error}`)\n    }\n  } catch (err) {\n    error.value = err.message\n    addLog(`Exception in loadProject: ${err.message}`)\n  } finally {\n    loading.value = false\n  }\n}\n\nconst updatePhaseByStatus = (status) => {\n  switch (status) {\n    case 'created':\n    case 'ontology_generated': currentPhase.value = 0; break;\n    case 'graph_building': currentPhase.value = 1; break;\n    case 'graph_completed': currentPhase.value = 2; break;\n    case 'failed': error.value = 'Project failed'; break;\n  }\n}\n\nconst startBuildGraph = async () => {\n  try {\n    currentPhase.value = 1\n    buildProgress.value = { progress: 0, message: 'Starting build...' }\n    addLog('Initiating graph build...')\n    \n    const res = await buildGraph({ project_id: currentProjectId.value })\n    if (res.success) {\n      addLog(`Graph build task started. Task ID: ${res.data.task_id}`)\n      startGraphPolling()\n      startPollingTask(res.data.task_id)\n    } else {\n      error.value = res.error\n      addLog(`Error starting build: ${res.error}`)\n    }\n  } catch (err) {\n    error.value = err.message\n    addLog(`Exception in startBuildGraph: ${err.message}`)\n  }\n}\n\nconst startGraphPolling = () => {\n  addLog('Started polling for graph data...')\n  fetchGraphData()\n  graphPollTimer = setInterval(fetchGraphData, 10000)\n}\n\nconst fetchGraphData = async () => {\n  try {\n    // Refresh project info to check for graph_id\n    const projRes = await getProject(currentProjectId.value)\n    if (projRes.success && projRes.data.graph_id) {\n      const gRes = await getGraphData(projRes.data.graph_id)\n      if (gRes.success) {\n        graphData.value = gRes.data\n        const nodeCount = gRes.data.node_count || gRes.data.nodes?.length || 0\n        const edgeCount = gRes.data.edge_count || gRes.data.edges?.length || 0\n        addLog(`Graph data refreshed. Nodes: ${nodeCount}, Edges: ${edgeCount}`)\n      }\n    }\n  } catch (err) {\n    console.warn('Graph fetch error:', err)\n  }\n}\n\nconst startPollingTask = (taskId) => {\n  pollTaskStatus(taskId)\n  pollTimer = setInterval(() => pollTaskStatus(taskId), 2000)\n}\n\nconst pollTaskStatus = async (taskId) => {\n  try {\n    const res = await getTaskStatus(taskId)\n    if (res.success) {\n      const task = res.data\n      \n      // Log progress message if it changed\n      if (task.message && task.message !== buildProgress.value?.message) {\n        addLog(task.message)\n      }\n      \n      buildProgress.value = { progress: task.progress || 0, message: task.message }\n      \n      if (task.status === 'completed') {\n        addLog('Graph build task completed.')\n        stopPolling()\n        stopGraphPolling() // Stop polling, do final load\n        currentPhase.value = 2\n        \n        // Final load\n        const projRes = await getProject(currentProjectId.value)\n        if (projRes.success && projRes.data.graph_id) {\n            projectData.value = projRes.data\n            await loadGraph(projRes.data.graph_id)\n        }\n      } else if (task.status === 'failed') {\n        stopPolling()\n        error.value = task.error\n        addLog(`Graph build task failed: ${task.error}`)\n      }\n    }\n  } catch (e) {\n    console.error(e)\n  }\n}\n\nconst loadGraph = async (graphId) => {\n  graphLoading.value = true\n  addLog(`Loading full graph data: ${graphId}`)\n  try {\n    const res = await getGraphData(graphId)\n    if (res.success) {\n      graphData.value = res.data\n      addLog('Graph data loaded successfully.')\n    } else {\n      addLog(`Failed to load graph data: ${res.error}`)\n    }\n  } catch (e) {\n    addLog(`Exception loading graph: ${e.message}`)\n  } finally {\n    graphLoading.value = false\n  }\n}\n\nconst refreshGraph = () => {\n  if (projectData.value?.graph_id) {\n    addLog('Manual graph refresh triggered.')\n    loadGraph(projectData.value.graph_id)\n  }\n}\n\nconst stopPolling = () => {\n  if (pollTimer) {\n    clearInterval(pollTimer)\n    pollTimer = null\n  }\n}\n\nconst stopGraphPolling = () => {\n  if (graphPollTimer) {\n    clearInterval(graphPollTimer)\n    graphPollTimer = null\n    addLog('Graph polling stopped.')\n  }\n}\n\nonMounted(() => {\n  initProject()\n})\n\nonUnmounted(() => {\n  stopPolling()\n  stopGraphPolling()\n})\n</script>\n\n<style scoped>\n.main-view {\n  height: 100vh;\n  display: flex;\n  flex-direction: column;\n  background: #FFF;\n  overflow: hidden;\n  font-family: 'Space Grotesk', 'Noto Sans SC', system-ui, sans-serif;\n}\n\n/* Header */\n.app-header {\n  height: 60px;\n  border-bottom: 1px solid #EAEAEA;\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  padding: 0 24px;\n  background: #FFF;\n  z-index: 100;\n  position: relative;\n}\n\n.header-center {\n  position: absolute;\n  left: 50%;\n  transform: translateX(-50%);\n}\n\n.brand {\n  font-family: 'JetBrains Mono', monospace;\n  font-weight: 800;\n  font-size: 18px;\n  letter-spacing: 1px;\n  cursor: pointer;\n}\n\n.view-switcher {\n  display: flex;\n  background: #F5F5F5;\n  padding: 4px;\n  border-radius: 6px;\n  gap: 4px;\n}\n\n.switch-btn {\n  border: none;\n  background: transparent;\n  padding: 6px 16px;\n  font-size: 12px;\n  font-weight: 600;\n  color: #666;\n  border-radius: 4px;\n  cursor: pointer;\n  transition: all 0.2s;\n}\n\n.switch-btn.active {\n  background: #FFF;\n  color: #000;\n  box-shadow: 0 2px 4px rgba(0,0,0,0.05);\n}\n\n.status-indicator {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  font-size: 12px;\n  color: #666;\n  font-weight: 500;\n}\n\n.header-right {\n  display: flex;\n  align-items: center;\n  gap: 16px;\n}\n\n.workflow-step {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  font-size: 14px;\n}\n\n.step-num {\n  font-family: 'JetBrains Mono', monospace;\n  font-weight: 700;\n  color: #999;\n}\n\n.step-name {\n  font-weight: 700;\n  color: #000;\n}\n\n.step-divider {\n  width: 1px;\n  height: 14px;\n  background-color: #E0E0E0;\n}\n\n.dot {\n  width: 8px;\n  height: 8px;\n  border-radius: 50%;\n  background: #CCC;\n}\n\n.status-indicator.processing .dot { background: #FF5722; animation: pulse 1s infinite; }\n.status-indicator.completed .dot { background: #4CAF50; }\n.status-indicator.error .dot { background: #F44336; }\n\n@keyframes pulse { 50% { opacity: 0.5; } }\n\n/* Content */\n.content-area {\n  flex: 1;\n  display: flex;\n  position: relative;\n  overflow: hidden;\n}\n\n.panel-wrapper {\n  height: 100%;\n  overflow: hidden;\n  transition: width 0.4s cubic-bezier(0.25, 0.8, 0.25, 1), opacity 0.3s ease, transform 0.3s ease;\n  will-change: width, opacity, transform;\n}\n\n.panel-wrapper.left {\n  border-right: 1px solid #EAEAEA;\n}\n</style>\n"
  },
  {
    "path": "frontend/src/views/Process.vue",
    "content": "<template>\n  <div class=\"process-page\">\n    <!-- 顶部导航栏 -->\n    <nav class=\"navbar\">\n      <div class=\"nav-brand\" @click=\"goHome\">MIROFISH</div>\n      \n      <!-- 中间步骤指示器 -->\n      <div class=\"nav-center\">\n        <div class=\"step-badge\">STEP 01</div>\n        <div class=\"step-name\">图谱构建</div>\n      </div>\n\n      <div class=\"nav-status\">\n        <span class=\"status-dot\" :class=\"statusClass\"></span>\n        <span class=\"status-text\">{{ statusText }}</span>\n      </div>\n    </nav>\n\n    <!-- 主内容区 -->\n    <div class=\"main-content\">\n      <!-- 左侧: 实时图谱展示 -->\n      <div class=\"left-panel\" :class=\"{ 'full-screen': isFullScreen }\">\n        <div class=\"panel-header\">\n          <div class=\"header-left\">\n            <span class=\"header-deco\">◆</span>\n            <span class=\"header-title\">实时知识图谱</span>\n          </div>\n          <div class=\"header-right\">\n            <template v-if=\"graphData\">\n              <span class=\"stat-item\">{{ graphData.node_count || graphData.nodes?.length || 0 }} 节点</span>\n              <span class=\"stat-divider\">|</span>\n              <span class=\"stat-item\">{{ graphData.edge_count || graphData.edges?.length || 0 }} 关系</span>\n              <span class=\"stat-divider\">|</span>\n            </template>\n            <div class=\"action-buttons\">\n                <button class=\"action-btn\" @click=\"refreshGraph\" :disabled=\"graphLoading\" title=\"刷新图谱\">\n                  <span class=\"icon-refresh\" :class=\"{ 'spinning': graphLoading }\">↻</span>\n                </button>\n                <button class=\"action-btn\" @click=\"toggleFullScreen\" :title=\"isFullScreen ? '退出全屏' : '全屏显示'\">\n                  <span class=\"icon-fullscreen\">{{ isFullScreen ? '↙' : '↗' }}</span>\n                </button>\n            </div>\n          </div>\n        </div>\n        \n        <div class=\"graph-container\" ref=\"graphContainer\">\n          <!-- 图谱可视化（只要有数据就显示） -->\n          <div v-if=\"graphData\" class=\"graph-view\">\n            <svg ref=\"graphSvg\" class=\"graph-svg\"></svg>\n            <!-- 构建中提示 -->\n            <div v-if=\"currentPhase === 1\" class=\"graph-building-hint\">\n              <span class=\"building-dot\"></span>\n              实时更新中...\n            </div>\n            \n            <!-- 节点/边详情面板 -->\n            <div v-if=\"selectedItem\" class=\"detail-panel\">\n              <div class=\"detail-panel-header\">\n                <span class=\"detail-title\">{{ selectedItem.type === 'node' ? 'Node Details' : 'Relationship' }}</span>\n                <span v-if=\"selectedItem.type === 'node'\" class=\"detail-badge\" :style=\"{ background: selectedItem.color }\">\n                  {{ selectedItem.entityType }}\n                </span>\n                <button class=\"detail-close\" @click=\"closeDetailPanel\">×</button>\n              </div>\n              \n              <!-- 节点详情 -->\n              <div v-if=\"selectedItem.type === 'node'\" class=\"detail-content\">\n                <div class=\"detail-row\">\n                  <span class=\"detail-label\">Name:</span>\n                  <span class=\"detail-value highlight\">{{ selectedItem.data.name }}</span>\n                </div>\n                <div class=\"detail-row\">\n                  <span class=\"detail-label\">UUID:</span>\n                  <span class=\"detail-value uuid\">{{ selectedItem.data.uuid }}</span>\n                </div>\n                <div class=\"detail-row\" v-if=\"selectedItem.data.created_at\">\n                  <span class=\"detail-label\">Created:</span>\n                  <span class=\"detail-value\">{{ formatDate(selectedItem.data.created_at) }}</span>\n                </div>\n                \n                <!-- Properties / Attributes -->\n                <div class=\"detail-section\" v-if=\"selectedItem.data.attributes && Object.keys(selectedItem.data.attributes).length > 0\">\n                  <span class=\"detail-label\">Properties:</span>\n                  <div class=\"properties-list\">\n                    <div v-for=\"(value, key) in selectedItem.data.attributes\" :key=\"key\" class=\"property-item\">\n                      <span class=\"property-key\">{{ key }}:</span>\n                      <span class=\"property-value\">{{ value }}</span>\n                    </div>\n                  </div>\n                </div>\n                \n                <!-- Summary -->\n                <div class=\"detail-section\" v-if=\"selectedItem.data.summary\">\n                  <span class=\"detail-label\">Summary:</span>\n                  <p class=\"detail-summary\">{{ selectedItem.data.summary }}</p>\n                </div>\n                \n                <!-- Labels -->\n                <div class=\"detail-row\" v-if=\"selectedItem.data.labels?.length\">\n                  <span class=\"detail-label\">Labels:</span>\n                  <div class=\"detail-labels\">\n                    <span v-for=\"label in selectedItem.data.labels\" :key=\"label\" class=\"label-tag\">{{ label }}</span>\n                  </div>\n                </div>\n              </div>\n              \n              <!-- 边详情 -->\n              <div v-else class=\"detail-content\">\n                <!-- 关系展示 -->\n                <div class=\"edge-relation\">\n                  <span class=\"edge-source\">{{ selectedItem.data.source_name || selectedItem.data.source_node_name }}</span>\n                  <span class=\"edge-arrow\">→</span>\n                  <span class=\"edge-type\">{{ selectedItem.data.name || selectedItem.data.fact_type || 'RELATED_TO' }}</span>\n                  <span class=\"edge-arrow\">→</span>\n                  <span class=\"edge-target\">{{ selectedItem.data.target_name || selectedItem.data.target_node_name }}</span>\n                </div>\n                \n                <div class=\"detail-subtitle\">Relationship</div>\n                \n                <div class=\"detail-row\">\n                  <span class=\"detail-label\">UUID:</span>\n                  <span class=\"detail-value uuid\">{{ selectedItem.data.uuid }}</span>\n                </div>\n                <div class=\"detail-row\">\n                  <span class=\"detail-label\">Label:</span>\n                  <span class=\"detail-value\">{{ selectedItem.data.name || selectedItem.data.fact_type || 'RELATED_TO' }}</span>\n                </div>\n                <div class=\"detail-row\" v-if=\"selectedItem.data.fact_type\">\n                  <span class=\"detail-label\">Type:</span>\n                  <span class=\"detail-value\">{{ selectedItem.data.fact_type }}</span>\n                </div>\n                \n                <!-- Fact -->\n                <div class=\"detail-section\" v-if=\"selectedItem.data.fact\">\n                  <span class=\"detail-label\">Fact:</span>\n                  <p class=\"detail-summary\">{{ selectedItem.data.fact }}</p>\n                </div>\n                \n                <!-- Episodes -->\n                <div class=\"detail-section\" v-if=\"selectedItem.data.episodes?.length\">\n                  <span class=\"detail-label\">Episodes:</span>\n                  <div class=\"episodes-list\">\n                    <span v-for=\"ep in selectedItem.data.episodes\" :key=\"ep\" class=\"episode-tag\">{{ ep }}</span>\n                  </div>\n                </div>\n                \n                <div class=\"detail-row\" v-if=\"selectedItem.data.created_at\">\n                  <span class=\"detail-label\">Created:</span>\n                  <span class=\"detail-value\">{{ formatDate(selectedItem.data.created_at) }}</span>\n                </div>\n                <div class=\"detail-row\" v-if=\"selectedItem.data.valid_at\">\n                  <span class=\"detail-label\">Valid From:</span>\n                  <span class=\"detail-value\">{{ formatDate(selectedItem.data.valid_at) }}</span>\n                </div>\n                <div class=\"detail-row\" v-if=\"selectedItem.data.invalid_at\">\n                  <span class=\"detail-label\">Invalid At:</span>\n                  <span class=\"detail-value\">{{ formatDate(selectedItem.data.invalid_at) }}</span>\n                </div>\n                <div class=\"detail-row\" v-if=\"selectedItem.data.expired_at\">\n                  <span class=\"detail-label\">Expired At:</span>\n                  <span class=\"detail-value\">{{ formatDate(selectedItem.data.expired_at) }}</span>\n                </div>\n              </div>\n            </div>\n          </div>\n          \n          <!-- 加载状态 -->\n          <div v-else-if=\"graphLoading\" class=\"graph-loading\">\n            <div class=\"loading-animation\">\n              <div class=\"loading-ring\"></div>\n              <div class=\"loading-ring\"></div>\n              <div class=\"loading-ring\"></div>\n            </div>\n            <p class=\"loading-text\">图谱数据加载中...</p>\n          </div>\n          \n          <!-- 等待构建 -->\n          <div v-else-if=\"currentPhase < 1\" class=\"graph-waiting\">\n            <div class=\"waiting-icon\">\n              <svg viewBox=\"0 0 100 100\" class=\"network-icon\">\n                <circle cx=\"50\" cy=\"20\" r=\"8\" fill=\"none\" stroke=\"#000\" stroke-width=\"1.5\"/>\n                <circle cx=\"20\" cy=\"60\" r=\"8\" fill=\"none\" stroke=\"#000\" stroke-width=\"1.5\"/>\n                <circle cx=\"80\" cy=\"60\" r=\"8\" fill=\"none\" stroke=\"#000\" stroke-width=\"1.5\"/>\n                <circle cx=\"50\" cy=\"80\" r=\"8\" fill=\"none\" stroke=\"#000\" stroke-width=\"1.5\"/>\n                <line x1=\"50\" y1=\"28\" x2=\"25\" y2=\"54\" stroke=\"#000\" stroke-width=\"1\"/>\n                <line x1=\"50\" y1=\"28\" x2=\"75\" y2=\"54\" stroke=\"#000\" stroke-width=\"1\"/>\n                <line x1=\"28\" y1=\"60\" x2=\"72\" y2=\"60\" stroke=\"#000\" stroke-width=\"1\" stroke-dasharray=\"4\"/>\n                <line x1=\"50\" y1=\"72\" x2=\"26\" y2=\"66\" stroke=\"#000\" stroke-width=\"1\"/>\n                <line x1=\"50\" y1=\"72\" x2=\"74\" y2=\"66\" stroke=\"#000\" stroke-width=\"1\"/>\n              </svg>\n            </div>\n            <p class=\"waiting-text\">等待本体生成</p>\n            <p class=\"waiting-hint\">生成完成后将自动开始构建图谱</p>\n          </div>\n          \n          <!-- 构建中但还没有数据 -->\n          <div v-else-if=\"currentPhase === 1 && !graphData\" class=\"graph-waiting\">\n            <div class=\"loading-animation\">\n              <div class=\"loading-ring\"></div>\n              <div class=\"loading-ring\"></div>\n              <div class=\"loading-ring\"></div>\n            </div>\n            <p class=\"waiting-text\">图谱构建中</p>\n            <p class=\"waiting-hint\">数据即将显示...</p>\n          </div>\n          \n          <!-- 错误状态 -->\n          <div v-else-if=\"error\" class=\"graph-error\">\n            <span class=\"error-icon\">⚠</span>\n            <p>{{ error }}</p>\n          </div>\n        </div>\n        \n        <!-- 图谱图例 -->\n        <div v-if=\"graphData\" class=\"graph-legend\">\n          <div class=\"legend-item\" v-for=\"type in entityTypes\" :key=\"type.name\">\n            <span class=\"legend-dot\" :style=\"{ background: type.color }\"></span>\n            <span class=\"legend-label\">{{ type.name }}</span>\n            <span class=\"legend-count\">{{ type.count }}</span>\n          </div>\n        </div>\n      </div>\n\n      <!-- 右侧: 构建流程详情 -->\n      <div class=\"right-panel\" :class=\"{ 'hidden': isFullScreen }\">\n        <div class=\"panel-header dark-header\">\n          <span class=\"header-icon\">▣</span>\n          <span class=\"header-title\">构建流程</span>\n        </div>\n\n        <div class=\"process-content\">\n          <!-- 阶段1: 本体生成 -->\n          <div class=\"process-phase\" :class=\"{ 'active': currentPhase === 0, 'completed': currentPhase > 0 }\">\n            <div class=\"phase-header\">\n              <span class=\"phase-num\">01</span>\n              <div class=\"phase-info\">\n                <div class=\"phase-title\">本体生成</div>\n                <div class=\"phase-api\">/api/graph/ontology/generate</div>\n              </div>\n              <span class=\"phase-status\" :class=\"getPhaseStatusClass(0)\">\n                {{ getPhaseStatusText(0) }}\n              </span>\n            </div>\n            \n            <div class=\"phase-detail\">\n              <div class=\"detail-section\">\n                <div class=\"detail-label\">接口说明</div>\n                <div class=\"detail-content\">\n                  上传文档后，LLM分析文档内容，自动生成适合舆论模拟的本体结构（实体类型 + 关系类型）\n                </div>\n              </div>\n              \n              <!-- 本体生成进度 -->\n              <div class=\"detail-section\" v-if=\"ontologyProgress && currentPhase === 0\">\n                <div class=\"detail-label\">生成进度</div>\n                <div class=\"ontology-progress\">\n                  <div class=\"progress-spinner\"></div>\n                  <span class=\"progress-text\">{{ ontologyProgress.message }}</span>\n                </div>\n              </div>\n              \n              <!-- 已生成的本体信息 -->\n              <div class=\"detail-section\" v-if=\"projectData?.ontology\">\n                <div class=\"detail-label\">生成的实体类型 ({{ projectData.ontology.entity_types?.length || 0 }})</div>\n                <div class=\"entity-tags\">\n                  <span \n                    v-for=\"entity in projectData.ontology.entity_types\" \n                    :key=\"entity.name\"\n                    class=\"entity-tag\"\n                  >\n                    {{ entity.name }}\n                  </span>\n                </div>\n              </div>\n              \n              <div class=\"detail-section\" v-if=\"projectData?.ontology\">\n                <div class=\"detail-label\">生成的关系类型 ({{ projectData.ontology.relation_types?.length || 0 }})</div>\n                <div class=\"relation-list\">\n                  <div \n                    v-for=\"(rel, idx) in projectData.ontology.relation_types?.slice(0, 5) || []\" \n                    :key=\"idx\"\n                    class=\"relation-item\"\n                  >\n                    <span class=\"rel-source\">{{ rel.source_type }}</span>\n                    <span class=\"rel-arrow\">→</span>\n                    <span class=\"rel-name\">{{ rel.name }}</span>\n                    <span class=\"rel-arrow\">→</span>\n                    <span class=\"rel-target\">{{ rel.target_type }}</span>\n                  </div>\n                  <div v-if=\"(projectData.ontology.relation_types?.length || 0) > 5\" class=\"relation-more\">\n                    +{{ projectData.ontology.relation_types.length - 5 }} 更多关系...\n                  </div>\n                </div>\n              </div>\n              \n              <!-- 等待状态 -->\n              <div class=\"detail-section waiting-state\" v-if=\"!projectData?.ontology && currentPhase === 0 && !ontologyProgress\">\n                <div class=\"waiting-hint\">等待本体生成...</div>\n              </div>\n            </div>\n          </div>\n\n          <!-- 阶段2: 图谱构建 -->\n          <div class=\"process-phase\" :class=\"{ 'active': currentPhase === 1, 'completed': currentPhase > 1 }\">\n            <div class=\"phase-header\">\n              <span class=\"phase-num\">02</span>\n              <div class=\"phase-info\">\n                <div class=\"phase-title\">图谱构建</div>\n                <div class=\"phase-api\">/api/graph/build</div>\n              </div>\n              <span class=\"phase-status\" :class=\"getPhaseStatusClass(1)\">\n                {{ getPhaseStatusText(1) }}\n              </span>\n            </div>\n            \n            <div class=\"phase-detail\">\n              <div class=\"detail-section\">\n                <div class=\"detail-label\">接口说明</div>\n                <div class=\"detail-content\">\n                  基于生成的本体，将文档分块后调用 Zep API 构建知识图谱，提取实体和关系\n                </div>\n              </div>\n              \n              <!-- 等待本体完成 -->\n              <div class=\"detail-section waiting-state\" v-if=\"currentPhase < 1\">\n                <div class=\"waiting-hint\">等待本体生成完成...</div>\n              </div>\n              \n              <!-- 构建进度 -->\n              <div class=\"detail-section\" v-if=\"buildProgress && currentPhase >= 1\">\n                <div class=\"detail-label\">构建进度</div>\n                <div class=\"progress-bar\">\n                  <div class=\"progress-fill\" :style=\"{ width: buildProgress.progress + '%' }\"></div>\n                </div>\n                <div class=\"progress-info\">\n                  <span class=\"progress-message\">{{ buildProgress.message }}</span>\n                  <span class=\"progress-percent\">{{ buildProgress.progress }}%</span>\n                </div>\n              </div>\n              \n              <div class=\"detail-section\" v-if=\"graphData\">\n                <div class=\"detail-label\">构建结果</div>\n                <div class=\"build-result\">\n                  <div class=\"result-item\">\n                    <span class=\"result-value\">{{ graphData.node_count }}</span>\n                    <span class=\"result-label\">实体节点</span>\n                  </div>\n                  <div class=\"result-item\">\n                    <span class=\"result-value\">{{ graphData.edge_count }}</span>\n                    <span class=\"result-label\">关系边</span>\n                  </div>\n                  <div class=\"result-item\">\n                    <span class=\"result-value\">{{ entityTypes.length }}</span>\n                    <span class=\"result-label\">实体类型</span>\n                  </div>\n                </div>\n              </div>\n            </div>\n          </div>\n\n          <!-- 阶段3: 完成 -->\n          <div class=\"process-phase\" :class=\"{ 'active': currentPhase === 2, 'completed': currentPhase > 2 }\">\n            <div class=\"phase-header\">\n              <span class=\"phase-num\">03</span>\n              <div class=\"phase-info\">\n                <div class=\"phase-title\">构建完成</div>\n                <div class=\"phase-api\">准备进入下一步骤</div>\n              </div>\n              <span class=\"phase-status\" :class=\"getPhaseStatusClass(2)\">\n                {{ getPhaseStatusText(2) }}\n              </span>\n            </div>\n          </div>\n\n          <!-- 下一步按钮 -->\n          <div class=\"next-step-section\" v-if=\"currentPhase >= 2\">\n            <button class=\"next-step-btn\" @click=\"goToNextStep\" :disabled=\"currentPhase < 2\">\n              进入环境搭建\n              <span class=\"btn-arrow\">→</span>\n            </button>\n          </div>\n        </div>\n\n        <!-- 项目信息面板 -->\n        <div class=\"project-panel\">\n          <div class=\"project-header\">\n            <span class=\"project-icon\">◇</span>\n            <span class=\"project-title\">项目信息</span>\n          </div>\n          <div class=\"project-details\" v-if=\"projectData\">\n            <div class=\"project-item\">\n              <span class=\"item-label\">项目名称</span>\n              <span class=\"item-value\">{{ projectData.name }}</span>\n            </div>\n            <div class=\"project-item\">\n              <span class=\"item-label\">项目ID</span>\n              <span class=\"item-value code\">{{ projectData.project_id }}</span>\n            </div>\n            <div class=\"project-item\" v-if=\"projectData.graph_id\">\n              <span class=\"item-label\">图谱ID</span>\n              <span class=\"item-value code\">{{ projectData.graph_id }}</span>\n            </div>\n            <div class=\"project-item\">\n              <span class=\"item-label\">模拟需求</span>\n              <span class=\"item-value\">{{ projectData.simulation_requirement || '-' }}</span>\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n  </div>\n</template>\n\n<script setup>\nimport { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'\nimport { useRoute, useRouter } from 'vue-router'\nimport { generateOntology, getProject, buildGraph, getTaskStatus, getGraphData } from '../api/graph'\nimport { getPendingUpload, clearPendingUpload } from '../store/pendingUpload'\nimport * as d3 from 'd3'\n\nconst route = useRoute()\nconst router = useRouter()\n\n// 当前项目ID（可能从'new'变为实际ID）\nconst currentProjectId = ref(route.params.projectId)\n\n// 状态\nconst loading = ref(true)\nconst graphLoading = ref(false)\nconst error = ref('')\nconst projectData = ref(null)\nconst graphData = ref(null)\nconst buildProgress = ref(null)\nconst ontologyProgress = ref(null) // 本体生成进度\nconst currentPhase = ref(-1) // -1: 上传中, 0: 本体生成中, 1: 图谱构建, 2: 完成\nconst selectedItem = ref(null) // 选中的节点或边\nconst isFullScreen = ref(false)\n\n// DOM引用\nconst graphContainer = ref(null)\nconst graphSvg = ref(null)\n\n// 轮询定时器\nlet pollTimer = null\n\n// 计算属性\nconst statusClass = computed(() => {\n  if (error.value) return 'error'\n  if (currentPhase.value >= 2) return 'completed'\n  return 'processing'\n})\n\nconst statusText = computed(() => {\n  if (error.value) return '构建失败'\n  if (currentPhase.value >= 2) return '构建完成'\n  if (currentPhase.value === 1) return '图谱构建中'\n  if (currentPhase.value === 0) return '本体生成中'\n  return '初始化中'\n})\n\nconst entityTypes = computed(() => {\n  if (!graphData.value?.nodes) return []\n  \n  const typeMap = {}\n  const colors = ['#FF6B35', '#004E89', '#7B2D8E', '#1A936F', '#C5283D', '#E9724C']\n  \n  graphData.value.nodes.forEach(node => {\n    const type = node.labels?.find(l => l !== 'Entity') || 'Entity'\n    if (!typeMap[type]) {\n      typeMap[type] = { name: type, count: 0, color: colors[Object.keys(typeMap).length % colors.length] }\n    }\n    typeMap[type].count++\n  })\n  \n  return Object.values(typeMap)\n})\n\n// 方法\nconst goHome = () => {\n  router.push('/')\n}\n\nconst goToNextStep = () => {\n  // TODO: 进入环境搭建步骤\n  alert('环境搭建功能开发中...')\n}\n\nconst toggleFullScreen = () => {\n  isFullScreen.value = !isFullScreen.value\n  // Wait for transition to finish then re-render graph\n  setTimeout(() => {\n    renderGraph()\n  }, 350) \n}\n\n// 关闭详情面板\nconst closeDetailPanel = () => {\n  selectedItem.value = null\n}\n\n// 格式化日期\nconst formatDate = (dateStr) => {\n  if (!dateStr) return '-'\n  try {\n    const date = new Date(dateStr)\n    return date.toLocaleString('zh-CN', {\n      year: 'numeric',\n      month: 'short',\n      day: 'numeric',\n      hour: '2-digit',\n      minute: '2-digit'\n    })\n  } catch {\n    return dateStr\n  }\n}\n\n// 选中节点\nconst selectNode = (nodeData, color) => {\n  selectedItem.value = {\n    type: 'node',\n    data: nodeData,\n    color: color,\n    entityType: nodeData.labels?.find(l => l !== 'Entity' && l !== 'Node') || 'Entity'\n  }\n}\n\n// 选中边\nconst selectEdge = (edgeData) => {\n  selectedItem.value = {\n    type: 'edge',\n    data: edgeData\n  }\n}\n\nconst getPhaseStatusClass = (phase) => {\n  if (currentPhase.value > phase) return 'completed'\n  if (currentPhase.value === phase) return 'active'\n  return 'pending'\n}\n\nconst getPhaseStatusText = (phase) => {\n  if (currentPhase.value > phase) return '已完成'\n  if (currentPhase.value === phase) {\n    if (phase === 1 && buildProgress.value) {\n      return `${buildProgress.value.progress}%`\n    }\n    return '进行中'\n  }\n  return '等待中'\n}\n\n// 初始化 - 处理新建项目或加载已有项目\nconst initProject = async () => {\n  const paramProjectId = route.params.projectId\n  \n  if (paramProjectId === 'new') {\n    // 新建项目：从 store 获取待上传的数据\n    await handleNewProject()\n  } else {\n    // 加载已有项目\n    currentProjectId.value = paramProjectId\n    await loadProject()\n  }\n}\n\n// 处理新建项目 - 调用 ontology/generate API\nconst handleNewProject = async () => {\n  const pending = getPendingUpload()\n  \n  if (!pending.isPending || pending.files.length === 0) {\n    error.value = '没有待上传的文件，请返回首页重新操作'\n    loading.value = false\n    return\n  }\n  \n  try {\n    loading.value = true\n    currentPhase.value = 0 // 本体生成阶段\n    ontologyProgress.value = { message: '正在上传文件并分析文档...' }\n    \n    // 构建 FormData\n    const formDataObj = new FormData()\n    pending.files.forEach(file => {\n      formDataObj.append('files', file)\n    })\n    formDataObj.append('simulation_requirement', pending.simulationRequirement)\n    \n    // 调用本体生成 API\n    const response = await generateOntology(formDataObj)\n    \n    if (response.success) {\n      // 清除待上传数据\n      clearPendingUpload()\n      \n      // 更新项目ID和数据\n      currentProjectId.value = response.data.project_id\n      projectData.value = response.data\n      \n      // 更新URL（不刷新页面）\n      router.replace({\n        name: 'Process',\n        params: { projectId: response.data.project_id }\n      })\n      \n      ontologyProgress.value = null\n      \n      // 自动开始图谱构建\n      await startBuildGraph()\n    } else {\n      error.value = response.error || '本体生成失败'\n    }\n  } catch (err) {\n    console.error('Handle new project error:', err)\n    error.value = '项目初始化失败: ' + (err.message || '未知错误')\n  } finally {\n    loading.value = false\n  }\n}\n\n// 加载已有项目数据\nconst loadProject = async () => {\n  try {\n    loading.value = true\n    const response = await getProject(currentProjectId.value)\n    \n    if (response.success) {\n      projectData.value = response.data\n      updatePhaseByStatus(response.data.status)\n      \n      // 自动开始图谱构建\n      if (response.data.status === 'ontology_generated' && !response.data.graph_id) {\n        await startBuildGraph()\n      }\n      \n      // 继续轮询构建中的任务\n      if (response.data.status === 'graph_building' && response.data.graph_build_task_id) {\n        currentPhase.value = 1\n        startPollingTask(response.data.graph_build_task_id)\n      }\n      \n      // 加载已完成的图谱\n      if (response.data.status === 'graph_completed' && response.data.graph_id) {\n        currentPhase.value = 2\n        await loadGraph(response.data.graph_id)\n      }\n    } else {\n      error.value = response.error || '加载项目失败'\n    }\n  } catch (err) {\n    console.error('Load project error:', err)\n    error.value = '加载项目失败: ' + (err.message || '未知错误')\n  } finally {\n    loading.value = false\n  }\n}\n\nconst updatePhaseByStatus = (status) => {\n  switch (status) {\n    case 'created':\n    case 'ontology_generated':\n      currentPhase.value = 0\n      break\n    case 'graph_building':\n      currentPhase.value = 1\n      break\n    case 'graph_completed':\n      currentPhase.value = 2\n      break\n    case 'failed':\n      error.value = projectData.value?.error || '处理失败'\n      break\n  }\n}\n\n// 开始构建图谱\nconst startBuildGraph = async () => {\n  try {\n    currentPhase.value = 1\n    // 设置初始进度\n    buildProgress.value = {\n      progress: 0,\n      message: '正在启动图谱构建...'\n    }\n    \n    const response = await buildGraph({ project_id: currentProjectId.value })\n    \n    if (response.success) {\n      buildProgress.value.message = '图谱构建任务已启动...'\n      \n      // 保存 task_id 用于轮询\n      const taskId = response.data.task_id\n      \n      // 启动图谱数据轮询（独立于任务状态轮询）\n      startGraphPolling()\n      \n      // 启动任务状态轮询\n      startPollingTask(taskId)\n    } else {\n      error.value = response.error || '启动图谱构建失败'\n      buildProgress.value = null\n    }\n  } catch (err) {\n    console.error('Build graph error:', err)\n    error.value = '启动图谱构建失败: ' + (err.message || '未知错误')\n    buildProgress.value = null\n  }\n}\n\n// 图谱数据轮询定时器\nlet graphPollTimer = null\n\n// 启动图谱数据轮询\nconst startGraphPolling = () => {\n  // 立即获取一次\n  fetchGraphData()\n  \n  // 每 10 秒自动获取一次图谱数据\n  graphPollTimer = setInterval(async () => {\n    await fetchGraphData()\n  }, 10000)\n}\n\n// 手动刷新图谱\nconst refreshGraph = async () => {\n  graphLoading.value = true\n  await fetchGraphData()\n  graphLoading.value = false\n}\n\n// 停止图谱数据轮询\nconst stopGraphPolling = () => {\n  if (graphPollTimer) {\n    clearInterval(graphPollTimer)\n    graphPollTimer = null\n  }\n}\n\n// 获取图谱数据\nconst fetchGraphData = async () => {\n  try {\n    // 先获取项目信息以获取 graph_id\n    const projectResponse = await getProject(currentProjectId.value)\n    \n    if (projectResponse.success && projectResponse.data.graph_id) {\n      const graphId = projectResponse.data.graph_id\n      projectData.value = projectResponse.data\n      \n      // 获取图谱数据\n      const graphResponse = await getGraphData(graphId)\n      \n      if (graphResponse.success && graphResponse.data) {\n        const newData = graphResponse.data\n        const newNodeCount = newData.node_count || newData.nodes?.length || 0\n        const oldNodeCount = graphData.value?.node_count || graphData.value?.nodes?.length || 0\n        \n        console.log('Fetching graph data, nodes:', newNodeCount, 'edges:', newData.edge_count || newData.edges?.length || 0)\n        \n        // 数据有变化时更新渲染\n        if (newNodeCount !== oldNodeCount || !graphData.value) {\n          graphData.value = newData\n          await nextTick()\n          renderGraph()\n        }\n      }\n    }\n  } catch (err) {\n    console.log('Graph data fetch:', err.message || 'not ready')\n  }\n}\n\n// 轮询任务状态\nconst startPollingTask = (taskId) => {\n  // 立即执行一次查询\n  pollTaskStatus(taskId)\n  \n  // 然后定时轮询\n  pollTimer = setInterval(() => {\n    pollTaskStatus(taskId)\n  }, 2000)\n}\n\n// 查询任务状态\nconst pollTaskStatus = async (taskId) => {\n  try {\n    const response = await getTaskStatus(taskId)\n    \n    if (response.success) {\n      const task = response.data\n      \n      // 更新进度显示\n      buildProgress.value = {\n        progress: task.progress || 0,\n        message: task.message || '处理中...'\n      }\n      \n      console.log('Task status:', task.status, 'Progress:', task.progress)\n      \n      if (task.status === 'completed') {\n        console.log('✅ 图谱构建完成，正在加载完整数据...')\n        \n        stopPolling()\n        stopGraphPolling()\n        currentPhase.value = 2\n        \n        // 更新进度显示为完成状态\n        buildProgress.value = {\n          progress: 100,\n          message: '构建完成，正在加载图谱...'\n        }\n        \n        // 重新加载项目数据获取 graph_id\n        const projectResponse = await getProject(currentProjectId.value)\n        if (projectResponse.success) {\n          projectData.value = projectResponse.data\n          \n          // 最终加载完整图谱数据\n          if (projectResponse.data.graph_id) {\n            console.log('📊 加载完整图谱:', projectResponse.data.graph_id)\n            await loadGraph(projectResponse.data.graph_id)\n            console.log('✅ 图谱加载完成')\n          }\n        }\n        \n        // 清除进度显示\n        buildProgress.value = null\n      } else if (task.status === 'failed') {\n        stopPolling()\n        stopGraphPolling()\n        error.value = '图谱构建失败: ' + (task.error || '未知错误')\n        buildProgress.value = null\n      }\n    }\n  } catch (err) {\n    console.error('Poll task error:', err)\n  }\n}\n\nconst stopPolling = () => {\n  if (pollTimer) {\n    clearInterval(pollTimer)\n    pollTimer = null\n  }\n}\n\n// 加载图谱数据\nconst loadGraph = async (graphId) => {\n  try {\n    graphLoading.value = true\n    const response = await getGraphData(graphId)\n    \n    if (response.success) {\n      graphData.value = response.data\n      await nextTick()\n      renderGraph()\n    }\n  } catch (err) {\n    console.error('Load graph error:', err)\n  } finally {\n    graphLoading.value = false\n  }\n}\n\n// 渲染图谱 (D3.js)\nconst renderGraph = () => {\n  if (!graphSvg.value || !graphData.value) {\n    console.log('Cannot render: svg or data missing')\n    return\n  }\n  \n  const container = graphContainer.value\n  if (!container) {\n    console.log('Cannot render: container missing')\n    return\n  }\n  \n  // 获取容器尺寸\n  const rect = container.getBoundingClientRect()\n  const width = rect.width || 800\n  const height = (rect.height || 600) - 60\n  \n  if (width <= 0 || height <= 0) {\n    console.log('Cannot render: invalid dimensions', width, height)\n    return\n  }\n  \n  console.log('Rendering graph:', width, 'x', height)\n  \n  const svg = d3.select(graphSvg.value)\n    .attr('width', width)\n    .attr('height', height)\n    .attr('viewBox', `0 0 ${width} ${height}`)\n  \n  svg.selectAll('*').remove()\n  \n  // 处理节点数据\n  const nodesData = graphData.value.nodes || []\n  const edgesData = graphData.value.edges || []\n  \n  if (nodesData.length === 0) {\n    console.log('No nodes to render')\n    // 显示空状态\n    svg.append('text')\n      .attr('x', width / 2)\n      .attr('y', height / 2)\n      .attr('text-anchor', 'middle')\n      .attr('fill', '#999')\n      .text('等待图谱数据...')\n    return\n  }\n  \n  // 创建节点映射用于查找名称\n  const nodeMap = {}\n  nodesData.forEach(n => {\n    nodeMap[n.uuid] = n\n  })\n  \n  const nodes = nodesData.map(n => ({\n    id: n.uuid,\n    name: n.name || '未命名',\n    type: n.labels?.find(l => l !== 'Entity' && l !== 'Node') || 'Entity',\n    rawData: n // 保存原始数据\n  }))\n  \n  // 创建节点ID集合用于过滤有效边\n  const nodeIds = new Set(nodes.map(n => n.id))\n  \n  const edges = edgesData\n    .filter(e => nodeIds.has(e.source_node_uuid) && nodeIds.has(e.target_node_uuid))\n    .map(e => ({\n      source: e.source_node_uuid,\n      target: e.target_node_uuid,\n      type: e.fact_type || e.name || 'RELATED_TO',\n      rawData: {\n        ...e,\n        source_name: nodeMap[e.source_node_uuid]?.name || '未知',\n        target_name: nodeMap[e.target_node_uuid]?.name || '未知'\n      }\n    }))\n  \n  console.log('Nodes:', nodes.length, 'Edges:', edges.length)\n  \n  // 颜色映射\n  const types = [...new Set(nodes.map(n => n.type))]\n  const colorScale = d3.scaleOrdinal()\n    .domain(types)\n    .range(['#FF6B35', '#004E89', '#7B2D8E', '#1A936F', '#C5283D', '#E9724C', '#2D3436', '#6C5CE7'])\n  \n  // 力导向布局\n  const simulation = d3.forceSimulation(nodes)\n    .force('link', d3.forceLink(edges).id(d => d.id).distance(100).strength(0.5))\n    .force('charge', d3.forceManyBody().strength(-300))\n    .force('center', d3.forceCenter(width / 2, height / 2))\n    .force('collision', d3.forceCollide().radius(40))\n    .force('x', d3.forceX(width / 2).strength(0.05))\n    .force('y', d3.forceY(height / 2).strength(0.05))\n  \n  // 添加缩放功能\n  const g = svg.append('g')\n  \n  svg.call(d3.zoom()\n    .extent([[0, 0], [width, height]])\n    .scaleExtent([0.2, 4])\n    .on('zoom', (event) => {\n      g.attr('transform', event.transform)\n    }))\n  \n  // 绘制边（包含可点击的透明宽线）\n  const linkGroup = g.append('g')\n    .attr('class', 'links')\n    .selectAll('g')\n    .data(edges)\n    .enter()\n    .append('g')\n    .style('cursor', 'pointer')\n    .on('click', (event, d) => {\n      event.stopPropagation()\n      selectEdge(d.rawData)\n    })\n  \n  // 可见的细线\n  const link = linkGroup.append('line')\n    .attr('stroke', '#ccc')\n    .attr('stroke-width', 1.5)\n    .attr('stroke-opacity', 0.6)\n  \n  // 透明的宽线用于点击\n  linkGroup.append('line')\n    .attr('stroke', 'transparent')\n    .attr('stroke-width', 10)\n  \n  // 边标签\n  const linkLabel = g.append('g')\n    .attr('class', 'link-labels')\n    .selectAll('text')\n    .data(edges)\n    .enter()\n    .append('text')\n    .attr('font-size', '9px')\n    .attr('fill', '#999')\n    .attr('text-anchor', 'middle')\n    .text(d => d.type.length > 15 ? d.type.substring(0, 12) + '...' : d.type)\n  \n  // 绘制节点\n  const node = g.append('g')\n    .attr('class', 'nodes')\n    .selectAll('g')\n    .data(nodes)\n    .enter()\n    .append('g')\n    .style('cursor', 'pointer')\n    .on('click', (event, d) => {\n      event.stopPropagation()\n      selectNode(d.rawData, colorScale(d.type))\n    })\n    .call(d3.drag()\n      .on('start', dragstarted)\n      .on('drag', dragged)\n      .on('end', dragended))\n  \n  node.append('circle')\n    .attr('r', 10)\n    .attr('fill', d => colorScale(d.type))\n    .attr('stroke', '#fff')\n    .attr('stroke-width', 2)\n    .attr('class', 'node-circle')\n  \n  node.append('text')\n    .attr('dx', 14)\n    .attr('dy', 4)\n    .text(d => d.name?.substring(0, 12) || '')\n    .attr('font-size', '11px')\n    .attr('fill', '#333')\n    .attr('font-family', 'JetBrains Mono, monospace')\n  \n  // 点击空白处关闭详情面板\n  svg.on('click', () => {\n    closeDetailPanel()\n  })\n  \n  simulation.on('tick', () => {\n    // 更新所有边的位置（包括可见线和透明点击区域）\n    linkGroup.selectAll('line')\n      .attr('x1', d => d.source.x)\n      .attr('y1', d => d.source.y)\n      .attr('x2', d => d.target.x)\n      .attr('y2', d => d.target.y)\n    \n    // 更新边标签位置\n    linkLabel\n      .attr('x', d => (d.source.x + d.target.x) / 2)\n      .attr('y', d => (d.source.y + d.target.y) / 2 - 5)\n    \n    node.attr('transform', d => `translate(${d.x},${d.y})`)\n  })\n  \n  function dragstarted(event) {\n    if (!event.active) simulation.alphaTarget(0.3).restart()\n    event.subject.fx = event.subject.x\n    event.subject.fy = event.subject.y\n  }\n  \n  function dragged(event) {\n    event.subject.fx = event.x\n    event.subject.fy = event.y\n  }\n  \n  function dragended(event) {\n    if (!event.active) simulation.alphaTarget(0)\n    event.subject.fx = null\n    event.subject.fy = null\n  }\n}\n\n// 监听图谱数据变化\nwatch(graphData, () => {\n  if (graphData.value) {\n    nextTick(() => renderGraph())\n  }\n})\n\n// 生命周期\nonMounted(() => {\n  initProject()\n})\n\nonUnmounted(() => {\n  stopPolling()\n  stopGraphPolling()\n})\n</script>\n\n<style scoped>\n/* 变量 */\n:root {\n  --black: #000000;\n  --white: #FFFFFF;\n  --orange: #FF6B35;\n  --gray-light: #F5F5F5;\n  --gray-border: #E0E0E0;\n  --gray-text: #666666;\n}\n\n.process-page {\n  min-height: 100vh;\n  background: var(--white);\n  font-family: 'JetBrains Mono', 'Noto Sans SC', monospace;\n  overflow: hidden; /* Prevent body scroll in fullscreen */\n}\n\n/* 导航栏 */\n.navbar {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  padding: 0 24px;\n  height: 56px;\n  background: #000;\n  color: #fff;\n  z-index: 10;\n  position: relative;\n}\n\n.nav-brand {\n  font-size: 1rem;\n  font-weight: 700;\n  letter-spacing: 0.1em;\n  cursor: pointer;\n  transition: opacity 0.2s;\n}\n\n.nav-brand:hover {\n  opacity: 0.8;\n}\n\n.nav-center {\n  display: flex;\n  align-items: center;\n  gap: 12px;\n  position: absolute;\n  left: 50%;\n  transform: translateX(-50%);\n}\n\n.step-badge {\n  background: #FF6B35;\n  color: #fff;\n  padding: 2px 8px;\n  font-size: 0.7rem;\n  font-weight: 600;\n  letter-spacing: 0.05em;\n  border-radius: 2px;\n}\n\n.step-name {\n  font-size: 0.85rem;\n  letter-spacing: 0.05em;\n  color: #fff;\n}\n\n.nav-status {\n  display: flex;\n  align-items: center;\n}\n\n.status-dot {\n  width: 6px;\n  height: 6px;\n  border-radius: 50%;\n  background: #666;\n  margin-right: 8px;\n}\n\n.status-dot.processing {\n  background: #FF6B35;\n  animation: pulse 1.5s infinite;\n}\n\n.status-dot.completed {\n  background: #1A936F;\n}\n\n.status-dot.error {\n  background: #C5283D;\n}\n\n@keyframes pulse {\n  0%, 100% { opacity: 1; }\n  50% { opacity: 0.5; }\n}\n\n.status-text {\n  font-size: 0.75rem;\n  color: #999;\n}\n\n/* 主内容区 */\n.main-content {\n  display: flex;\n  height: calc(100vh - 56px);\n  position: relative;\n}\n\n/* 左侧面板 - 50% default */\n.left-panel {\n  width: 50%;\n  flex: none; /* Fixed width initially */\n  display: flex;\n  flex-direction: column;\n  border-right: 1px solid #E0E0E0;\n  transition: width 0.35s cubic-bezier(0.4, 0, 0.2, 1);\n  background: #fff;\n  z-index: 5;\n}\n\n.left-panel.full-screen {\n  width: 100%;\n  border-right: none;\n}\n\n.panel-header {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  padding: 12px 24px;\n  border-bottom: 1px solid #E0E0E0;\n  background: #fff;\n  height: 50px;\n}\n\n.header-left {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n}\n\n.header-deco {\n  color: #FF6B35;\n  font-size: 0.8rem;\n}\n\n.header-title {\n  font-size: 0.85rem;\n  font-weight: 600;\n  letter-spacing: 0.05em;\n}\n\n.header-right {\n  display: flex;\n  align-items: center;\n  gap: 16px;\n  font-size: 0.75rem;\n  color: #666;\n}\n\n.stat-item {\n  display: flex;\n  align-items: center;\n  gap: 4px;\n}\n\n.stat-val {\n  font-weight: 600;\n  color: #333;\n}\n\n.stat-divider {\n  color: #eee;\n}\n\n.action-buttons {\n    display: flex;\n    align-items: center;\n    gap: 8px;\n}\n\n.action-btn {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  width: 24px;\n  height: 24px;\n  background: transparent;\n  border: 1px solid transparent;\n  cursor: pointer;\n  transition: all 0.2s;\n  color: #666;\n  border-radius: 2px;\n}\n\n.action-btn:hover:not(:disabled) {\n  background: #F5F5F5;\n  color: #000;\n}\n\n.action-btn:disabled {\n  opacity: 0.3;\n  cursor: not-allowed;\n}\n\n.icon-refresh, .icon-fullscreen {\n  font-size: 1rem;\n  line-height: 1;\n}\n\n.icon-refresh.spinning {\n  animation: spin 1s linear infinite;\n}\n\n@keyframes spin {\n  to { transform: rotate(360deg); }\n}\n\n/* 图谱容器 */\n.graph-container {\n  flex: 1;\n  position: relative;\n  overflow: hidden;\n}\n\n.graph-loading,\n.graph-waiting,\n.graph-error {\n  position: absolute;\n  top: 50%;\n  left: 50%;\n  transform: translate(-50%, -50%);\n  text-align: center;\n}\n\n.loading-animation {\n  position: relative;\n  width: 80px;\n  height: 80px;\n  margin: 0 auto 20px;\n}\n\n.loading-ring {\n  position: absolute;\n  border: 2px solid transparent;\n  border-radius: 50%;\n  animation: ring-rotate 1.5s linear infinite;\n}\n\n.loading-ring:nth-child(1) {\n  width: 80px;\n  height: 80px;\n  border-top-color: #000;\n}\n\n.loading-ring:nth-child(2) {\n  width: 60px;\n  height: 60px;\n  top: 10px;\n  left: 10px;\n  border-right-color: #FF6B35;\n  animation-delay: 0.2s;\n}\n\n.loading-ring:nth-child(3) {\n  width: 40px;\n  height: 40px;\n  top: 20px;\n  left: 20px;\n  border-bottom-color: #666;\n  animation-delay: 0.4s;\n}\n\n@keyframes ring-rotate {\n  to { transform: rotate(360deg); }\n}\n\n.loading-text,\n.waiting-text {\n  font-size: 0.9rem;\n  color: #333;\n  margin: 0 0 8px;\n}\n\n.waiting-hint {\n  font-size: 0.8rem;\n  color: #999;\n  margin: 0;\n}\n\n.waiting-icon {\n  margin-bottom: 20px;\n}\n\n.network-icon {\n  width: 100px;\n  height: 100px;\n  opacity: 0.6;\n}\n\n.graph-view {\n  width: 100%;\n  height: 100%;\n  position: relative;\n}\n\n.graph-svg {\n  width: 100%;\n  height: 100%;\n  display: block;\n}\n\n.graph-building-hint {\n  position: absolute;\n  bottom: 16px;\n  left: 16px;\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  padding: 8px 16px;\n  background: rgba(255, 107, 53, 0.1);\n  border: 1px solid #FF6B35;\n  font-size: 0.8rem;\n  color: #FF6B35;\n}\n\n.building-dot {\n  width: 8px;\n  height: 8px;\n  background: #FF6B35;\n  border-radius: 50%;\n  animation: pulse 1s infinite;\n}\n\n/* 节点/边详情面板 */\n.detail-panel {\n  position: absolute;\n  top: 16px;\n  right: 16px;\n  width: 320px;\n  max-height: calc(100% - 32px);\n  background: #fff;\n  border: 1px solid #E0E0E0;\n  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);\n  overflow: hidden;\n  display: flex;\n  flex-direction: column;\n  z-index: 100;\n}\n\n.detail-panel-header {\n  display: flex;\n  align-items: center;\n  gap: 10px;\n  padding: 12px 16px;\n  background: #FAFAFA;\n  border-bottom: 1px solid #E0E0E0;\n}\n\n.detail-title {\n  font-size: 0.9rem;\n  font-weight: 600;\n  color: #333;\n}\n\n.detail-badge {\n  padding: 2px 10px;\n  font-size: 0.75rem;\n  color: #fff;\n  border-radius: 2px;\n}\n\n.detail-close {\n  margin-left: auto;\n  width: 24px;\n  height: 24px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  background: none;\n  border: none;\n  font-size: 1.2rem;\n  color: #999;\n  cursor: pointer;\n  transition: color 0.2s;\n}\n\n.detail-close:hover {\n  color: #333;\n}\n\n.detail-content {\n  padding: 16px;\n  overflow-y: auto;\n  flex: 1;\n}\n\n.detail-row {\n  display: flex;\n  align-items: flex-start;\n  margin-bottom: 12px;\n}\n\n.detail-label {\n  font-size: 0.8rem;\n  color: #999;\n  min-width: 70px;\n  flex-shrink: 0;\n}\n\n.detail-value {\n  font-size: 0.85rem;\n  color: #333;\n  word-break: break-word;\n}\n\n.detail-value.uuid {\n  font-family: 'JetBrains Mono', monospace;\n  font-size: 0.75rem;\n  color: #666;\n}\n\n.detail-section {\n  margin-bottom: 12px;\n}\n\n.detail-summary {\n  margin: 8px 0 0 0;\n  font-size: 0.85rem;\n  color: #333;\n  line-height: 1.6;\n  padding: 10px;\n  background: #F9F9F9;\n  border-left: 3px solid #FF6B35;\n}\n\n.detail-labels {\n  display: flex;\n  flex-wrap: wrap;\n  gap: 6px;\n}\n\n.label-tag {\n  padding: 2px 8px;\n  font-size: 0.75rem;\n  background: #F0F0F0;\n  border: 1px solid #E0E0E0;\n  color: #666;\n}\n\n/* 边详情关系展示 */\n.edge-relation {\n  display: flex;\n  align-items: center;\n  flex-wrap: wrap;\n  gap: 8px;\n  margin-bottom: 16px;\n  padding: 12px;\n  background: #F9F9F9;\n  border: 1px solid #E0E0E0;\n}\n\n.edge-source,\n.edge-target {\n  font-size: 0.85rem;\n  font-weight: 500;\n  color: #333;\n}\n\n.edge-arrow {\n  color: #999;\n}\n\n.edge-type {\n  padding: 2px 8px;\n  font-size: 0.75rem;\n  background: #FF6B35;\n  color: #fff;\n}\n\n.detail-value.highlight {\n  font-weight: 600;\n  color: #000;\n}\n\n.detail-subtitle {\n  font-size: 0.9rem;\n  font-weight: 600;\n  color: #333;\n  margin: 16px 0 12px 0;\n  padding-bottom: 8px;\n  border-bottom: 1px solid #E0E0E0;\n}\n\n/* Properties 属性列表 */\n.properties-list {\n  margin-top: 8px;\n  padding: 10px;\n  background: #F9F9F9;\n  border: 1px solid #E0E0E0;\n}\n\n.property-item {\n  display: flex;\n  margin-bottom: 6px;\n  font-size: 0.85rem;\n}\n\n.property-item:last-child {\n  margin-bottom: 0;\n}\n\n.property-key {\n  color: #666;\n  margin-right: 8px;\n  font-family: 'JetBrains Mono', monospace;\n}\n\n.property-value {\n  color: #333;\n  word-break: break-word;\n}\n\n/* Episodes 列表 */\n.episodes-list {\n  margin-top: 8px;\n  display: flex;\n  flex-direction: column;\n  gap: 6px;\n}\n\n.episode-tag {\n  display: block;\n  padding: 6px 10px;\n  font-size: 0.75rem;\n  font-family: 'JetBrains Mono', monospace;\n  background: #F0F0F0;\n  border: 1px solid #E0E0E0;\n  color: #666;\n  word-break: break-all;\n}\n\n.error-icon {\n  font-size: 2rem;\n  display: block;\n  margin-bottom: 10px;\n}\n\n/* 图谱图例 */\n.graph-legend {\n  display: flex;\n  flex-wrap: wrap;\n  gap: 16px;\n  padding: 12px 24px;\n  border-top: 1px solid #E0E0E0;\n  background: #FAFAFA;\n}\n\n.legend-item {\n  display: flex;\n  align-items: center;\n  gap: 6px;\n  font-size: 0.75rem;\n}\n\n.legend-dot {\n  width: 10px;\n  height: 10px;\n  border-radius: 50%;\n}\n\n.legend-label {\n  color: #333;\n}\n\n.legend-count {\n  color: #999;\n}\n\n/* 右侧面板 - 50% default */\n.right-panel {\n  width: 50%;\n  flex: none;\n  display: flex;\n  flex-direction: column;\n  background: #fff;\n  transition: width 0.35s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s ease, transform 0.3s ease;\n  overflow: hidden;\n  opacity: 1;\n}\n\n.right-panel.hidden {\n  width: 0;\n  opacity: 0;\n  transform: translateX(20px);\n  pointer-events: none;\n}\n\n.right-panel .panel-header.dark-header {\n  background: #000;\n  color: #fff;\n  border-bottom: none;\n}\n\n.right-panel .header-icon {\n  color: #FF6B35;\n  margin-right: 8px;\n}\n\n/* 流程内容 */\n.process-content {\n  flex: 1;\n  overflow-y: auto;\n  padding: 24px;\n}\n\n/* 流程阶段 */\n.process-phase {\n  margin-bottom: 24px;\n  border: 1px solid #E0E0E0;\n  opacity: 0.5;\n  transition: all 0.3s;\n}\n\n.process-phase.active,\n.process-phase.completed {\n  opacity: 1;\n}\n\n.process-phase.active {\n  border-color: #FF6B35;\n}\n\n.process-phase.completed {\n  border-color: #1A936F;\n}\n\n.phase-header {\n  display: flex;\n  align-items: flex-start;\n  gap: 16px;\n  padding: 16px;\n  background: #FAFAFA;\n  border-bottom: 1px solid #E0E0E0;\n}\n\n.process-phase.active .phase-header {\n  background: #FFF5F2;\n}\n\n.process-phase.completed .phase-header {\n  background: #F2FAF6;\n}\n\n.phase-num {\n  font-size: 1.5rem;\n  font-weight: 700;\n  color: #ddd;\n  line-height: 1;\n}\n\n.process-phase.active .phase-num {\n  color: #FF6B35;\n}\n\n.process-phase.completed .phase-num {\n  color: #1A936F;\n}\n\n.phase-info {\n  flex: 1;\n}\n\n.phase-title {\n  font-size: 1rem;\n  font-weight: 600;\n  margin-bottom: 4px;\n}\n\n.phase-api {\n  font-size: 0.75rem;\n  color: #999;\n  font-family: 'JetBrains Mono', monospace;\n}\n\n.phase-status {\n  font-size: 0.75rem;\n  padding: 4px 10px;\n  background: #eee;\n  color: #666;\n}\n\n.phase-status.active {\n  background: #FF6B35;\n  color: #fff;\n}\n\n.phase-status.completed {\n  background: #1A936F;\n  color: #fff;\n}\n\n/* 阶段详情 */\n.phase-detail {\n  padding: 16px;\n}\n\n/* 实体标签 */\n.entity-tags {\n  display: flex;\n  flex-wrap: wrap;\n  gap: 8px;\n}\n\n.entity-tag {\n  font-size: 0.75rem;\n  padding: 4px 10px;\n  background: #F5F5F5;\n  border: 1px solid #E0E0E0;\n  color: #333;\n}\n\n/* 关系列表 */\n.relation-list {\n  font-size: 0.8rem;\n}\n\n.relation-item {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  padding: 6px 0;\n  border-bottom: 1px dashed #eee;\n}\n\n.relation-item:last-child {\n  border-bottom: none;\n}\n\n.rel-source,\n.rel-target {\n  color: #333;\n}\n\n.rel-arrow {\n  color: #ccc;\n}\n\n.rel-name {\n  color: #FF6B35;\n  font-weight: 500;\n}\n\n.relation-more {\n  padding-top: 8px;\n  color: #999;\n  font-size: 0.75rem;\n}\n\n/* 本体生成进度 */\n.ontology-progress {\n  display: flex;\n  align-items: center;\n  gap: 12px;\n  padding: 12px;\n  background: #FFF5F2;\n  border: 1px solid #FFE0D6;\n}\n\n.progress-spinner {\n  width: 20px;\n  height: 20px;\n  border: 2px solid #FFE0D6;\n  border-top-color: #FF6B35;\n  border-radius: 50%;\n  animation: spin 1s linear infinite;\n}\n\n.progress-text {\n  font-size: 0.85rem;\n  color: #333;\n}\n\n/* 等待状态 */\n.waiting-state {\n  padding: 16px;\n  background: #F9F9F9;\n  border: 1px dashed #E0E0E0;\n  text-align: center;\n}\n\n.waiting-hint {\n  font-size: 0.85rem;\n  color: #999;\n}\n\n/* 进度条 */\n.progress-bar {\n  height: 6px;\n  background: #E0E0E0;\n  margin-bottom: 8px;\n  overflow: hidden;\n}\n\n.progress-fill {\n  height: 100%;\n  background: #FF6B35;\n  transition: width 0.3s;\n}\n\n.progress-info {\n  display: flex;\n  justify-content: space-between;\n  font-size: 0.75rem;\n}\n\n.progress-message {\n  color: #666;\n}\n\n.progress-percent {\n  color: #FF6B35;\n  font-weight: 600;\n}\n\n/* 构建结果 */\n.build-result {\n  display: flex;\n  gap: 16px;\n}\n\n.result-item {\n  flex: 1;\n  text-align: center;\n  padding: 12px;\n  background: #F5F5F5;\n}\n\n.result-value {\n  display: block;\n  font-size: 1.5rem;\n  font-weight: 700;\n  color: #000;\n  margin-bottom: 4px;\n}\n\n.result-label {\n  font-size: 0.7rem;\n  color: #999;\n  text-transform: uppercase;\n  letter-spacing: 0.05em;\n}\n\n/* 下一步按钮 */\n.next-step-section {\n  margin-top: 24px;\n  padding-top: 24px;\n  border-top: 1px solid #E0E0E0;\n}\n\n.next-step-btn {\n  width: 100%;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  gap: 10px;\n  padding: 16px;\n  background: #000;\n  color: #fff;\n  border: none;\n  font-size: 1rem;\n  font-weight: 500;\n  letter-spacing: 0.05em;\n  cursor: pointer;\n  transition: all 0.2s;\n}\n\n.next-step-btn:hover:not(:disabled) {\n  background: #FF6B35;\n}\n\n.next-step-btn:disabled {\n  background: #ccc;\n  cursor: not-allowed;\n}\n\n.btn-arrow {\n  font-size: 1.2rem;\n}\n\n/* 项目信息面板 */\n.project-panel {\n  border-top: 1px solid #E0E0E0;\n  background: #FAFAFA;\n}\n\n.project-header {\n  display: flex;\n  align-items: center;\n  gap: 10px;\n  padding: 12px 24px;\n  border-bottom: 1px solid #E0E0E0;\n}\n\n.project-icon {\n  color: #FF6B35;\n}\n\n.project-title {\n  font-size: 0.85rem;\n  font-weight: 600;\n}\n\n.project-details {\n  padding: 16px 24px;\n}\n\n.project-item {\n  display: flex;\n  justify-content: space-between;\n  align-items: flex-start;\n  padding: 8px 0;\n  border-bottom: 1px dashed #E0E0E0;\n  font-size: 0.8rem;\n}\n\n.project-item:last-child {\n  border-bottom: none;\n}\n\n.item-label {\n  color: #999;\n  flex-shrink: 0;\n}\n\n.item-value {\n  color: #333;\n  text-align: right;\n  max-width: 60%;\n  word-break: break-all;\n}\n\n.item-value.code {\n  font-family: 'JetBrains Mono', monospace;\n  font-size: 0.75rem;\n  color: #666;\n}\n\n/* 响应式 */\n@media (max-width: 1024px) {\n  .main-content {\n    flex-direction: column;\n  }\n  \n  .left-panel {\n    width: 100% !important;\n    border-right: none;\n    border-bottom: 1px solid #E0E0E0;\n    height: 50vh;\n  }\n  \n  .right-panel {\n    width: 100% !important;\n    height: 50vh;\n    opacity: 1 !important;\n    transform: none !important;\n  }\n  \n  .right-panel.hidden {\n      display: none;\n  }\n}\n</style>"
  },
  {
    "path": "frontend/src/views/ReportView.vue",
    "content": "<template>\n  <div class=\"main-view\">\n    <!-- Header -->\n    <header class=\"app-header\">\n      <div class=\"header-left\">\n        <div class=\"brand\" @click=\"router.push('/')\">MIROFISH</div>\n      </div>\n      \n      <div class=\"header-center\">\n        <div class=\"view-switcher\">\n          <button \n            v-for=\"mode in ['graph', 'split', 'workbench']\" \n            :key=\"mode\"\n            class=\"switch-btn\"\n            :class=\"{ active: viewMode === mode }\"\n            @click=\"viewMode = mode\"\n          >\n            {{ { graph: '图谱', split: '双栏', workbench: '工作台' }[mode] }}\n          </button>\n        </div>\n      </div>\n\n      <div class=\"header-right\">\n        <div class=\"workflow-step\">\n          <span class=\"step-num\">Step 4/5</span>\n          <span class=\"step-name\">报告生成</span>\n        </div>\n        <div class=\"step-divider\"></div>\n        <span class=\"status-indicator\" :class=\"statusClass\">\n          <span class=\"dot\"></span>\n          {{ statusText }}\n        </span>\n      </div>\n    </header>\n\n    <!-- Main Content Area -->\n    <main class=\"content-area\">\n      <!-- Left Panel: Graph -->\n      <div class=\"panel-wrapper left\" :style=\"leftPanelStyle\">\n        <GraphPanel \n          :graphData=\"graphData\"\n          :loading=\"graphLoading\"\n          :currentPhase=\"4\"\n          :isSimulating=\"false\"\n          @refresh=\"refreshGraph\"\n          @toggle-maximize=\"toggleMaximize('graph')\"\n        />\n      </div>\n\n      <!-- Right Panel: Step4 报告生成 -->\n      <div class=\"panel-wrapper right\" :style=\"rightPanelStyle\">\n        <Step4Report\n          :reportId=\"currentReportId\"\n          :simulationId=\"simulationId\"\n          :systemLogs=\"systemLogs\"\n          @add-log=\"addLog\"\n          @update-status=\"updateStatus\"\n        />\n      </div>\n    </main>\n  </div>\n</template>\n\n<script setup>\nimport { ref, computed, onMounted, watch } from 'vue'\nimport { useRoute, useRouter } from 'vue-router'\nimport GraphPanel from '../components/GraphPanel.vue'\nimport Step4Report from '../components/Step4Report.vue'\nimport { getProject, getGraphData } from '../api/graph'\nimport { getSimulation } from '../api/simulation'\nimport { getReport } from '../api/report'\n\nconst route = useRoute()\nconst router = useRouter()\n\n// Props\nconst props = defineProps({\n  reportId: String\n})\n\n// Layout State - 默认切换到工作台视角\nconst viewMode = ref('workbench')\n\n// Data State\nconst currentReportId = ref(route.params.reportId)\nconst simulationId = ref(null)\nconst projectData = ref(null)\nconst graphData = ref(null)\nconst graphLoading = ref(false)\nconst systemLogs = ref([])\nconst currentStatus = ref('processing') // processing | completed | error\n\n// --- Computed Layout Styles ---\nconst leftPanelStyle = computed(() => {\n  if (viewMode.value === 'graph') return { width: '100%', opacity: 1, transform: 'translateX(0)' }\n  if (viewMode.value === 'workbench') return { width: '0%', opacity: 0, transform: 'translateX(-20px)' }\n  return { width: '50%', opacity: 1, transform: 'translateX(0)' }\n})\n\nconst rightPanelStyle = computed(() => {\n  if (viewMode.value === 'workbench') return { width: '100%', opacity: 1, transform: 'translateX(0)' }\n  if (viewMode.value === 'graph') return { width: '0%', opacity: 0, transform: 'translateX(20px)' }\n  return { width: '50%', opacity: 1, transform: 'translateX(0)' }\n})\n\n// --- Status Computed ---\nconst statusClass = computed(() => {\n  return currentStatus.value\n})\n\nconst statusText = computed(() => {\n  if (currentStatus.value === 'error') return 'Error'\n  if (currentStatus.value === 'completed') return 'Completed'\n  return 'Generating'\n})\n\n// --- Helpers ---\nconst addLog = (msg) => {\n  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')\n  systemLogs.value.push({ time, msg })\n  if (systemLogs.value.length > 200) {\n    systemLogs.value.shift()\n  }\n}\n\nconst updateStatus = (status) => {\n  currentStatus.value = status\n}\n\n// --- Layout Methods ---\nconst toggleMaximize = (target) => {\n  if (viewMode.value === target) {\n    viewMode.value = 'split'\n  } else {\n    viewMode.value = target\n  }\n}\n\n// --- Data Logic ---\nconst loadReportData = async () => {\n  try {\n    addLog(`加载报告数据: ${currentReportId.value}`)\n    \n    // 获取 report 信息以获取 simulation_id\n    const reportRes = await getReport(currentReportId.value)\n    if (reportRes.success && reportRes.data) {\n      const reportData = reportRes.data\n      simulationId.value = reportData.simulation_id\n      \n      if (simulationId.value) {\n        // 获取 simulation 信息\n        const simRes = await getSimulation(simulationId.value)\n        if (simRes.success && simRes.data) {\n          const simData = simRes.data\n          \n          // 获取 project 信息\n          if (simData.project_id) {\n            const projRes = await getProject(simData.project_id)\n            if (projRes.success && projRes.data) {\n              projectData.value = projRes.data\n              addLog(`项目加载成功: ${projRes.data.project_id}`)\n              \n              // 获取 graph 数据\n              if (projRes.data.graph_id) {\n                await loadGraph(projRes.data.graph_id)\n              }\n            }\n          }\n        }\n      }\n    } else {\n      addLog(`获取报告信息失败: ${reportRes.error || '未知错误'}`)\n    }\n  } catch (err) {\n    addLog(`加载异常: ${err.message}`)\n  }\n}\n\nconst loadGraph = async (graphId) => {\n  graphLoading.value = true\n  \n  try {\n    const res = await getGraphData(graphId)\n    if (res.success) {\n      graphData.value = res.data\n      addLog('图谱数据加载成功')\n    }\n  } catch (err) {\n    addLog(`图谱加载失败: ${err.message}`)\n  } finally {\n    graphLoading.value = false\n  }\n}\n\nconst refreshGraph = () => {\n  if (projectData.value?.graph_id) {\n    loadGraph(projectData.value.graph_id)\n  }\n}\n\n// Watch route params\nwatch(() => route.params.reportId, (newId) => {\n  if (newId && newId !== currentReportId.value) {\n    currentReportId.value = newId\n    loadReportData()\n  }\n}, { immediate: true })\n\nonMounted(() => {\n  addLog('ReportView 初始化')\n  loadReportData()\n})\n</script>\n\n<style scoped>\n.main-view {\n  height: 100vh;\n  display: flex;\n  flex-direction: column;\n  background: #FFF;\n  overflow: hidden;\n  font-family: 'Space Grotesk', 'Noto Sans SC', system-ui, sans-serif;\n}\n\n/* Header */\n.app-header {\n  height: 60px;\n  border-bottom: 1px solid #EAEAEA;\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  padding: 0 24px;\n  background: #FFF;\n  z-index: 100;\n  position: relative;\n}\n\n.header-center {\n  position: absolute;\n  left: 50%;\n  transform: translateX(-50%);\n}\n\n.brand {\n  font-family: 'JetBrains Mono', monospace;\n  font-weight: 800;\n  font-size: 18px;\n  letter-spacing: 1px;\n  cursor: pointer;\n}\n\n.view-switcher {\n  display: flex;\n  background: #F5F5F5;\n  padding: 4px;\n  border-radius: 6px;\n  gap: 4px;\n}\n\n.switch-btn {\n  border: none;\n  background: transparent;\n  padding: 6px 16px;\n  font-size: 12px;\n  font-weight: 600;\n  color: #666;\n  border-radius: 4px;\n  cursor: pointer;\n  transition: all 0.2s;\n}\n\n.switch-btn.active {\n  background: #FFF;\n  color: #000;\n  box-shadow: 0 2px 4px rgba(0,0,0,0.05);\n}\n\n.header-right {\n  display: flex;\n  align-items: center;\n  gap: 16px;\n}\n\n.workflow-step {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  font-size: 14px;\n}\n\n.step-num {\n  font-family: 'JetBrains Mono', monospace;\n  font-weight: 700;\n  color: #999;\n}\n\n.step-name {\n  font-weight: 700;\n  color: #000;\n}\n\n.step-divider {\n  width: 1px;\n  height: 14px;\n  background-color: #E0E0E0;\n}\n\n.status-indicator {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  font-size: 12px;\n  color: #666;\n  font-weight: 500;\n}\n\n.dot {\n  width: 8px;\n  height: 8px;\n  border-radius: 50%;\n  background: #CCC;\n}\n\n.status-indicator.processing .dot { background: #FF9800; animation: pulse 1s infinite; }\n.status-indicator.completed .dot { background: #4CAF50; }\n.status-indicator.error .dot { background: #F44336; }\n\n@keyframes pulse { 50% { opacity: 0.5; } }\n\n/* Content */\n.content-area {\n  flex: 1;\n  display: flex;\n  position: relative;\n  overflow: hidden;\n}\n\n.panel-wrapper {\n  height: 100%;\n  overflow: hidden;\n  transition: width 0.4s cubic-bezier(0.25, 0.8, 0.25, 1), opacity 0.3s ease, transform 0.3s ease;\n  will-change: width, opacity, transform;\n}\n\n.panel-wrapper.left {\n  border-right: 1px solid #EAEAEA;\n}\n</style>\n"
  },
  {
    "path": "frontend/src/views/SimulationRunView.vue",
    "content": "<template>\n  <div class=\"main-view\">\n    <!-- Header -->\n    <header class=\"app-header\">\n      <div class=\"header-left\">\n        <div class=\"brand\" @click=\"router.push('/')\">MIROFISH</div>\n      </div>\n      \n      <div class=\"header-center\">\n        <div class=\"view-switcher\">\n          <button \n            v-for=\"mode in ['graph', 'split', 'workbench']\" \n            :key=\"mode\"\n            class=\"switch-btn\"\n            :class=\"{ active: viewMode === mode }\"\n            @click=\"viewMode = mode\"\n          >\n            {{ { graph: '图谱', split: '双栏', workbench: '工作台' }[mode] }}\n          </button>\n        </div>\n      </div>\n\n      <div class=\"header-right\">\n        <div class=\"workflow-step\">\n          <span class=\"step-num\">Step 3/5</span>\n          <span class=\"step-name\">开始模拟</span>\n        </div>\n        <div class=\"step-divider\"></div>\n        <span class=\"status-indicator\" :class=\"statusClass\">\n          <span class=\"dot\"></span>\n          {{ statusText }}\n        </span>\n      </div>\n    </header>\n\n    <!-- Main Content Area -->\n    <main class=\"content-area\">\n      <!-- Left Panel: Graph -->\n      <div class=\"panel-wrapper left\" :style=\"leftPanelStyle\">\n        <GraphPanel \n          :graphData=\"graphData\"\n          :loading=\"graphLoading\"\n          :currentPhase=\"3\"\n          :isSimulating=\"isSimulating\"\n          @refresh=\"refreshGraph\"\n          @toggle-maximize=\"toggleMaximize('graph')\"\n        />\n      </div>\n\n      <!-- Right Panel: Step3 开始模拟 -->\n      <div class=\"panel-wrapper right\" :style=\"rightPanelStyle\">\n        <Step3Simulation\n          :simulationId=\"currentSimulationId\"\n          :maxRounds=\"maxRounds\"\n          :minutesPerRound=\"minutesPerRound\"\n          :projectData=\"projectData\"\n          :graphData=\"graphData\"\n          :systemLogs=\"systemLogs\"\n          @go-back=\"handleGoBack\"\n          @next-step=\"handleNextStep\"\n          @add-log=\"addLog\"\n          @update-status=\"updateStatus\"\n        />\n      </div>\n    </main>\n  </div>\n</template>\n\n<script setup>\nimport { ref, computed, onMounted, onUnmounted, watch } from 'vue'\nimport { useRoute, useRouter } from 'vue-router'\nimport GraphPanel from '../components/GraphPanel.vue'\nimport Step3Simulation from '../components/Step3Simulation.vue'\nimport { getProject, getGraphData } from '../api/graph'\nimport { getSimulation, getSimulationConfig, stopSimulation, closeSimulationEnv, getEnvStatus } from '../api/simulation'\n\nconst route = useRoute()\nconst router = useRouter()\n\n// Props\nconst props = defineProps({\n  simulationId: String\n})\n\n// Layout State\nconst viewMode = ref('split')\n\n// Data State\nconst currentSimulationId = ref(route.params.simulationId)\n// 直接在初始化时从 query 参数获取 maxRounds，确保子组件能立即获取到值\nconst maxRounds = ref(route.query.maxRounds ? parseInt(route.query.maxRounds) : null)\nconst minutesPerRound = ref(30) // 默认每轮30分钟\nconst projectData = ref(null)\nconst graphData = ref(null)\nconst graphLoading = ref(false)\nconst systemLogs = ref([])\nconst currentStatus = ref('processing') // processing | completed | error\n\n// --- Computed Layout Styles ---\nconst leftPanelStyle = computed(() => {\n  if (viewMode.value === 'graph') return { width: '100%', opacity: 1, transform: 'translateX(0)' }\n  if (viewMode.value === 'workbench') return { width: '0%', opacity: 0, transform: 'translateX(-20px)' }\n  return { width: '50%', opacity: 1, transform: 'translateX(0)' }\n})\n\nconst rightPanelStyle = computed(() => {\n  if (viewMode.value === 'workbench') return { width: '100%', opacity: 1, transform: 'translateX(0)' }\n  if (viewMode.value === 'graph') return { width: '0%', opacity: 0, transform: 'translateX(20px)' }\n  return { width: '50%', opacity: 1, transform: 'translateX(0)' }\n})\n\n// --- Status Computed ---\nconst statusClass = computed(() => {\n  return currentStatus.value\n})\n\nconst statusText = computed(() => {\n  if (currentStatus.value === 'error') return 'Error'\n  if (currentStatus.value === 'completed') return 'Completed'\n  return 'Running'\n})\n\nconst isSimulating = computed(() => currentStatus.value === 'processing')\n\n// --- Helpers ---\nconst addLog = (msg) => {\n  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')\n  systemLogs.value.push({ time, msg })\n  if (systemLogs.value.length > 200) {\n    systemLogs.value.shift()\n  }\n}\n\nconst updateStatus = (status) => {\n  currentStatus.value = status\n}\n\n// --- Layout Methods ---\nconst toggleMaximize = (target) => {\n  if (viewMode.value === target) {\n    viewMode.value = 'split'\n  } else {\n    viewMode.value = target\n  }\n}\n\nconst handleGoBack = async () => {\n  // 在返回 Step 2 之前，先关闭正在运行的模拟\n  addLog('准备返回 Step 2，正在关闭模拟...')\n  \n  // 停止轮询\n  stopGraphRefresh()\n  \n  try {\n    // 先尝试优雅关闭模拟环境\n    const envStatusRes = await getEnvStatus({ simulation_id: currentSimulationId.value })\n    \n    if (envStatusRes.success && envStatusRes.data?.env_alive) {\n      addLog('正在关闭模拟环境...')\n      try {\n        await closeSimulationEnv({ \n          simulation_id: currentSimulationId.value,\n          timeout: 10\n        })\n        addLog('✓ 模拟环境已关闭')\n      } catch (closeErr) {\n        addLog(`关闭模拟环境失败，尝试强制停止...`)\n        try {\n          await stopSimulation({ simulation_id: currentSimulationId.value })\n          addLog('✓ 模拟已强制停止')\n        } catch (stopErr) {\n          addLog(`强制停止失败: ${stopErr.message}`)\n        }\n      }\n    } else {\n      // 环境未运行，检查是否需要停止进程\n      if (isSimulating.value) {\n        addLog('正在停止模拟进程...')\n        try {\n          await stopSimulation({ simulation_id: currentSimulationId.value })\n          addLog('✓ 模拟已停止')\n        } catch (err) {\n          addLog(`停止模拟失败: ${err.message}`)\n        }\n      }\n    }\n  } catch (err) {\n    addLog(`检查模拟状态失败: ${err.message}`)\n  }\n  \n  // 返回到 Step 2 (环境搭建)\n  router.push({ name: 'Simulation', params: { simulationId: currentSimulationId.value } })\n}\n\nconst handleNextStep = () => {\n  // Step3Simulation 组件会直接处理报告生成和路由跳转\n  // 这个方法仅作为备用\n  addLog('进入 Step 4: 报告生成')\n}\n\n// --- Data Logic ---\nconst loadSimulationData = async () => {\n  try {\n    addLog(`加载模拟数据: ${currentSimulationId.value}`)\n    \n    // 获取 simulation 信息\n    const simRes = await getSimulation(currentSimulationId.value)\n    if (simRes.success && simRes.data) {\n      const simData = simRes.data\n      \n      // 获取 simulation config 以获取 minutes_per_round\n      try {\n        const configRes = await getSimulationConfig(currentSimulationId.value)\n        if (configRes.success && configRes.data?.time_config?.minutes_per_round) {\n          minutesPerRound.value = configRes.data.time_config.minutes_per_round\n          addLog(`时间配置: 每轮 ${minutesPerRound.value} 分钟`)\n        }\n      } catch (configErr) {\n        addLog(`获取时间配置失败，使用默认值: ${minutesPerRound.value}分钟/轮`)\n      }\n      \n      // 获取 project 信息\n      if (simData.project_id) {\n        const projRes = await getProject(simData.project_id)\n        if (projRes.success && projRes.data) {\n          projectData.value = projRes.data\n          addLog(`项目加载成功: ${projRes.data.project_id}`)\n          \n          // 获取 graph 数据\n          if (projRes.data.graph_id) {\n            await loadGraph(projRes.data.graph_id)\n          }\n        }\n      }\n    } else {\n      addLog(`加载模拟数据失败: ${simRes.error || '未知错误'}`)\n    }\n  } catch (err) {\n    addLog(`加载异常: ${err.message}`)\n  }\n}\n\nconst loadGraph = async (graphId) => {\n  // 当正在模拟时，自动刷新不显示全屏 loading，以免闪烁\n  // 手动刷新或初始加载时显示 loading\n  if (!isSimulating.value) {\n    graphLoading.value = true\n  }\n  \n  try {\n    const res = await getGraphData(graphId)\n    if (res.success) {\n      graphData.value = res.data\n      if (!isSimulating.value) {\n        addLog('图谱数据加载成功')\n      }\n    }\n  } catch (err) {\n    addLog(`图谱加载失败: ${err.message}`)\n  } finally {\n    graphLoading.value = false\n  }\n}\n\nconst refreshGraph = () => {\n  if (projectData.value?.graph_id) {\n    loadGraph(projectData.value.graph_id)\n  }\n}\n\n// --- Auto Refresh Logic ---\nlet graphRefreshTimer = null\n\nconst startGraphRefresh = () => {\n  if (graphRefreshTimer) return\n  addLog('开启图谱实时刷新 (30s)')\n  // 立即刷新一次，然后每30秒刷新\n  graphRefreshTimer = setInterval(refreshGraph, 30000)\n}\n\nconst stopGraphRefresh = () => {\n  if (graphRefreshTimer) {\n    clearInterval(graphRefreshTimer)\n    graphRefreshTimer = null\n    addLog('停止图谱实时刷新')\n  }\n}\n\nwatch(isSimulating, (newValue) => {\n  if (newValue) {\n    startGraphRefresh()\n  } else {\n    stopGraphRefresh()\n  }\n}, { immediate: true })\n\nonMounted(() => {\n  addLog('SimulationRunView 初始化')\n  \n  // 记录 maxRounds 配置（值已在初始化时从 query 参数获取）\n  if (maxRounds.value) {\n    addLog(`自定义模拟轮数: ${maxRounds.value}`)\n  }\n  \n  loadSimulationData()\n})\n\nonUnmounted(() => {\n  stopGraphRefresh()\n})\n</script>\n\n<style scoped>\n.main-view {\n  height: 100vh;\n  display: flex;\n  flex-direction: column;\n  background: #FFF;\n  overflow: hidden;\n  font-family: 'Space Grotesk', 'Noto Sans SC', system-ui, sans-serif;\n}\n\n/* Header */\n.app-header {\n  height: 60px;\n  border-bottom: 1px solid #EAEAEA;\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  padding: 0 24px;\n  background: #FFF;\n  z-index: 100;\n  position: relative;\n}\n\n.header-center {\n  position: absolute;\n  left: 50%;\n  transform: translateX(-50%);\n}\n\n.brand {\n  font-family: 'JetBrains Mono', monospace;\n  font-weight: 800;\n  font-size: 18px;\n  letter-spacing: 1px;\n  cursor: pointer;\n}\n\n.view-switcher {\n  display: flex;\n  background: #F5F5F5;\n  padding: 4px;\n  border-radius: 6px;\n  gap: 4px;\n}\n\n.switch-btn {\n  border: none;\n  background: transparent;\n  padding: 6px 16px;\n  font-size: 12px;\n  font-weight: 600;\n  color: #666;\n  border-radius: 4px;\n  cursor: pointer;\n  transition: all 0.2s;\n}\n\n.switch-btn.active {\n  background: #FFF;\n  color: #000;\n  box-shadow: 0 2px 4px rgba(0,0,0,0.05);\n}\n\n.header-right {\n  display: flex;\n  align-items: center;\n  gap: 16px;\n}\n\n.workflow-step {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  font-size: 14px;\n}\n\n.step-num {\n  font-family: 'JetBrains Mono', monospace;\n  font-weight: 700;\n  color: #999;\n}\n\n.step-name {\n  font-weight: 700;\n  color: #000;\n}\n\n.step-divider {\n  width: 1px;\n  height: 14px;\n  background-color: #E0E0E0;\n}\n\n.status-indicator {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  font-size: 12px;\n  color: #666;\n  font-weight: 500;\n}\n\n.dot {\n  width: 8px;\n  height: 8px;\n  border-radius: 50%;\n  background: #CCC;\n}\n\n.status-indicator.processing .dot { background: #FF5722; animation: pulse 1s infinite; }\n.status-indicator.completed .dot { background: #4CAF50; }\n.status-indicator.error .dot { background: #F44336; }\n\n@keyframes pulse { 50% { opacity: 0.5; } }\n\n/* Content */\n.content-area {\n  flex: 1;\n  display: flex;\n  position: relative;\n  overflow: hidden;\n}\n\n.panel-wrapper {\n  height: 100%;\n  overflow: hidden;\n  transition: width 0.4s cubic-bezier(0.25, 0.8, 0.25, 1), opacity 0.3s ease, transform 0.3s ease;\n  will-change: width, opacity, transform;\n}\n\n.panel-wrapper.left {\n  border-right: 1px solid #EAEAEA;\n}\n</style>\n\n"
  },
  {
    "path": "frontend/src/views/SimulationView.vue",
    "content": "<template>\n  <div class=\"main-view\">\n    <!-- Header -->\n    <header class=\"app-header\">\n      <div class=\"header-left\">\n        <div class=\"brand\" @click=\"router.push('/')\">MIROFISH</div>\n      </div>\n      \n      <div class=\"header-center\">\n        <div class=\"view-switcher\">\n          <button \n            v-for=\"mode in ['graph', 'split', 'workbench']\" \n            :key=\"mode\"\n            class=\"switch-btn\"\n            :class=\"{ active: viewMode === mode }\"\n            @click=\"viewMode = mode\"\n          >\n            {{ { graph: '图谱', split: '双栏', workbench: '工作台' }[mode] }}\n          </button>\n        </div>\n      </div>\n\n      <div class=\"header-right\">\n        <div class=\"workflow-step\">\n          <span class=\"step-num\">Step 2/5</span>\n          <span class=\"step-name\">环境搭建</span>\n        </div>\n        <div class=\"step-divider\"></div>\n        <span class=\"status-indicator\" :class=\"statusClass\">\n          <span class=\"dot\"></span>\n          {{ statusText }}\n        </span>\n      </div>\n    </header>\n\n    <!-- Main Content Area -->\n    <main class=\"content-area\">\n      <!-- Left Panel: Graph -->\n      <div class=\"panel-wrapper left\" :style=\"leftPanelStyle\">\n        <GraphPanel \n          :graphData=\"graphData\"\n          :loading=\"graphLoading\"\n          :currentPhase=\"2\"\n          @refresh=\"refreshGraph\"\n          @toggle-maximize=\"toggleMaximize('graph')\"\n        />\n      </div>\n\n      <!-- Right Panel: Step2 环境搭建 -->\n      <div class=\"panel-wrapper right\" :style=\"rightPanelStyle\">\n        <Step2EnvSetup\n          :simulationId=\"currentSimulationId\"\n          :projectData=\"projectData\"\n          :graphData=\"graphData\"\n          :systemLogs=\"systemLogs\"\n          @go-back=\"handleGoBack\"\n          @next-step=\"handleNextStep\"\n          @add-log=\"addLog\"\n          @update-status=\"updateStatus\"\n        />\n      </div>\n    </main>\n  </div>\n</template>\n\n<script setup>\nimport { ref, computed, onMounted, onUnmounted } from 'vue'\nimport { useRoute, useRouter } from 'vue-router'\nimport GraphPanel from '../components/GraphPanel.vue'\nimport Step2EnvSetup from '../components/Step2EnvSetup.vue'\nimport { getProject, getGraphData } from '../api/graph'\nimport { getSimulation, stopSimulation, getEnvStatus, closeSimulationEnv } from '../api/simulation'\n\nconst route = useRoute()\nconst router = useRouter()\n\n// Props\nconst props = defineProps({\n  simulationId: String\n})\n\n// Layout State\nconst viewMode = ref('split')\n\n// Data State\nconst currentSimulationId = ref(route.params.simulationId)\nconst projectData = ref(null)\nconst graphData = ref(null)\nconst graphLoading = ref(false)\nconst systemLogs = ref([])\nconst currentStatus = ref('processing') // processing | completed | error\n\n// --- Computed Layout Styles ---\nconst leftPanelStyle = computed(() => {\n  if (viewMode.value === 'graph') return { width: '100%', opacity: 1, transform: 'translateX(0)' }\n  if (viewMode.value === 'workbench') return { width: '0%', opacity: 0, transform: 'translateX(-20px)' }\n  return { width: '50%', opacity: 1, transform: 'translateX(0)' }\n})\n\nconst rightPanelStyle = computed(() => {\n  if (viewMode.value === 'workbench') return { width: '100%', opacity: 1, transform: 'translateX(0)' }\n  if (viewMode.value === 'graph') return { width: '0%', opacity: 0, transform: 'translateX(20px)' }\n  return { width: '50%', opacity: 1, transform: 'translateX(0)' }\n})\n\n// --- Status Computed ---\nconst statusClass = computed(() => {\n  return currentStatus.value\n})\n\nconst statusText = computed(() => {\n  if (currentStatus.value === 'error') return 'Error'\n  if (currentStatus.value === 'completed') return 'Ready'\n  return 'Preparing'\n})\n\n// --- Helpers ---\nconst addLog = (msg) => {\n  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')\n  systemLogs.value.push({ time, msg })\n  if (systemLogs.value.length > 100) {\n    systemLogs.value.shift()\n  }\n}\n\nconst updateStatus = (status) => {\n  currentStatus.value = status\n}\n\n// --- Layout Methods ---\nconst toggleMaximize = (target) => {\n  if (viewMode.value === target) {\n    viewMode.value = 'split'\n  } else {\n    viewMode.value = target\n  }\n}\n\nconst handleGoBack = () => {\n  // 返回到 process 页面\n  if (projectData.value?.project_id) {\n    router.push({ name: 'Process', params: { projectId: projectData.value.project_id } })\n  } else {\n    router.push('/')\n  }\n}\n\nconst handleNextStep = (params = {}) => {\n  addLog('进入 Step 3: 开始模拟')\n  \n  // 记录模拟轮数配置\n  if (params.maxRounds) {\n    addLog(`自定义模拟轮数: ${params.maxRounds} 轮`)\n  } else {\n    addLog('使用自动配置的模拟轮数')\n  }\n  \n  // 构建路由参数\n  const routeParams = {\n    name: 'SimulationRun',\n    params: { simulationId: currentSimulationId.value }\n  }\n  \n  // 如果有自定义轮数，通过 query 参数传递\n  if (params.maxRounds) {\n    routeParams.query = { maxRounds: params.maxRounds }\n  }\n  \n  // 跳转到 Step 3 页面\n  router.push(routeParams)\n}\n\n// --- Data Logic ---\n\n/**\n * 检查并关闭正在运行的模拟\n * 当用户从 Step 3 返回到 Step 2 时，默认用户要退出模拟\n */\nconst checkAndStopRunningSimulation = async () => {\n  if (!currentSimulationId.value) return\n  \n  try {\n    // 先检查模拟环境是否存活\n    const envStatusRes = await getEnvStatus({ simulation_id: currentSimulationId.value })\n    \n    if (envStatusRes.success && envStatusRes.data?.env_alive) {\n      addLog('检测到模拟环境正在运行，正在关闭...')\n      \n      // 尝试优雅关闭模拟环境\n      try {\n        const closeRes = await closeSimulationEnv({ \n          simulation_id: currentSimulationId.value,\n          timeout: 10  // 10秒超时\n        })\n        \n        if (closeRes.success) {\n          addLog('✓ 模拟环境已关闭')\n        } else {\n          addLog(`关闭模拟环境失败: ${closeRes.error || '未知错误'}`)\n          // 如果优雅关闭失败，尝试强制停止\n          await forceStopSimulation()\n        }\n      } catch (closeErr) {\n        addLog(`关闭模拟环境异常: ${closeErr.message}`)\n        // 如果优雅关闭异常，尝试强制停止\n        await forceStopSimulation()\n      }\n    } else {\n      // 环境未运行，但可能进程还在，检查模拟状态\n      const simRes = await getSimulation(currentSimulationId.value)\n      if (simRes.success && simRes.data?.status === 'running') {\n        addLog('检测到模拟状态为运行中，正在停止...')\n        await forceStopSimulation()\n      }\n    }\n  } catch (err) {\n    // 检查环境状态失败不影响后续流程\n    console.warn('检查模拟状态失败:', err)\n  }\n}\n\n/**\n * 强制停止模拟\n */\nconst forceStopSimulation = async () => {\n  try {\n    const stopRes = await stopSimulation({ simulation_id: currentSimulationId.value })\n    if (stopRes.success) {\n      addLog('✓ 模拟已强制停止')\n    } else {\n      addLog(`强制停止模拟失败: ${stopRes.error || '未知错误'}`)\n    }\n  } catch (err) {\n    addLog(`强制停止模拟异常: ${err.message}`)\n  }\n}\n\nconst loadSimulationData = async () => {\n  try {\n    addLog(`加载模拟数据: ${currentSimulationId.value}`)\n    \n    // 获取 simulation 信息\n    const simRes = await getSimulation(currentSimulationId.value)\n    if (simRes.success && simRes.data) {\n      const simData = simRes.data\n      \n      // 获取 project 信息\n      if (simData.project_id) {\n        const projRes = await getProject(simData.project_id)\n        if (projRes.success && projRes.data) {\n          projectData.value = projRes.data\n          addLog(`项目加载成功: ${projRes.data.project_id}`)\n          \n          // 获取 graph 数据\n          if (projRes.data.graph_id) {\n            await loadGraph(projRes.data.graph_id)\n          }\n        }\n      }\n    } else {\n      addLog(`加载模拟数据失败: ${simRes.error || '未知错误'}`)\n    }\n  } catch (err) {\n    addLog(`加载异常: ${err.message}`)\n  }\n}\n\nconst loadGraph = async (graphId) => {\n  graphLoading.value = true\n  try {\n    const res = await getGraphData(graphId)\n    if (res.success) {\n      graphData.value = res.data\n      addLog('图谱数据加载成功')\n    }\n  } catch (err) {\n    addLog(`图谱加载失败: ${err.message}`)\n  } finally {\n    graphLoading.value = false\n  }\n}\n\nconst refreshGraph = () => {\n  if (projectData.value?.graph_id) {\n    loadGraph(projectData.value.graph_id)\n  }\n}\n\nonMounted(async () => {\n  addLog('SimulationView 初始化')\n  \n  // 检查并关闭正在运行的模拟（用户从 Step 3 返回时）\n  await checkAndStopRunningSimulation()\n  \n  // 加载模拟数据\n  loadSimulationData()\n})\n</script>\n\n<style scoped>\n.main-view {\n  height: 100vh;\n  display: flex;\n  flex-direction: column;\n  background: #FFF;\n  overflow: hidden;\n  font-family: 'Space Grotesk', 'Noto Sans SC', system-ui, sans-serif;\n}\n\n/* Header */\n.app-header {\n  height: 60px;\n  border-bottom: 1px solid #EAEAEA;\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  padding: 0 24px;\n  background: #FFF;\n  z-index: 100;\n  position: relative;\n}\n\n.brand {\n  font-family: 'JetBrains Mono', monospace;\n  font-weight: 800;\n  font-size: 18px;\n  letter-spacing: 1px;\n  cursor: pointer;\n}\n\n.header-center {\n  position: absolute;\n  left: 50%;\n  transform: translateX(-50%);\n}\n\n.view-switcher {\n  display: flex;\n  background: #F5F5F5;\n  padding: 4px;\n  border-radius: 6px;\n  gap: 4px;\n}\n\n.switch-btn {\n  border: none;\n  background: transparent;\n  padding: 6px 16px;\n  font-size: 12px;\n  font-weight: 600;\n  color: #666;\n  border-radius: 4px;\n  cursor: pointer;\n  transition: all 0.2s;\n}\n\n.switch-btn.active {\n  background: #FFF;\n  color: #000;\n  box-shadow: 0 2px 4px rgba(0,0,0,0.05);\n}\n\n.header-right {\n  display: flex;\n  align-items: center;\n  gap: 16px;\n}\n\n.workflow-step {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  font-size: 14px;\n}\n\n.step-num {\n  font-family: 'JetBrains Mono', monospace;\n  font-weight: 700;\n  color: #999;\n}\n\n.step-name {\n  font-weight: 700;\n  color: #000;\n}\n\n.step-divider {\n  width: 1px;\n  height: 14px;\n  background-color: #E0E0E0;\n}\n\n.status-indicator {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  font-size: 12px;\n  color: #666;\n  font-weight: 500;\n}\n\n.dot {\n  width: 8px;\n  height: 8px;\n  border-radius: 50%;\n  background: #CCC;\n}\n\n.status-indicator.processing .dot { background: #FF5722; animation: pulse 1s infinite; }\n.status-indicator.completed .dot { background: #4CAF50; }\n.status-indicator.error .dot { background: #F44336; }\n\n@keyframes pulse { 50% { opacity: 0.5; } }\n\n/* Content */\n.content-area {\n  flex: 1;\n  display: flex;\n  position: relative;\n  overflow: hidden;\n}\n\n.panel-wrapper {\n  height: 100%;\n  overflow: hidden;\n  transition: width 0.4s cubic-bezier(0.25, 0.8, 0.25, 1), opacity 0.3s ease, transform 0.3s ease;\n  will-change: width, opacity, transform;\n}\n\n.panel-wrapper.left {\n  border-right: 1px solid #EAEAEA;\n}\n</style>\n\n"
  },
  {
    "path": "frontend/vite.config.js",
    "content": "import { defineConfig } from 'vite'\nimport vue from '@vitejs/plugin-vue'\n\n// https://vite.dev/config/\nexport default defineConfig({\n  plugins: [vue()],\n  server: {\n    port: 3000,\n    open: true,\n    proxy: {\n      '/api': {\n        target: 'http://localhost:5001',\n        changeOrigin: true,\n        secure: false\n      }\n    }\n  }\n})\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"mirofish\",\n  \"version\": \"0.1.0\",\n  \"description\": \"MiroFish - 简洁通用的群体智能引擎，预测万物\",\n  \"scripts\": {\n    \"setup\": \"npm install && cd frontend && npm install\",\n    \"setup:backend\": \"cd backend && uv sync\",\n    \"setup:all\": \"npm run setup && npm run setup:backend\",\n    \"dev\": \"concurrently --kill-others -n \\\"backend,frontend\\\" -c \\\"green,cyan\\\" \\\"npm run backend\\\" \\\"npm run frontend\\\"\",\n    \"backend\": \"cd backend && uv run python run.py\",\n    \"frontend\": \"cd frontend && npm run dev\",\n    \"build\": \"cd frontend && npm run build\"\n  },\n  \"devDependencies\": {\n    \"concurrently\": \"^9.1.2\"\n  },\n  \"engines\": {\n    \"node\": \">=18.0.0\"\n  },\n  \"license\": \"AGPL-3.0\"\n}\n"
  }
]