commit 813bb026727cd024995fde8eace275967a787f04 Author: canglan Date: Tue May 5 03:21:58 2026 +0800 初始化仓库及v1.0.0提交 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..78814d8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# Dependencies +node_modules/ +vendor/ + +# Environment variables +.env +.env.local + +# Config files (managed by Web installer) +config/db-config.json +config/app-config.json + +# Uploads (keep .gitkeep) +uploads/* +!uploads/.gitkeep + +# IDE and Editor +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Logs +logs/ +*.log + +# OS generated files +.DS_Store +Thumbs.db + +# Plan workflow +.cospec/ diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..6cff973 --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,122 @@ +# 安装指南 + +## 环境要求 + +| 组件 | 最低版本 | 说明 | +|------|----------|------| +| PHP | 8.0+ | 需启用 PHP-FPM | +| MySQL | 5.7+ | 或 MariaDB 10.3+ | +| Nginx | 1.18+ | 或其他支持 PHP-FPM 的 Web 服务器 | +| Composer | 2.0+ | PHP 依赖管理工具 | + +### PHP 扩展要求 + +- PDO(含 PDO MySQL 驱动) +- cURL +- JSON +- mbstring +- OpenSSL + +## 安装方式 + +### 方式一:宝塔面板安装(推荐) + +宝塔面板提供图形化界面,最适合快速部署。 + +#### 1. 安装宝塔面板环境 + +在宝塔面板中安装以下组件: +- Nginx(任意版本) +- PHP 8.0(注意选择正确版本) +- MySQL 5.7 或 8.0 + +#### 2. 创建网站 + +1. 登录宝塔面板 +2. 点击"网站" → "添加站点" +3. 填写域名,PHP 版本选择 **PHP 8.0** +4. 数据库选择"不创建"(安装向导中会创建) + +#### 3. 上传代码 + +将项目代码上传到网站目录(例如 `/www/wwwroot/your-domain.com/`),或使用 Git 克隆。 + +#### 4. 安装 PHP 依赖 + +在宝塔终端中执行: +```bash +cd /www/wwwroot/your-domain.com +composer install +``` + +#### 5. 设置目录权限 + +```bash +chmod 755 uploads/ +chmod 755 config/ +``` + +#### 6. 配置 Nginx + +1. 在宝塔面板中找到你的站点 → 设置 → 配置文件 +2. 将网站根目录修改为 `/www/wwwroot/your-domain.com/public` +3. 参考 `docs/baota-nginx-snippet.conf` 中的配置片段,添加到 server 块中 +4. 保存配置 + +#### 7. 运行安装向导 + +访问 `http://your-domain.com/install.php`,按照向导步骤: +1. **环境检查** — 系统自动检查 PHP 版本和扩展 +2. **数据库配置** — 填写 MySQL 连接信息 +3. **应用配置** — JWT 密钥(可自动生成) +4. **管理员账户** — 设置管理员用户名和密码 +5. **AI 供应商** — 配置至少一个 AI 服务供应商 + +### 方式二:手动安装 + +#### 1. 安装环境 + +```bash +# Ubuntu/Debian +sudo apt update +sudo apt install php8.0-fpm php8.0-mysql php8.0-curl php8.0-mbstring php8.0-xml +sudo apt install mysql-server nginx composer +``` + +#### 2. 部署代码 + +```bash +cd /var/www +git clone ai-chat +cd ai-chat +composer install +``` + +#### 3. 设置权限 + +```bash +chown -R www-data:www-data /var/www/ai-chat +chmod 755 uploads/ config/ +``` + +#### 4. 配置 Nginx + +参考 `docs/nginx.conf` 配置 Nginx 站点。 + +#### 5. 运行安装向导 + +访问 `http://your-domain.com/install.php` 完成安装。 + +## 常见问题 + +### Q: 安装页面空白? +检查 PHP 版本是否 >= 8.0,以及 PHP 扩展是否已安装。 + +### Q: 500 错误? +检查 Nginx 错误日志:`tail -f /var/log/nginx/error.log` + +### Q: 数据库连接失败? +确认 MySQL 服务正在运行,且数据库账号密码正确。 + +### Q: SSE 流式响应不工作? +确认 Nginx 配置中已禁用缓冲(参考 baota-nginx-snippet.conf)。 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f840759 --- /dev/null +++ b/LICENSE @@ -0,0 +1,671 @@ +Copyright (C) AI Chat Contributors. All rights reserved. +This software is licensed under the GNU Affero General Public License v3.0. + + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license +for software and other kinds of works, which are specifically designed +to ensure cooperation with the community in the case of network server +software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. When we speak of free software, we are +referring to freedom, not price. Our General Public Licenses are +designed to make sure that you have the freedom to distribute copies of +free software (and charge for them if you wish), that you receive source +code or can get it if you want it, that you can change the software or +use pieces of it in new free programs, and that you know you can do +these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of a program, if they +receive widespread use, become available for other developers to +reuse or incorporate. Many developers of free software are heartened +and encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, in any medium. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source is intended to be the minimal source code +that needs to be available to allow anyone to generate, install, +run, maintain, and modify the work. It does not include general-purpose +tools that are generally available and are not part of the work. + + For example, Corresponding Source includes scripts used to control +compilation and installation of the executable. It does not include +the source code for the compiler itself, or the source code for any +standard libraries that the work uses. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the Corresponding + Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of persons who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import, and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source for the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibit the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least the +"copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, you +could provide a link from your application to the source code on a +network server. + + The GNU Affero General Public License does not permit incorporating your +program into proprietary software. If your program is a subroutine +library, you may consider it more useful to permit linking proprietary +applications with the library. If this is what you want to do, use the +GNU Lesser General Public License instead of this License. But first, +please read . diff --git a/README.md b/README.md new file mode 100644 index 0000000..f176f1d --- /dev/null +++ b/README.md @@ -0,0 +1,114 @@ +# AI Chat - 智能对话助手 + +> **版本:v1.0.0** | **协议:AGPLv3** | **PHP 8.0 + MySQL + Nginx** + +一个基于 PHP 8.0 的 AI 聊天 Web 应用,支持多种 AI 服务供应商,提供流式对话体验。 + +## ✨ 功能特性 + +- 🔐 用户认证系统(JWT) +- 🤖 多 AI 服务供应商支持(OpenAI、Claude、DeepSeek 等 12+ 供应商) +- 💭 思考/非思考模式切换 +- 📡 SSE 流式响应(打字机效果) +- 💬 多会话管理 +- 📁 文件上传(图片 + 代码文件) +- 🎨 代码高亮和 Markdown 渲染 +- 🧠 自定义提示词和可配置人格(预设 + 自定义) +- 💾 双重历史记录存储(服务器 + 本地缓存) +- 🌐 Web 安装界面(零配置部署) +- ⚙️ API 配置管理界面 + +## 🛠️ 技术栈 + +| 组件 | 技术 | +|------|------| +| 后端 | PHP 8.0(原生,无框架) | +| 数据库 | MySQL 5.7+ / 8.0(PDO 驱动) | +| 前端 | PHP 模板 + 原生 JavaScript + AJAX | +| Web 服务器 | Nginx + PHP-FPM | +| 认证 | JWT(firebase/php-jwt) | +| 依赖管理 | Composer(PSR-4 自动加载) | +| 代码高亮 | highlight.js(CDN) | +| Markdown 渲染 | marked.js(CDN) | + +## 📁 项目结构 + +``` +ai-chat/ +├── public/ # Web 根目录(Nginx 指向此目录) +│ ├── api.php # API 统一入口 +│ ├── login.php # 登录页面 +│ ├── chat.php # 聊天主页面 +│ ├── config.php # 配置管理页面 +│ └── install.php # 安装向导页面 +├── app/ # 应用代码(PSR-4 自动加载) +│ ├── Config/ # 配置管理(Database、AppConfig) +│ ├── Controllers/ # 控制器(Auth、Chat、Session 等) +│ ├── Middleware/ # 中间件(Auth、Admin) +│ ├── Models/ # 数据模型(User、Session、Message 等) +│ ├── Services/ # 服务层(AIService、Installer、Providers) +│ └── Views/ # 视图模板(login、chat、config、layout) +├── assets/ # 前端静态资源 +│ ├── css/ # 样式文件 +│ ├── js/ # JavaScript 文件 +│ └── img/ # 图片资源 +├── config/ # JSON 配置文件(安装后生成) +├── uploads/ # 用户上传文件 +├── docs/ # 文档 +├── vendor/ # Composer 依赖(安装后生成) +├── composer.json # PHP 依赖配置 +└── .gitignore +``` + +## 🚀 快速开始 + +### 环境要求 + +- PHP >= 8.0 +- MySQL >= 5.7 +- Nginx(或 Apache) +- Composer +- PHP 扩展:PDO、cURL、json、mbstring + +### 安装步骤 + +1. **克隆项目** + ```bash + git clone + cd ai-chat + ``` + +2. **安装 PHP 依赖** + ```bash + composer install + ``` + +3. **配置 Nginx** + - 将网站根目录指向 `public/` 目录 + - 参考 `docs/nginx.conf` 或 `docs/baota-nginx-snippet.conf` 配置 Nginx + +4. **运行安装向导** + - 访问 `http://your-domain.com/install.php` + - 按照向导步骤完成安装 + +详细安装说明请查看 [INSTALL.md](INSTALL.md)。 + +## 📖 文档 + +- [安装指南](INSTALL.md) +- [部署文档](docs/DEPLOY.md) +- [架构说明](docs/ARCHITECTURE.md) +- [API 文档](docs/API.md) +- [人格系统](docs/PERSONALITY.md) +- [Nginx 配置](docs/nginx.conf) +- [宝塔面板配置](docs/baota-nginx-snippet.conf) + +## 📄 开源协议 + +本项目基于 [GNU Affero General Public License v3.0 (AGPLv3)](LICENSE) 开源。 + +## 📌 版本历史 + +| 版本 | 日期 | 说明 | +|------|------|------| +| v1.0.0 | 2026.5.5 | 初始版本,PHP 8.0 全栈实现 | diff --git a/app/Config/.gitkeep b/app/Config/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app/Config/AppConfig.php b/app/Config/AppConfig.php new file mode 100644 index 0000000..f205d7e --- /dev/null +++ b/app/Config/AppConfig.php @@ -0,0 +1,57 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4", + ]); + } + + public static function getInstance(): PDO + { + if (self::$instance === null) { + new self(); + } + + return self::$instance; + } +} diff --git a/app/Controllers/.gitkeep b/app/Controllers/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app/Controllers/AuthController.php b/app/Controllers/AuthController.php new file mode 100644 index 0000000..26ff8ef --- /dev/null +++ b/app/Controllers/AuthController.php @@ -0,0 +1,69 @@ + false, 'message' => '用户名和密码不能为空']); + return; + } + + $user = User::findByUsername($username); + if (!$user || !User::verifyPassword($username, $password)) { + http_response_code(401); + echo json_encode(['success' => false, 'message' => '用户名或密码错误']); + return; + } + + $jwtSecret = AppConfig::get('jwtSecret'); + $jwtExpiry = AppConfig::get('jwtExpiry', 86400); + + $payload = [ + 'userId' => $user['id'], + 'username' => $user['username'], + 'role' => $user['role'], + 'iat' => time(), + 'exp' => time() + $jwtExpiry + ]; + + $token = JWT::encode($payload, $jwtSecret, 'HS256'); + + echo json_encode([ + 'success' => true, + 'data' => [ + 'token' => $token, + 'user' => [ + 'id' => $user['id'], + 'username' => $user['username'], + 'role' => $user['role'] + ] + ] + ]); + } + + public static function me(): void + { + $user = $GLOBALS['auth_user']; + echo json_encode([ + 'success' => true, + 'data' => [ + 'id' => $user['userId'], + 'username' => $user['username'], + 'role' => $user['role'] + ] + ]); + } +} diff --git a/app/Controllers/ChatController.php b/app/Controllers/ChatController.php new file mode 100644 index 0000000..ff4dff2 --- /dev/null +++ b/app/Controllers/ChatController.php @@ -0,0 +1,117 @@ + false, 'message' => '缺少必要参数(provider、model、messages)']); + return; + } + + if (!is_array($messages) || empty($messages)) { + http_response_code(400); + echo json_encode(['success' => false, 'message' => 'messages 必须是非空数组']); + return; + } + + // 获取供应商配置 + $configRow = Config::getByKey('providers'); + if (!$configRow) { + http_response_code(500); + echo json_encode(['success' => false, 'message' => '未找到供应商配置']); + return; + } + + $providers = json_decode($configRow['config_value'], true); + if (!is_array($providers)) { + http_response_code(500); + echo json_encode(['success' => false, 'message' => '供应商配置格式错误']); + return; + } + + $providerKey = $provider; + if (!isset($providers[$providerKey])) { + http_response_code(400); + echo json_encode(['success' => false, 'message' => '供应商不存在: ' . $providerKey]); + return; + } + + $providerConfig = $providers[$providerKey]; + if (empty($providerConfig['enabled'])) { + http_response_code(400); + echo json_encode(['success' => false, 'message' => '供应商已禁用: ' . $providerKey]); + return; + } + + $models = $providerConfig['models'] ?? []; + if (!empty($models) && !in_array($model, $models)) { + http_response_code(400); + echo json_encode(['success' => false, 'message' => '模型不在供应商支持列表中: ' . $model]); + return; + } + + // 非流式模式暂不支持 + if (!$stream) { + http_response_code(400); + echo json_encode(['success' => false, 'message' => '当前仅支持流式响应']); + return; + } + + // 设置 SSE 响应头 + header('Content-Type: text/event-stream'); + header('Cache-Control: no-cache'); + header('Connection: keep-alive'); + header('X-Accel-Buffering: no'); + + // 关闭输出缓冲 + while (ob_get_level()) { + ob_end_flush(); + } + + $options = [ + 'provider' => $providerConfig, + 'systemPrompt' => $systemPrompt, + 'thinkingMode' => (bool) $thinkingMode + ]; + + try { + AIService::streamChat($providerKey, $model, $messages, $options, function ($chunk, $type = 'content') { + if ($type === 'thinking') { + self::sendSSE(['thinking' => $chunk]); + } else { + self::sendSSE(['content' => $chunk]); + } + }); + + self::sendSSE('[DONE]'); + } catch (\Throwable $e) { + self::sendSSE(['type' => 'error', 'message' => $e->getMessage()]); + } + } + + private static function sendSSE($data): void + { + if (is_string($data)) { + echo "data: " . $data . "\n\n"; + } else { + echo "data: " . json_encode($data, JSON_UNESCAPED_UNICODE) . "\n\n"; + } + flush(); + } +} diff --git a/app/Controllers/ConfigController.php b/app/Controllers/ConfigController.php new file mode 100644 index 0000000..1dc78d7 --- /dev/null +++ b/app/Controllers/ConfigController.php @@ -0,0 +1,106 @@ + true, 'data' => ['providers' => $providers]]); + } + + public static function updateConfig(): void + { + $input = json_decode(file_get_contents('php://input'), true); + $providers = $input['providers'] ?? []; + + if (!is_array($providers)) { + http_response_code(400); + echo json_encode(['success' => false, 'message' => 'providers 必须是数组']); + return; + } + + Config::setByKey('providers', json_encode($providers, JSON_UNESCAPED_UNICODE)); + echo json_encode(['success' => true, 'message' => '配置更新成功']); + } + + public static function listPersonalities(): void + { + $personalities = Personality::findAll(); + echo json_encode(['success' => true, 'data' => $personalities]); + } + + public static function createPersonality(): void + { + $input = json_decode(file_get_contents('php://input'), true); + $name = $input['name'] ?? ''; + $prompt = $input['prompt'] ?? ''; + + if (!$name || !$prompt) { + http_response_code(400); + echo json_encode(['success' => false, 'message' => '名称和提示词不能为空']); + return; + } + + $data = [ + 'name' => $name, + 'prompt' => $prompt, + 'description' => $input['description'] ?? null, + 'icon' => $input['icon'] ?? null, + 'is_preset' => 0, + 'created_by' => $GLOBALS['auth_user']['userId'] + ]; + + $personality = Personality::create($data); + echo json_encode(['success' => true, 'data' => $personality]); + } + + public static function updatePersonality($id): void + { + $personality = Personality::findById($id); + if (!$personality) { + http_response_code(404); + echo json_encode(['success' => false, 'message' => '人格不存在']); + return; + } + if ($personality['is_preset']) { + http_response_code(400); + echo json_encode(['success' => false, 'message' => '预设人格不可编辑']); + return; + } + + $input = json_decode(file_get_contents('php://input'), true); + $data = array_filter([ + 'name' => $input['name'] ?? null, + 'prompt' => $input['prompt'] ?? null, + 'description' => $input['description'] ?? null, + 'icon' => $input['icon'] ?? null, + ], fn($v) => $v !== null); + + $updated = Personality::update($id, $data); + echo json_encode(['success' => true, 'data' => $updated]); + } + + public static function deletePersonality($id): void + { + $personality = Personality::findById($id); + if (!$personality) { + http_response_code(404); + echo json_encode(['success' => false, 'message' => '人格不存在']); + return; + } + if ($personality['is_preset']) { + http_response_code(400); + echo json_encode(['success' => false, 'message' => '预设人格不可删除']); + return; + } + + Personality::delete($id); + echo json_encode(['success' => true, 'message' => '删除成功']); + } +} diff --git a/app/Controllers/InstallController.php b/app/Controllers/InstallController.php new file mode 100644 index 0000000..bd9f072 --- /dev/null +++ b/app/Controllers/InstallController.php @@ -0,0 +1,118 @@ + true, 'data' => ['installed' => $installed]]); + } + + public static function testDb(): void + { + $input = json_decode(file_get_contents('php://input'), true); + $host = $input['host'] ?? ''; + $port = $input['port'] ?? 3306; + $user = $input['user'] ?? ''; + $password = $input['password'] ?? ''; + $database = $input['database'] ?? ''; + + if (!$host || !$user || !$database) { + http_response_code(400); + echo json_encode(['success' => false, 'message' => '缺少必要参数']); + return; + } + + try { + $dsn = "mysql:host={$host};port={$port};dbname={$database};charset=utf8mb4"; + $pdo = new \PDO($dsn, $user, $password, [ + \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION + ]); + echo json_encode(['success' => true, 'message' => '数据库连接成功']); + } catch (\PDOException $e) { + http_response_code(500); + echo json_encode(['success' => false, 'message' => '数据库连接失败: ' . $e->getMessage()]); + } + } + + public static function setup(): void + { + if (Installer::isInstalled()) { + http_response_code(400); + echo json_encode(['success' => false, 'message' => '系统已安装,不能重复安装']); + return; + } + + $input = json_decode(file_get_contents('php://input'), true); + $username = $input['username'] ?? ''; + $password = $input['password'] ?? ''; + $dbConfig = $input['dbConfig'] ?? []; + $providers = $input['providers'] ?? []; + + if (!$username || !$password) { + http_response_code(400); + echo json_encode(['success' => false, 'message' => '用户名和密码不能为空']); + return; + } + if (strlen($password) < 6) { + http_response_code(400); + echo json_encode(['success' => false, 'message' => '密码长度不能少于6位']); + return; + } + if (!$dbConfig || !$dbConfig['host'] || !$dbConfig['user'] || !$dbConfig['database']) { + http_response_code(400); + echo json_encode(['success' => false, 'message' => '请填写数据库配置']); + return; + } + if (!is_array($providers) || count($providers) === 0) { + http_response_code(400); + echo json_encode(['success' => false, 'message' => '请至少配置一个AI服务提供商']); + return; + } + + try { + $dsn = "mysql:host={$dbConfig['host']};port=" . ($dbConfig['port'] ?? 3306) . ";dbname={$dbConfig['database']};charset=utf8mb4"; + $pdo = new \PDO($dsn, $dbConfig['user'], $dbConfig['password'], [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]); + } catch (\PDOException $e) { + http_response_code(400); + echo json_encode(['success' => false, 'message' => '数据库连接失败: ' . $e->getMessage()]); + return; + } + + $configDir = __DIR__ . '/../../config'; + file_put_contents($configDir . '/db-config.json', json_encode($dbConfig, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + + try { + Installer::runMigration(); + } catch (\Exception $e) { + http_response_code(500); + echo json_encode(['success' => false, 'message' => '数据库迁移失败: ' . $e->getMessage()]); + return; + } + + Installer::seedDefaults(); + + $admin = User::create(['username' => $username, 'password' => $password, 'role' => 'admin']); + + $jwtSecret = bin2hex(random_bytes(32)); + AppConfig::set('jwtSecret', $jwtSecret); + AppConfig::set('jwtExpiry', 86400); + AppConfig::set('corsOrigin', ''); + + Config::setByKey('providers', json_encode($providers)); + + echo json_encode([ + 'success' => true, + 'message' => '安装成功', + 'data' => ['user' => $admin] + ]); + } +} diff --git a/app/Controllers/MessageController.php b/app/Controllers/MessageController.php new file mode 100644 index 0000000..7b2d51f --- /dev/null +++ b/app/Controllers/MessageController.php @@ -0,0 +1,57 @@ + false, 'message' => '会话不存在']); + return; + } + $messages = Message::findBySessionId($sessionId); + echo json_encode(['success' => true, 'data' => $messages]); + } + + public static function create(int $sessionId): void + { + $user = $GLOBALS['auth_user']; + $session = Session::findById($sessionId); + if (!$session || $session['user_id'] != $user['userId']) { + http_response_code(404); + echo json_encode(['success' => false, 'message' => '会话不存在']); + return; + } + + $input = json_decode(file_get_contents('php://input'), true); + + if (empty($input['role']) || !isset($input['content']) || $input['content'] === '') { + http_response_code(400); + echo json_encode(['success' => false, 'message' => 'role 和 content 为必填字段']); + return; + } + + $data = [ + 'session_id' => $sessionId, + 'role' => $input['role'], + 'content' => $input['content'], + ]; + + if (isset($input['file_info'])) { + $data['file_info'] = $input['file_info']; + } + if (isset($input['thinking_content'])) { + $data['thinking_content'] = $input['thinking_content']; + } + + $message = Message::create($data); + echo json_encode(['success' => true, 'data' => $message]); + } +} diff --git a/app/Controllers/SessionController.php b/app/Controllers/SessionController.php new file mode 100644 index 0000000..e0c8ca1 --- /dev/null +++ b/app/Controllers/SessionController.php @@ -0,0 +1,88 @@ + true, 'data' => $sessions]); + } + + public static function create(): void + { + $input = json_decode(file_get_contents('php://input'), true); + $user = $GLOBALS['auth_user']; + + $data = [ + 'user_id' => $user['userId'], + 'name' => $input['name'] ?? '新会话', + 'provider' => $input['provider'] ?? 'newapi', + 'model' => $input['model'] ?? 'gpt-3.5-turbo', + 'system_prompt' => $input['system_prompt'] ?? '', + 'thinking_mode' => $input['thinking_mode'] ?? false, + ]; + + if (isset($input['personality_id'])) { + $data['personality_id'] = $input['personality_id']; + } + + $session = Session::create($data); + echo json_encode(['success' => true, 'data' => $session]); + } + + public static function update(int $id): void + { + $session = Session::findById($id); + if (!$session) { + http_response_code(404); + echo json_encode(['success' => false, 'message' => '会话不存在']); + return; + } + + $user = $GLOBALS['auth_user']; + if ($session['user_id'] != $user['userId']) { + http_response_code(404); + echo json_encode(['success' => false, 'message' => '会话不存在']); + return; + } + + $input = json_decode(file_get_contents('php://input'), true); + + $allowedFields = ['name', 'provider', 'model', 'system_prompt', 'personality_id', 'thinking_mode']; + $data = []; + foreach ($allowedFields as $field) { + if (isset($input[$field])) { + $data[$field] = $input[$field]; + } + } + + Session::update($id, $data); + $updatedSession = Session::findById($id); + echo json_encode(['success' => true, 'data' => $updatedSession]); + } + + public static function delete(int $id): void + { + $session = Session::findById($id); + if (!$session) { + http_response_code(404); + echo json_encode(['success' => false, 'message' => '会话不存在']); + return; + } + + $user = $GLOBALS['auth_user']; + if ($session['user_id'] != $user['userId']) { + http_response_code(404); + echo json_encode(['success' => false, 'message' => '会话不存在']); + return; + } + + Session::delete($id); + echo json_encode(['success' => true, 'message' => '删除成功']); + } +} diff --git a/app/Controllers/UploadController.php b/app/Controllers/UploadController.php new file mode 100644 index 0000000..b54c42a --- /dev/null +++ b/app/Controllers/UploadController.php @@ -0,0 +1,56 @@ + false, 'message' => '请选择要上传的文件']); + return; + } + + $file = $_FILES['file']; + $allowedTypes = [ + 'jpg', 'jpeg', 'png', 'gif', 'webp', + 'js', 'ts', 'py', 'java', 'cpp', 'c', 'html', 'css', 'json', 'xml', + 'txt', 'md', 'go', 'rs', 'php', 'rb', 'sql', 'yaml', 'yml', 'sh', 'bat' + ]; + + $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)); + + if (!in_array($ext, $allowedTypes)) { + http_response_code(400); + echo json_encode(['success' => false, 'message' => '不支持的文件类型: .' . $ext]); + return; + } + + if ($file['size'] > 10 * 1024 * 1024) { + http_response_code(400); + echo json_encode(['success' => false, 'message' => '文件大小不能超过10MB']); + return; + } + + $filename = uniqid() . '.' . $ext; + $uploadDir = __DIR__ . '/../../uploads/'; + $filepath = $uploadDir . $filename; + + if (!move_uploaded_file($file['tmp_name'], $filepath)) { + http_response_code(500); + echo json_encode(['success' => false, 'message' => '文件上传失败']); + return; + } + + echo json_encode([ + 'success' => true, + 'data' => [ + 'url' => '/uploads/' . $filename, + 'name' => $file['name'], + 'size' => $file['size'], + 'type' => $ext + ] + ]); + } +} diff --git a/app/Middleware/.gitkeep b/app/Middleware/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app/Middleware/AdminMiddleware.php b/app/Middleware/AdminMiddleware.php new file mode 100644 index 0000000..37c7d81 --- /dev/null +++ b/app/Middleware/AdminMiddleware.php @@ -0,0 +1,17 @@ + false, 'message' => '需要管理员权限']); + exit; + } + } +} diff --git a/app/Middleware/AuthMiddleware.php b/app/Middleware/AuthMiddleware.php new file mode 100644 index 0000000..741c8b4 --- /dev/null +++ b/app/Middleware/AuthMiddleware.php @@ -0,0 +1,37 @@ + false, 'message' => '请先登录']); + exit; + } + + $token = $matches[1]; + + try { + $jwtSecret = AppConfig::get('jwtSecret'); + $decoded = JWT::decode($token, new Key($jwtSecret, 'HS256')); + $GLOBALS['auth_user'] = [ + 'userId' => $decoded->userId, + 'username' => $decoded->username, + 'role' => $decoded->role + ]; + } catch (\Exception $e) { + http_response_code(401); + echo json_encode(['success' => false, 'message' => '请先登录']); + exit; + } + } +} diff --git a/app/Models/.gitkeep b/app/Models/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app/Models/Config.php b/app/Models/Config.php new file mode 100644 index 0000000..f63791d --- /dev/null +++ b/app/Models/Config.php @@ -0,0 +1,27 @@ +prepare('SELECT * FROM config WHERE config_key = :config_key'); + $stmt->execute(['config_key' => $key]); + $result = $stmt->fetch(); + return $result ?: null; + } + + public static function setByKey(string $key, string $value): bool + { + $db = Database::getInstance(); + $stmt = $db->prepare('INSERT INTO config (config_key, config_value) VALUES (:config_key, :config_value) ON DUPLICATE KEY UPDATE config_value = VALUES(config_value)'); + return $stmt->execute([ + 'config_key' => $key, + 'config_value' => $value, + ]); + } +} diff --git a/app/Models/Message.php b/app/Models/Message.php new file mode 100644 index 0000000..dc3da09 --- /dev/null +++ b/app/Models/Message.php @@ -0,0 +1,33 @@ +prepare('SELECT * FROM messages WHERE session_id = :session_id ORDER BY created_at ASC'); + $stmt->execute(['session_id' => $sessionId]); + return $stmt->fetchAll(); + } + + public static function create(array $data): array + { + $db = Database::getInstance(); + $stmt = $db->prepare('INSERT INTO messages (session_id, role, content, file_info, thinking_content) VALUES (:session_id, :role, :content, :file_info, :thinking_content)'); + $stmt->execute([ + 'session_id' => $data['session_id'], + 'role' => $data['role'], + 'content' => $data['content'], + 'file_info' => isset($data['file_info']) ? json_encode($data['file_info']) : null, + 'thinking_content' => $data['thinking_content'] ?? null, + ]); + + $stmt = $db->prepare('SELECT * FROM messages WHERE id = :id'); + $stmt->execute(['id' => (int) $db->lastInsertId()]); + return $stmt->fetch(); + } +} diff --git a/app/Models/Personality.php b/app/Models/Personality.php new file mode 100644 index 0000000..f051bb1 --- /dev/null +++ b/app/Models/Personality.php @@ -0,0 +1,65 @@ +query('SELECT * FROM personalities ORDER BY id ASC'); + return $stmt->fetchAll(); + } + + public static function findById(int $id): ?array + { + $db = Database::getInstance(); + $stmt = $db->prepare('SELECT * FROM personalities WHERE id = :id'); + $stmt->execute(['id' => $id]); + $result = $stmt->fetch(); + return $result ?: null; + } + + public static function create(array $data): array + { + $db = Database::getInstance(); + $stmt = $db->prepare('INSERT INTO personalities (name, prompt, description, icon, is_preset, created_by) VALUES (:name, :prompt, :description, :icon, :is_preset, :created_by)'); + $stmt->execute([ + 'name' => $data['name'], + 'prompt' => $data['prompt'], + 'description' => $data['description'] ?? null, + 'icon' => $data['icon'] ?? null, + 'is_preset' => $data['is_preset'] ?? 0, + 'created_by' => $data['created_by'] ?? null, + ]); + + return self::findById((int) $db->lastInsertId()); + } + + public static function update(int $id, array $data): bool + { + $db = Database::getInstance(); + + $fields = []; + $params = ['id' => $id]; + + foreach ($data as $key => $value) { + $fields[] = "{$key} = :{$key}"; + $params[$key] = $value; + } + + $sql = 'UPDATE personalities SET ' . implode(', ', $fields) . ' WHERE id = :id'; + $stmt = $db->prepare($sql); + + return $stmt->execute($params); + } + + public static function delete(int $id): bool + { + $db = Database::getInstance(); + $stmt = $db->prepare('DELETE FROM personalities WHERE id = :id'); + return $stmt->execute(['id' => $id]); + } +} diff --git a/app/Models/Session.php b/app/Models/Session.php new file mode 100644 index 0000000..fe91e78 --- /dev/null +++ b/app/Models/Session.php @@ -0,0 +1,78 @@ +prepare('SELECT * FROM sessions WHERE user_id = :user_id ORDER BY updated_at DESC'); + $stmt->execute(['user_id' => $userId]); + return $stmt->fetchAll(); + } + + public static function findById(int $id): ?array + { + $db = Database::getInstance(); + $stmt = $db->prepare('SELECT * FROM sessions WHERE id = :id'); + $stmt->execute(['id' => $id]); + $result = $stmt->fetch(); + return $result ?: null; + } + + public static function create(array $data): array + { + $db = Database::getInstance(); + $stmt = $db->prepare('INSERT INTO sessions (user_id, name, provider, model, system_prompt, personality_id, thinking_mode) VALUES (:user_id, :name, :provider, :model, :system_prompt, :personality_id, :thinking_mode)'); + $stmt->execute([ + 'user_id' => $data['user_id'], + 'name' => $data['name'] ?? '新会话', + 'provider' => $data['provider'] ?? 'newapi', + 'model' => $data['model'] ?? 'gpt-3.5-turbo', + 'system_prompt' => $data['system_prompt'] ?? '', + 'personality_id' => $data['personality_id'] ?? null, + 'thinking_mode' => $data['thinking_mode'] ?? 0, + ]); + + return self::findById((int) $db->lastInsertId()); + } + + public static function update(int $id, array $data): bool + { + $db = Database::getInstance(); + + $fields = []; + $params = ['id' => $id]; + + foreach ($data as $key => $value) { + $fields[] = "{$key} = :{$key}"; + $params[$key] = $value; + } + + $fields[] = 'updated_at = CURRENT_TIMESTAMP'; + + $sql = 'UPDATE sessions SET ' . implode(', ', $fields) . ' WHERE id = :id'; + $stmt = $db->prepare($sql); + + return $stmt->execute($params); + } + + public static function delete(int $id): bool + { + $db = Database::getInstance(); + + $db->beginTransaction(); + try { + $db->prepare('DELETE FROM messages WHERE session_id = :session_id')->execute(['session_id' => $id]); + $db->prepare('DELETE FROM sessions WHERE id = :id')->execute(['id' => $id]); + $db->commit(); + return true; + } catch (\Throwable $e) { + $db->rollBack(); + throw $e; + } + } +} diff --git a/app/Models/User.php b/app/Models/User.php new file mode 100644 index 0000000..d16035a --- /dev/null +++ b/app/Models/User.php @@ -0,0 +1,55 @@ +prepare('SELECT * FROM users WHERE username = :username'); + $stmt->execute(['username' => $username]); + $result = $stmt->fetch(); + return $result ?: null; + } + + public static function findById(int $id): ?array + { + $db = Database::getInstance(); + $stmt = $db->prepare('SELECT * FROM users WHERE id = :id'); + $stmt->execute(['id' => $id]); + $result = $stmt->fetch(); + return $result ?: null; + } + + public static function create(array $data): array + { + $db = Database::getInstance(); + $stmt = $db->prepare('INSERT INTO users (username, password_hash, role) VALUES (:username, :password_hash, :role)'); + $stmt->execute([ + 'username' => $data['username'], + 'password_hash' => password_hash($data['password'], PASSWORD_DEFAULT), + 'role' => $data['role'] ?? 'user', + ]); + + $user = self::findById((int) $db->lastInsertId()); + unset($user['password_hash']); + return $user; + } + + public static function verifyPassword(string $username, string $password): bool + { + $db = Database::getInstance(); + $stmt = $db->prepare('SELECT password_hash FROM users WHERE username = :username'); + $stmt->execute(['username' => $username]); + $result = $stmt->fetch(); + + if (!$result) { + return false; + } + + return password_verify($password, $result['password_hash']); + } +} diff --git a/app/Services/AIService.php b/app/Services/AIService.php new file mode 100644 index 0000000..7ae5df9 --- /dev/null +++ b/app/Services/AIService.php @@ -0,0 +1,26 @@ + OpenAIProvider::class, + 'claude' => ClaudeProvider::class, + 'newapi' => NewAPIProvider::class, + default => throw new \RuntimeException('不支持的供应商: ' . $name) + }; + } +} diff --git a/app/Services/Installer.php b/app/Services/Installer.php new file mode 100644 index 0000000..9f607d9 --- /dev/null +++ b/app/Services/Installer.php @@ -0,0 +1,132 @@ +query("SHOW TABLES LIKE 'users'"); + return $stmt->fetch() !== false; + } catch (\Throwable $e) { + return false; + } + } + + public static function runMigration(): void + { + $db = Database::getInstance(); + + $db->exec("CREATE TABLE IF NOT EXISTS users ( + id INT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(50) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + role ENUM('admin','user') DEFAULT 'user', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + + $db->exec("CREATE TABLE IF NOT EXISTS sessions ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + name VARCHAR(100), + provider VARCHAR(50), + model VARCHAR(50), + system_prompt TEXT, + personality_id INT NULL, + thinking_mode TINYINT(1) DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_user_id (user_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + + $db->exec("CREATE TABLE IF NOT EXISTS messages ( + id INT AUTO_INCREMENT PRIMARY KEY, + session_id INT NOT NULL, + role ENUM('user','assistant','system') NOT NULL, + content LONGTEXT, + file_info JSON, + thinking_content LONGTEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX idx_session_id (session_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + + $db->exec("CREATE TABLE IF NOT EXISTS config ( + id INT AUTO_INCREMENT PRIMARY KEY, + config_key VARCHAR(50) UNIQUE NOT NULL, + config_value LONGTEXT + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + + $db->exec("CREATE TABLE IF NOT EXISTS personalities ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(50) NOT NULL, + prompt TEXT NOT NULL, + description TEXT, + icon VARCHAR(50), + is_preset TINYINT(1) DEFAULT 0, + created_by INT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); + } + + public static function seedDefaults(): void + { + $db = Database::getInstance(); + + $presets = [ + [ + 'name' => '智能助手', + 'prompt' => '你是一个全能的智能助手,善于回答各种问题,提供准确、有帮助的回答。', + 'icon' => '🤖', + ], + [ + 'name' => '代码专家', + 'prompt' => '你是一个专业的编程专家,精通多种编程语言和技术框架,擅长代码编写、调试和优化。', + 'icon' => '💻', + ], + [ + 'name' => '翻译官', + 'prompt' => '你是一个专业的翻译官,精通中文、英文、日文等多种语言,提供准确、流畅的翻译服务。', + 'icon' => '🌐', + ], + [ + 'name' => '写作助手', + 'prompt' => '你是一个专业的写作助手,擅长各类文体的写作,包括文章、报告、邮件、创意写作等。', + 'icon' => '✍️', + ], + [ + 'name' => '数学家', + 'prompt' => '你是一个数学专家,精通各领域的数学知识,善于解答数学问题并提供详细的解题步骤。', + 'icon' => '🔢', + ], + ]; + + $stmt = $db->prepare('INSERT INTO personalities (name, prompt, description, icon, is_preset) VALUES (:name, :prompt, :description, :icon, :is_preset)'); + + foreach ($presets as $preset) { + $stmt->execute([ + 'name' => $preset['name'], + 'prompt' => $preset['prompt'], + 'description' => null, + 'icon' => $preset['icon'], + 'is_preset' => 1, + ]); + } + + $configStmt = $db->prepare('INSERT INTO config (config_key, config_value) VALUES (:config_key, :config_value) ON DUPLICATE KEY UPDATE config_value = VALUES(config_value)'); + $configStmt->execute([ + 'config_key' => 'providers', + 'config_value' => '[]', + ]); + } +} diff --git a/app/Services/Providers/.gitkeep b/app/Services/Providers/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app/Services/Providers/ClaudeProvider.php b/app/Services/Providers/ClaudeProvider.php new file mode 100644 index 0000000..71e8706 --- /dev/null +++ b/app/Services/Providers/ClaudeProvider.php @@ -0,0 +1,118 @@ + $model, + 'max_tokens' => $thinkingMode ? 16000 : 4096, + 'messages' => $claudeMessages, + 'stream' => true + ]; + + if (!empty($systemPrompt)) { + $bodyData['system'] = $systemPrompt; + } + + if ($thinkingMode) { + $bodyData['thinking'] = [ + 'type' => 'enabled', + 'budget_tokens' => 10000 + ]; + } + + $body = json_encode($bodyData); + + $headers = [ + 'x-api-key: ' . $apiKey, + 'anthropic-version: 2023-06-01', + 'Content-Type: application/json', + 'Accept: text/event-stream' + ]; + + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $body, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_TIMEOUT => 120, + CURLOPT_RETURNTRANSFER => false, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_WRITEFUNCTION => function ($ch, $data) use ($onChunk) { + $lines = explode("\n", $data); + $currentEvent = ''; + + foreach ($lines as $line) { + $line = trim($line); + if ($line === '') { + $currentEvent = ''; + continue; + } + if (str_starts_with($line, 'event: ')) { + $currentEvent = substr($line, 7); + continue; + } + if (str_starts_with($line, 'data: ')) { + $payload = substr($line, 6); + $json = json_decode($payload, true); + + if (!$json) { + continue; + } + + switch ($currentEvent) { + case 'content_block_delta': + if (isset($json['delta'])) { + $delta = $json['delta']; + if (isset($delta['type']) && $delta['type'] === 'thinking_delta' && isset($delta['thinking'])) { + $onChunk($delta['thinking'], 'thinking'); + } elseif (isset($delta['text'])) { + $onChunk($delta['text'], 'content'); + } + } + break; + case 'message_stop': + return strlen($data); + case 'error': + if (isset($json['error']['message'])) { + throw new \RuntimeException('Claude API错误: ' . $json['error']['message']); + } + break; + } + } + } + return strlen($data); + } + ]); + + $result = curl_exec($ch); + if ($result === false) { + $error = curl_error($ch); + curl_close($ch); + throw new \RuntimeException('API请求失败: ' . $error); + } + + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($httpCode >= 400) { + throw new \RuntimeException('API返回错误,状态码: ' . $httpCode); + } + } +} diff --git a/app/Services/Providers/NewAPIProvider.php b/app/Services/Providers/NewAPIProvider.php new file mode 100644 index 0000000..f9c2c9e --- /dev/null +++ b/app/Services/Providers/NewAPIProvider.php @@ -0,0 +1,11 @@ + 'system', 'content' => $systemPrompt]); + } + + $body = json_encode([ + 'model' => $model, + 'messages' => $messages, + 'stream' => true + ]); + + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $body, + CURLOPT_HTTPHEADER => [ + 'Authorization: Bearer ' . $apiKey, + 'Content-Type: application/json', + 'Accept: text/event-stream' + ], + CURLOPT_TIMEOUT => 120, + CURLOPT_RETURNTRANSFER => false, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_WRITEFUNCTION => function ($ch, $data) use ($onChunk) { + $lines = explode("\n", $data); + foreach ($lines as $line) { + $line = trim($line); + if ($line === '') { + continue; + } + if (str_starts_with($line, 'data: ')) { + $payload = substr($line, 6); + if ($payload === '[DONE]') { + return strlen($data); + } + $json = json_decode($payload, true); + if ($json && isset($json['choices'][0]['delta']['content'])) { + $content = $json['choices'][0]['delta']['content']; + if ($content !== '') { + $onChunk($content); + } + } + } + } + return strlen($data); + } + ]); + + $result = curl_exec($ch); + if ($result === false) { + $error = curl_error($ch); + curl_close($ch); + throw new \RuntimeException('API请求失败: ' . $error); + } + + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($httpCode >= 400) { + throw new \RuntimeException('API返回错误,状态码: ' . $httpCode); + } + } +} diff --git a/app/Views/chat.php b/app/Views/chat.php new file mode 100644 index 0000000..cd8189d --- /dev/null +++ b/app/Views/chat.php @@ -0,0 +1,164 @@ +
+ + + + +
+ +
+ + + + + + + + 思考 + + + + ⚙️ 设置 +
+ + +
+
+

开始新的对话

+

输入消息开始聊天

+
+
+ + +
+
+
+ + + +
+
+
+
+ + diff --git a/app/Views/config.php b/app/Views/config.php new file mode 100644 index 0000000..b3a2dda --- /dev/null +++ b/app/Views/config.php @@ -0,0 +1,249 @@ +
+
+

⚙️ 系统配置

+ ← 返回聊天 +
+ + +
+

AI 供应商管理

+
+ +
+ +
+ + +
+

人格管理

+
+ +
+

添加自定义人格

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ +
+ + diff --git a/app/Views/layout/.gitkeep b/app/Views/layout/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app/Views/layout/footer.php b/app/Views/layout/footer.php new file mode 100644 index 0000000..3d7e931 --- /dev/null +++ b/app/Views/layout/footer.php @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/app/Views/layout/header.php b/app/Views/layout/header.php new file mode 100644 index 0000000..6ad2ad9 --- /dev/null +++ b/app/Views/layout/header.php @@ -0,0 +1,12 @@ + + + + + + AI Chat + + + + + + diff --git a/app/Views/login.php b/app/Views/login.php new file mode 100644 index 0000000..c9b54bf --- /dev/null +++ b/app/Views/login.php @@ -0,0 +1,69 @@ + + diff --git a/assets/css/.gitkeep b/assets/css/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/assets/css/chat.css b/assets/css/chat.css new file mode 100644 index 0000000..dad5d5f --- /dev/null +++ b/assets/css/chat.css @@ -0,0 +1,345 @@ +/* 聊天布局 */ +.chat-layout { + display: flex; + height: 100vh; + overflow: hidden; +} + +/* 侧边栏 */ +.sidebar { + width: var(--sidebar-width); + background: var(--bg-secondary); + border-right: 1px solid var(--border-color); + display: flex; + flex-direction: column; + transition: transform 0.3s; + flex-shrink: 0; +} + +.sidebar.collapsed { + transform: translateX(-100%); + width: 0; + border: none; +} + +.sidebar-header { + padding: 16px; + border-bottom: 1px solid var(--border-color); + display: flex; + gap: 8px; +} + +.sidebar-header .btn { + flex: 1; +} + +.session-list { + flex: 1; + overflow-y: auto; + padding: 8px; +} + +.session-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px; + border-radius: var(--radius); + cursor: pointer; + transition: background 0.2s; + margin-bottom: 4px; +} + +.session-item:hover { + background: var(--bg-card); +} + +.session-item.active { + background: var(--primary); + color: white; +} + +.session-item .session-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 14px; +} + +.session-item .session-delete { + opacity: 0; + background: none; + border: none; + color: inherit; + cursor: pointer; + padding: 4px; + font-size: 16px; +} + +.session-item:hover .session-delete { + opacity: 1; +} + +/* 主区域 */ +.chat-main { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; +} + +/* 工具栏 */ +.toolbar { + height: var(--toolbar-height); + background: var(--bg-secondary); + border-bottom: 1px solid var(--border-color); + display: flex; + align-items: center; + padding: 0 16px; + gap: 12px; + flex-shrink: 0; +} + +.toolbar select { + padding: 6px 10px; + background: var(--bg-input); + border: 1px solid var(--border-color); + border-radius: var(--radius); + color: var(--text-primary); + font-size: 13px; +} + +.toolbar .toggle-sidebar { + background: none; + border: none; + color: var(--text-primary); + cursor: pointer; + font-size: 20px; + padding: 4px; +} + +/* 思考模式开关 */ +.toggle-switch { + position: relative; + display: inline-block; + width: 40px; + height: 20px; +} + +.toggle-switch input { + opacity: 0; + width: 0; + height: 0; +} + +.toggle-switch .slider { + position: absolute; + cursor: pointer; + top: 0; left: 0; right: 0; bottom: 0; + background-color: var(--border-color); + border-radius: 20px; + transition: 0.3s; +} + +.toggle-switch .slider:before { + position: absolute; + content: ""; + height: 16px; + width: 16px; + left: 2px; + bottom: 2px; + background-color: white; + border-radius: 50%; + transition: 0.3s; +} + +.toggle-switch input:checked + .slider { + background-color: var(--primary); +} + +.toggle-switch input:checked + .slider:before { + transform: translateX(20px); +} + +/* 消息区域 */ +.messages-container { + flex: 1; + overflow-y: auto; + padding: 20px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.message { + max-width: 80%; + padding: 12px 16px; + border-radius: var(--radius); + line-height: 1.6; + font-size: 14px; + word-wrap: break-word; +} + +.message.user { + align-self: flex-end; + background: var(--primary); + color: white; + border-bottom-right-radius: 2px; +} + +.message.assistant { + align-self: flex-start; + background: var(--bg-card); + border-bottom-left-radius: 2px; +} + +.message .message-role { + font-size: 12px; + color: var(--text-secondary); + margin-bottom: 4px; +} + +/* 思考内容折叠 */ +.thinking-toggle { + font-size: 12px; + color: var(--primary); + cursor: pointer; + margin-top: 8px; + display: flex; + align-items: center; + gap: 4px; +} + +.thinking-content { + display: none; + margin-top: 8px; + padding: 12px; + background: rgba(0,0,0,0.2); + border-radius: var(--radius); + font-size: 13px; + color: var(--text-secondary); + border-left: 3px solid var(--primary); +} + +.thinking-content.expanded { + display: block; +} + +/* 打字机光标 */ +.typing-cursor::after { + content: '▊'; + animation: blink 1s step-end infinite; + color: var(--primary); +} + +@keyframes blink { + 50% { opacity: 0; } +} + +/* 输入区域 */ +.input-area { + padding: 16px; + background: var(--bg-secondary); + border-top: 1px solid var(--border-color); + flex-shrink: 0; +} + +.input-wrapper { + display: flex; + align-items: flex-end; + gap: 8px; + background: var(--bg-input); + border: 1px solid var(--border-color); + border-radius: var(--radius); + padding: 8px 12px; +} + +.input-wrapper textarea { + flex: 1; + background: none; + border: none; + color: var(--text-primary); + font-size: 14px; + line-height: 1.5; + resize: none; + max-height: 120px; + min-height: 24px; + font-family: var(--font-family); +} + +.input-wrapper textarea:focus { + outline: none; +} + +.input-wrapper button { + background: var(--primary); + border: none; + color: white; + padding: 8px 12px; + border-radius: var(--radius); + cursor: pointer; + font-size: 14px; + white-space: nowrap; +} + +.input-wrapper button:hover { + background: var(--primary-hover); +} + +.input-wrapper button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* 文件预览 */ +.file-preview { + display: flex; + gap: 8px; + padding: 8px 0; + flex-wrap: wrap; +} + +.file-preview-item { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + background: var(--bg-card); + border-radius: 4px; + font-size: 12px; +} + +.file-preview-item .remove-file { + cursor: pointer; + color: var(--danger); + font-weight: bold; +} + +/* 空状态 */ +.empty-state { + text-align: center; + color: var(--text-secondary); + padding: 60px 20px; +} + +.empty-state h3 { + margin-bottom: 8px; +} + +/* 响应式 */ +@media (max-width: 768px) { + .sidebar { + position: fixed; + z-index: 100; + height: 100vh; + } + .message { + max-width: 95%; + } + .toolbar { + flex-wrap: wrap; + height: auto; + padding: 8px 12px; + } +} diff --git a/assets/css/markdown.css b/assets/css/markdown.css new file mode 100644 index 0000000..79d8419 --- /dev/null +++ b/assets/css/markdown.css @@ -0,0 +1,127 @@ +/* 消息中的 Markdown 内容 */ +.message.assistant .markdown-body { + font-size: 14px; + line-height: 1.7; +} + +.markdown-body p { + margin-bottom: 12px; +} + +.markdown-body p:last-child { + margin-bottom: 0; +} + +.markdown-body h1, .markdown-body h2, .markdown-body h3, +.markdown-body h4, .markdown-body h5, .markdown-body h6 { + margin-top: 16px; + margin-bottom: 8px; + font-weight: 600; +} + +.markdown-body h1 { font-size: 1.4em; } +.markdown-body h2 { font-size: 1.3em; } +.markdown-body h3 { font-size: 1.2em; } + +.markdown-body ul, .markdown-body ol { + margin: 8px 0; + padding-left: 24px; +} + +.markdown-body li { + margin-bottom: 4px; +} + +.markdown-body code { + background: rgba(255,255,255,0.1); + padding: 2px 6px; + border-radius: 3px; + font-size: 0.9em; + font-family: 'Consolas', 'Monaco', monospace; +} + +.markdown-body pre { + background: #1e1e2e; + border-radius: var(--radius); + padding: 0; + margin: 12px 0; + overflow: hidden; + position: relative; +} + +.markdown-body pre code { + display: block; + padding: 16px; + background: none; + overflow-x: auto; + font-size: 13px; + line-height: 1.5; +} + +.markdown-body pre .copy-btn { + position: absolute; + top: 8px; + right: 8px; + background: rgba(255,255,255,0.1); + border: none; + color: var(--text-secondary); + padding: 4px 8px; + border-radius: 4px; + cursor: pointer; + font-size: 12px; + transition: background 0.2s; +} + +.markdown-body pre .copy-btn:hover { + background: rgba(255,255,255,0.2); +} + +.markdown-body blockquote { + border-left: 4px solid var(--primary); + margin: 12px 0; + padding: 8px 16px; + background: rgba(74, 144, 217, 0.05); + color: var(--text-secondary); +} + +.markdown-body table { + width: 100%; + border-collapse: collapse; + margin: 12px 0; + font-size: 13px; +} + +.markdown-body th, .markdown-body td { + border: 1px solid var(--border-color); + padding: 8px 12px; + text-align: left; +} + +.markdown-body th { + background: var(--bg-secondary); + font-weight: 600; +} + +.markdown-body tr:nth-child(even) { + background: rgba(255,255,255,0.02); +} + +.markdown-body a { + color: var(--primary); + text-decoration: none; +} + +.markdown-body a:hover { + text-decoration: underline; +} + +.markdown-body hr { + border: none; + border-top: 1px solid var(--border-color); + margin: 16px 0; +} + +.markdown-body img { + max-width: 100%; + border-radius: var(--radius); +} diff --git a/assets/css/style.css b/assets/css/style.css new file mode 100644 index 0000000..243e69f --- /dev/null +++ b/assets/css/style.css @@ -0,0 +1,230 @@ +/* CSS 变量 */ +:root { + --primary: #4a90d9; + --primary-hover: #357abd; + --bg-primary: #1a1a2e; + --bg-secondary: #16213e; + --bg-card: #1e2a45; + --bg-input: #0f1b33; + --text-primary: #e0e0e0; + --text-secondary: #a0a0a0; + --border-color: #2a3a5c; + --success: #4caf50; + --danger: #f44336; + --warning: #ff9800; + --sidebar-width: 260px; + --toolbar-height: 50px; + --input-height: 120px; + --radius: 8px; + --font-family: system-ui, -apple-system, sans-serif; +} + +/* Reset */ +*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; } + +body { + font-family: var(--font-family); + background: var(--bg-primary); + color: var(--text-primary); + line-height: 1.6; + min-height: 100vh; +} + +/* 登录页样式 */ +.login-container { + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; + padding: 20px; +} + +.login-card { + background: var(--bg-card); + border-radius: var(--radius); + padding: 40px; + width: 100%; + max-width: 400px; + box-shadow: 0 4px 20px rgba(0,0,0,0.3); +} + +.login-card h1 { + text-align: center; + margin-bottom: 30px; + font-size: 24px; + color: var(--primary); +} + +/* 安装向导样式 */ +.install-container { + max-width: 700px; + margin: 40px auto; + padding: 0 20px; +} + +.step-indicator { + display: flex; + justify-content: space-between; + margin-bottom: 30px; + padding: 0; + list-style: none; +} + +.step-indicator .step { + flex: 1; + text-align: center; + padding: 10px; + font-size: 14px; + color: var(--text-secondary); + border-bottom: 3px solid var(--border-color); + transition: all 0.3s; +} + +.step-indicator .step.active { + color: var(--primary); + border-bottom-color: var(--primary); +} + +.step-indicator .step.completed { + color: var(--success); + border-bottom-color: var(--success); +} + +/* 通用表单样式 */ +.form-group { + margin-bottom: 16px; +} + +.form-group label { + display: block; + margin-bottom: 6px; + font-size: 14px; + color: var(--text-secondary); +} + +.form-group input, +.form-group select, +.form-group textarea { + width: 100%; + padding: 10px 12px; + background: var(--bg-input); + border: 1px solid var(--border-color); + border-radius: var(--radius); + color: var(--text-primary); + font-size: 14px; + transition: border-color 0.3s; +} + +.form-group input:focus, +.form-group select:focus, +.form-group textarea:focus { + outline: none; + border-color: var(--primary); +} + +/* 按钮 */ +.btn { + display: inline-block; + padding: 10px 20px; + border: none; + border-radius: var(--radius); + font-size: 14px; + cursor: pointer; + transition: all 0.3s; + text-decoration: none; +} + +.btn-primary { + background: var(--primary); + color: white; +} + +.btn-primary:hover { + background: var(--primary-hover); +} + +.btn-secondary { + background: var(--bg-secondary); + color: var(--text-primary); + border: 1px solid var(--border-color); +} + +.btn-danger { + background: var(--danger); + color: white; +} + +.btn-sm { + padding: 6px 12px; + font-size: 12px; +} + +.btn-block { + display: block; + width: 100%; + text-align: center; +} + +.btn-group { + display: flex; + gap: 10px; + margin-top: 20px; + justify-content: flex-end; +} + +/* 消息提示 */ +.alert { + padding: 12px 16px; + border-radius: var(--radius); + margin-bottom: 16px; + font-size: 14px; +} + +.alert-error { + background: rgba(244, 67, 54, 0.1); + border: 1px solid var(--danger); + color: var(--danger); +} + +.alert-success { + background: rgba(76, 175, 80, 0.1); + border: 1px solid var(--success); + color: var(--success); +} + +/* 配置页样式 */ +.config-container { + max-width: 900px; + margin: 40px auto; + padding: 0 20px; +} + +.config-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 30px; +} + +.config-section { + background: var(--bg-card); + border-radius: var(--radius); + padding: 24px; + margin-bottom: 24px; +} + +.config-section h2 { + margin-bottom: 16px; + font-size: 18px; +} + +/* 响应式 */ +@media (max-width: 768px) { + .login-card { + padding: 24px; + } + .install-container, + .config-container { + margin: 20px auto; + } +} diff --git a/assets/img/.gitkeep b/assets/img/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/assets/js/.gitkeep b/assets/js/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/assets/js/api.js b/assets/js/api.js new file mode 100644 index 0000000..cceeea1 --- /dev/null +++ b/assets/js/api.js @@ -0,0 +1,74 @@ +const api = { + async request(url, options = {}) { + const token = Storage.getToken(); + const headers = { + 'Content-Type': 'application/json', + ...(token ? { 'Authorization': 'Bearer ' + token } : {}), + ...options.headers + }; + + const response = await fetch('/api' + url, { + ...options, + headers + }); + + // 401 自动跳转登录 + if (response.status === 401) { + Storage.clearToken(); + window.location.href = '/login.php'; + throw new Error('请先登录'); + } + + const data = await response.json(); + if (!response.ok) { + throw new Error(data.message || '请求失败'); + } + return data; + }, + + get(url) { + return this.request(url, { method: 'GET' }); + }, + + post(url, data) { + return this.request(url, { + method: 'POST', + body: JSON.stringify(data) + }); + }, + + put(url, data) { + return this.request(url, { + method: 'PUT', + body: JSON.stringify(data) + }); + }, + + delete(url) { + return this.request(url, { method: 'DELETE' }); + }, + + // 文件上传(不用 JSON Content-Type) + async upload(url, formData) { + const token = Storage.getToken(); + const headers = token ? { 'Authorization': 'Bearer ' + token } : {}; + + const response = await fetch('/api' + url, { + method: 'POST', + headers, + body: formData + }); + + if (response.status === 401) { + Storage.clearToken(); + window.location.href = '/login.php'; + throw new Error('请先登录'); + } + + const data = await response.json(); + if (!response.ok) { + throw new Error(data.message || '上传失败'); + } + return data; + } +}; diff --git a/assets/js/chat.js b/assets/js/chat.js new file mode 100644 index 0000000..4a19561 --- /dev/null +++ b/assets/js/chat.js @@ -0,0 +1,308 @@ +const ChatManager = { + isStreaming: false, + messages: [], + + init() { + const input = document.getElementById('messageInput'); + if (input) { + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && e.ctrlKey) { + e.preventDefault(); + this.sendMessage(); + } + }); + // 自适应高度 + input.addEventListener('input', () => { + input.style.height = 'auto'; + input.style.height = Math.min(input.scrollHeight, 120) + 'px'; + }); + } + + const sendBtn = document.getElementById('sendBtn'); + if (sendBtn) { + sendBtn.addEventListener('click', () => this.sendMessage()); + } + + UploadManager.init(); + }, + + async loadMessages(sessionId) { + try { + const res = await api.get('/sessions/' + sessionId + '/messages'); + this.messages = res.data || []; + this.renderMessages(); + Storage.setCachedMessages(sessionId, this.messages); + } catch (err) { + // 尝试从缓存加载 + const cached = Storage.getCachedMessages(sessionId); + if (cached) { + this.messages = cached; + this.renderMessages(); + } + } + }, + + renderMessages() { + const container = document.getElementById('messagesContainer'); + if (!container) return; + + if (this.messages.length === 0) { + container.innerHTML = '

开始新的对话

输入消息开始聊天

'; + return; + } + + container.innerHTML = this.messages.map(msg => { + if (msg.role === 'user') { + return `
${this.escapeHtml(msg.content)}
`; + } else { + let html = `
`; + if (msg.thinking_content) { + html += `
💭 思考过程 ▾
`; + html += `
${MarkdownRenderer.render(msg.thinking_content)}
`; + } + html += `
${MarkdownRenderer.render(msg.content)}
`; + html += `
`; + return html; + } + }).join(''); + + container.scrollTop = container.scrollHeight; + }, + + async sendMessage() { + if (this.isStreaming) return; + + const input = document.getElementById('messageInput'); + const content = input.value.trim(); + if (!content) return; + + if (!SessionManager.currentSessionId) { + await SessionManager.createSession(); + } + + // 清空输入 + input.value = ''; + input.style.height = 'auto'; + + // 构建消息 + const userMessage = { role: 'user', content }; + if (UploadManager.getFiles().length > 0) { + userMessage.file_info = UploadManager.getFiles(); + } + this.messages.push(userMessage); + this.renderMessages(); + + // 保存用户消息到数据库 + api.post('/sessions/' + SessionManager.currentSessionId + '/messages', userMessage).catch(console.error); + + // 清除文件 + UploadManager.clearFiles(); + + // 获取当前配置 + const provider = document.getElementById('providerSelect')?.value || 'newapi'; + const model = document.getElementById('modelSelect')?.value || 'gpt-3.5-turbo'; + const thinkingMode = document.getElementById('thinkingMode')?.checked || false; + + // SSE 流式请求 + this.isStreaming = true; + this.updateSendButton(); + + try { + await this.streamChat(provider, model, thinkingMode); + } catch (err) { + this.addErrorMessage(err.message); + } finally { + this.isStreaming = false; + this.updateSendButton(); + } + }, + + async streamChat(provider, model, thinkingMode) { + const token = Storage.getToken(); + + // 构建消息历史(只取 role 和 content) + const messages = this.messages.map(m => ({ + role: m.role, + content: m.content + })); + + // 添加 AI 消息占位 + const assistantEl = this.addAssistantPlaceholder(); + let fullContent = ''; + let thinkingContent = ''; + + const response = await fetch('/api/chat/completions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ' + token + }, + body: JSON.stringify({ + provider, model, messages, stream: true, thinkingMode + }) + }); + + if (!response.ok) { + const err = await response.json(); + throw new Error(err.message || 'AI 请求失败'); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (!line.startsWith('data: ')) continue; + const data = line.slice(6).trim(); + + if (data === '[DONE]') { + continue; + } + + try { + const parsed = JSON.parse(data); + + if (parsed.type === 'error') { + throw new Error(parsed.message); + } + + if (parsed.thinking) { + thinkingContent += parsed.thinking; + this.updateThinkingContent(assistantEl, thinkingContent); + } + + if (parsed.content) { + fullContent += parsed.content; + this.updateAssistantContent(assistantEl, fullContent); + } + } catch (e) { + if (e.message && !e.message.includes('JSON')) throw e; + } + } + } + + // 流结束,保存 AI 消息 + const assistantMessage = { + role: 'assistant', + content: fullContent, + thinking_content: thinkingContent || null + }; + this.messages.push(assistantMessage); + + // 保存到数据库 + api.post('/sessions/' + SessionManager.currentSessionId + '/messages', assistantMessage).catch(console.error); + + // 缓存到 localStorage + Storage.setCachedMessages(SessionManager.currentSessionId, this.messages); + + // 移除打字机光标 + this.removeTypingCursor(assistantEl); + }, + + addAssistantPlaceholder() { + const container = document.getElementById('messagesContainer'); + const empty = container.querySelector('.empty-state'); + if (empty) empty.remove(); + + const el = document.createElement('div'); + el.className = 'message assistant'; + el.innerHTML = ''; + container.appendChild(el); + container.scrollTop = container.scrollHeight; + return el; + }, + + updateAssistantContent(el, content) { + const cursor = el.querySelector('.typing-cursor'); + const body = el.querySelector('.markdown-body'); + + if (body) { + body.innerHTML = MarkdownRenderer.render(content); + if (cursor) body.appendChild(cursor); + } else { + const thinking = el.querySelector('.thinking-content, .thinking-toggle'); + const md = document.createElement('div'); + md.className = 'markdown-body'; + md.innerHTML = MarkdownRenderer.render(content); + if (cursor) md.appendChild(cursor); + + if (thinking) { + el.appendChild(md); + } else { + el.innerHTML = ''; + el.appendChild(md); + } + } + + el.parentElement.scrollTop = el.parentElement.scrollHeight; + }, + + updateThinkingContent(el, content) { + let toggle = el.querySelector('.thinking-toggle'); + let container = el.querySelector('.thinking-content'); + + if (!toggle) { + toggle = document.createElement('div'); + toggle.className = 'thinking-toggle expanded'; + toggle.textContent = '💭 思考过程 ▾'; + toggle.onclick = function() { + container.classList.toggle('expanded'); + this.textContent = container.classList.contains('expanded') ? '💭 思考过程 ▴' : '💭 思考过程 ▾'; + }; + el.insertBefore(toggle, el.firstChild); + } + + if (!container) { + container = document.createElement('div'); + container.className = 'thinking-content expanded'; + el.insertBefore(container, toggle.nextSibling); + } + + container.innerHTML = MarkdownRenderer.render(content); + el.parentElement.scrollTop = el.parentElement.scrollHeight; + }, + + removeTypingCursor(el) { + const cursor = el.querySelector('.typing-cursor'); + if (cursor) cursor.remove(); + }, + + addErrorMessage(message) { + const container = document.getElementById('messagesContainer'); + const el = document.createElement('div'); + el.className = 'message assistant'; + el.innerHTML = `
❌ ${this.escapeHtml(message)}
`; + container.appendChild(el); + container.scrollTop = container.scrollHeight; + }, + + clearMessages() { + const container = document.getElementById('messagesContainer'); + if (container) { + container.innerHTML = '

开始新的对话

输入消息开始聊天

'; + } + this.messages = []; + }, + + updateSendButton() { + const btn = document.getElementById('sendBtn'); + if (btn) { + btn.disabled = this.isStreaming; + btn.textContent = this.isStreaming ? '回复中...' : '发送'; + } + }, + + escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } +}; diff --git a/assets/js/markdown.js b/assets/js/markdown.js new file mode 100644 index 0000000..5131775 --- /dev/null +++ b/assets/js/markdown.js @@ -0,0 +1,34 @@ +const MarkdownRenderer = { + init() { + // 配置 marked.js + marked.setOptions({ + highlight: function(code, lang) { + if (lang && hljs.getLanguage(lang)) { + return hljs.highlight(code, { language: lang }).value; + } + return hljs.highlightAuto(code).value; + }, + breaks: true, + gfm: true + }); + }, + + render(text) { + if (!text) return ''; + let html = marked.parse(text); + // 为代码块添加复制按钮 + html = html.replace(/
 {
+            btn.textContent = '已复制';
+            setTimeout(() => { btn.textContent = '复制'; }, 2000);
+        });
+    }
+};
+
+// 初始化
+MarkdownRenderer.init();
diff --git a/assets/js/session.js b/assets/js/session.js
new file mode 100644
index 0000000..e59466d
--- /dev/null
+++ b/assets/js/session.js
@@ -0,0 +1,72 @@
+const SessionManager = {
+    sessions: [],
+    currentSessionId: null,
+
+    async loadSessions() {
+        try {
+            const res = await api.get('/sessions');
+            this.sessions = res.data || [];
+            this.renderSessionList();
+        } catch (err) {
+            console.error('加载会话失败:', err);
+        }
+    },
+
+    renderSessionList() {
+        const list = document.getElementById('sessionList');
+        if (!list) return;
+        
+        list.innerHTML = this.sessions.map(s => `
+            
+ ${this.escapeHtml(s.name)} + +
+ `).join(''); + }, + + async createSession() { + try { + const res = await api.post('/sessions', {}); + this.sessions.unshift(res.data); + this.currentSessionId = res.data.id; + this.renderSessionList(); + ChatManager.clearMessages(); + return res.data; + } catch (err) { + console.error('创建会话失败:', err); + } + }, + + async switchSession(id) { + this.currentSessionId = id; + this.renderSessionList(); + await ChatManager.loadMessages(id); + }, + + async deleteSession(id) { + if (!confirm('确定要删除这个会话吗?')) return; + try { + await api.delete('/sessions/' + id); + this.sessions = this.sessions.filter(s => s.id !== id); + Storage.clearCachedMessages(id); + + if (this.currentSessionId === id) { + this.currentSessionId = null; + ChatManager.clearMessages(); + if (this.sessions.length > 0) { + await this.switchSession(this.sessions[0].id); + } + } + this.renderSessionList(); + } catch (err) { + alert('删除会话失败: ' + err.message); + } + }, + + escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } +}; diff --git a/assets/js/storage.js b/assets/js/storage.js new file mode 100644 index 0000000..a54b790 --- /dev/null +++ b/assets/js/storage.js @@ -0,0 +1,22 @@ +const Storage = { + getToken() { + return localStorage.getItem('token'); + }, + setToken(token) { + localStorage.setItem('token', token); + }, + clearToken() { + localStorage.removeItem('token'); + }, + // 消息缓存 + getCachedMessages(sessionId) { + const data = localStorage.getItem('messages_' + sessionId); + return data ? JSON.parse(data) : null; + }, + setCachedMessages(sessionId, messages) { + localStorage.setItem('messages_' + sessionId, JSON.stringify(messages)); + }, + clearCachedMessages(sessionId) { + localStorage.removeItem('messages_' + sessionId); + } +}; diff --git a/assets/js/upload.js b/assets/js/upload.js new file mode 100644 index 0000000..401ff14 --- /dev/null +++ b/assets/js/upload.js @@ -0,0 +1,67 @@ +const UploadManager = { + files: [], // 待发送的文件列表 + allowedTypes: '.jpg,.jpeg,.png,.gif,.webp,.js,.ts,.py,.java,.cpp,.c,.html,.css,.json,.xml,.txt,.md,.go,.rs,.php,.rb,.sql,.yaml,.yml,.sh,.bat', + + init() { + const btn = document.getElementById('uploadBtn'); + if (btn) { + btn.addEventListener('click', () => this.openFileSelector()); + } + }, + + openFileSelector() { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = this.allowedTypes; + input.multiple = true; + input.onchange = (e) => this.handleFiles(e.target.files); + input.click(); + }, + + async handleFiles(fileList) { + for (const file of fileList) { + const formData = new FormData(); + formData.append('file', file); + + try { + const res = await api.upload('/upload', formData); + this.files.push(res.data); + this.renderPreview(); + } catch (err) { + alert('文件上传失败: ' + err.message); + } + } + }, + + renderPreview() { + const preview = document.getElementById('filePreview'); + if (!preview) return; + + preview.innerHTML = this.files.map((f, i) => ` +
+ 📎 ${this.escapeHtml(f.name)} + × +
+ `).join(''); + }, + + removeFile(index) { + this.files.splice(index, 1); + this.renderPreview(); + }, + + getFiles() { + return this.files; + }, + + clearFiles() { + this.files = []; + this.renderPreview(); + }, + + escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } +}; diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..b723657 --- /dev/null +++ b/composer.json @@ -0,0 +1,20 @@ +{ + "name": "ai-chat/app", + "description": "AI Chat Web Application - PHP Implementation", + "type": "project", + "require": { + "php": ">=8.0", + "firebase/php-jwt": "^6.0", + "erusev/parsedown": "^1.7" + }, + "autoload": { + "psr-4": { + "App\\": "app/" + } + }, + "config": { + "platform": { + "php": "8.0" + } + } +} diff --git a/config/.gitkeep b/config/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..95da505 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,208 @@ +# API 文档 + +## 基础信息 + +- 基础 URL:`/api` +- 响应格式:JSON +- 认证方式:JWT Bearer Token(Authorization 头) + +### 统一响应格式 + +```json +{ + "success": true, + "data": {}, + "message": "操作成功" +} +``` + +## 认证 API + +### POST /auth/login +用户登录,获取 JWT 令牌。 + +**请求体:** +```json +{ + "username": "admin", + "password": "123456" +} +``` + +**成功响应:** +```json +{ + "success": true, + "data": { + "token": "eyJ0eXAiOiJKV1QiLCJhbGc...", + "user": { + "id": 1, + "username": "admin", + "role": "admin" + } + } +} +``` + +### GET /auth/me +获取当前用户信息。(需认证) + +**响应:** +```json +{ + "success": true, + "data": { + "id": 1, + "username": "admin", + "role": "admin" + } +} +``` + +## 会话 API + +### GET /sessions +获取当前用户的会话列表。(需认证) + +**响应:** +```json +{ + "success": true, + "data": [ + { + "id": 1, + "user_id": 1, + "name": "新会话", + "provider": "newapi", + "model": "gpt-3.5-turbo", + "system_prompt": "", + "personality_id": null, + "thinking_mode": 0, + "created_at": "2024-01-01 00:00:00", + "updated_at": "2024-01-01 00:00:00" + } + ] +} +``` + +### POST /sessions +创建新会话。(需认证) + +**请求体:** +```json +{ + "name": "新对话", + "provider": "newapi", + "model": "gpt-3.5-turbo", + "system_prompt": "", + "thinking_mode": false +} +``` + +### PUT /sessions/{id} +更新会话。(需认证) + +### DELETE /sessions/{id} +删除会话。(需认证) + +## 消息 API + +### GET /sessions/{id}/messages +获取指定会话的消息列表。(需认证) + +### POST /sessions/{id}/messages +保存消息到指定会话。(需认证) + +**请求体:** +```json +{ + "role": "user", + "content": "Hello!", + "file_info": null, + "thinking_content": null +} +``` + +## AI 对话 API + +### POST /chat/completions +AI 对话请求(SSE 流式响应)。(需认证) + +**请求体:** +```json +{ + "provider": "newapi", + "model": "gpt-3.5-turbo", + "messages": [ + {"role": "user", "content": "Hello!"} + ], + "stream": true, + "systemPrompt": "", + "thinkingMode": false +} +``` + +**SSE 响应格式:** +``` +data: {"content":"Hello"} + +data: {"content":"!"} + +data: {"thinking":"Let me think..."} + +data: [DONE] +``` + +## 文件上传 API + +### POST /upload +上传文件。(需认证,multipart/form-data) + +**支持格式:** jpg, jpeg, png, gif, webp, js, ts, py, java, cpp, c, html, css, json, xml, txt, md, go, rs, php, rb, sql, yaml, yml, sh, bat +**大小限制:** 10MB + +**响应:** +```json +{ + "success": true, + "data": { + "url": "/uploads/xxx.jpg", + "name": "原始文件名.jpg", + "size": 1024, + "type": "jpg" + } +} +``` + +## 配置 API + +### GET /config +获取系统配置。(需认证) + +### PUT /config +更新系统配置。(需认证 + 管理员) + +## 人格 API + +### GET /personalities +获取人格列表。(需认证) + +### POST /personalities +创建人格。(需认证 + 管理员) + +### PUT /personalities/{id} +更新人格。(需认证 + 管理员) + +### DELETE /personalities/{id} +删除人格。(需认证 + 管理员) + +## 安装 API + +### GET /install/status +检查安装状态。(无需认证) + +### POST /install/test-db +测试数据库连接。(无需认证) + +### POST /install/setup +执行安装。(无需认证) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..6483971 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,138 @@ +# 架构说明 + +## 技术架构 + +本项目采用 PHP 8.0 原生 MVC 架构,不使用任何 PHP 框架。 + +``` +┌──────────────────────────────────────────────┐ +│ Nginx │ +│ (Web 服务器 + 反向代理) │ +└──────────┬───────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────┐ +│ public/ │ +│ (Web 根目录, 入口文件) │ +│ api.php, login.php, chat.php, config.php │ +└──────────┬───────────────────────────────────┘ + │ + ┌─────┴─────┐ + │ │ + ▼ ▼ +┌─────────┐ ┌──────────────────────────────┐ +│ PHP 页面 │ │ API (api.php) │ +│ (视图) │ │ 路由分发 + 中间件处理 │ +└────┬────┘ └──────┬───────────────────────┘ + │ │ + ▼ ▼ +┌──────────────────────────────────────────────┐ +│ Controllers │ +│ AuthController, ChatController, │ +│ SessionController, MessageController, │ +│ ConfigController, InstallController, │ +│ UploadController │ +└──────────┬───────────────────────────────────┘ + │ + ┌─────┼─────────────┐ + │ │ │ + ▼ ▼ ▼ +┌───────┐ ┌───────┐ ┌──────────────────────┐ +│Models │ │Services│ │ Middleware │ +│(数据) │ │(业务) │ │ Auth/Admin │ +└───┬───┘ └───┬───┘ └──────────────────────┘ + │ │ + ▼ ▼ +┌────────┐ ┌─────────────────┐ +│ MySQL │ │ AI Providers │ +│(PDO) │ │ OpenAI/Claude/ │ +│ │ │ NewAPI │ +└────────┘ └─────────────────┘ +``` + +## 目录结构 + +``` +ai-chat/ +├── public/ # Web 根目录 +│ ├── api.php # API 统一入口(路由分发) +│ ├── login.php # 登录页面 +│ ├── chat.php # 聊天主页面 +│ ├── config.php # 配置管理页面 +│ └── install.php # 安装向导页面 +├── app/ # 应用代码(命名空间 App\) +│ ├── Config/ # 配置类 +│ │ ├── Database.php # 数据库连接(单例 PDO) +│ │ └── AppConfig.php # 应用配置读写 +│ ├── Controllers/ # 控制器 +│ │ ├── AuthController.php +│ │ ├── ChatController.php +│ │ ├── SessionController.php +│ │ ├── MessageController.php +│ │ ├── ConfigController.php +│ │ ├── InstallController.php +│ │ └── UploadController.php +│ ├── Middleware/ # 中间件 +│ │ ├── AuthMiddleware.php # JWT 认证 +│ │ └── AdminMiddleware.php # 管理员权限 +│ ├── Models/ # 数据模型 +│ │ ├── User.php +│ │ ├── Session.php +│ │ ├── Message.php +│ │ ├── Config.php +│ │ └── Personality.php +│ ├── Services/ # 服务层 +│ │ ├── AIService.php # AI 服务路由 +│ │ ├── Installer.php # 安装迁移服务 +│ │ └── Providers/ # AI 供应商 +│ │ ├── OpenAIProvider.php +│ │ ├── ClaudeProvider.php +│ │ └── NewAPIProvider.php +│ └── Views/ # 视图模板 +│ ├── login.php +│ ├── chat.php +│ ├── config.php +│ └── layout/ +│ ├── header.php +│ └── footer.php +├── assets/ # 前端静态资源 +│ ├── css/ # 样式 +│ │ ├── style.css # 全局样式 +│ │ ├── chat.css # 聊天界面样式 +│ │ └── markdown.css # Markdown 渲染样式 +│ ├── js/ # JavaScript +│ │ ├── api.js # API 调用封装 +│ │ ├── chat.js # 聊天核心功能 +│ │ ├── session.js # 会话管理 +│ │ ├── upload.js # 文件上传 +│ │ ├── markdown.js # Markdown 渲染 +│ │ └── storage.js # 本地存储 +│ └── img/ # 图片 +├── config/ # JSON 配置文件 +├── uploads/ # 用户上传文件 +├── docs/ # 文档 +└── composer.json # Composer 配置 +``` + +## 请求流程 + +### API 请求流程 +1. 客户端发送 HTTP 请求到 `/api/*` +2. Nginx 将请求转发到 `public/api.php` +3. api.php 解析路由,执行对应的中间件和控制器 +4. 控制器调用模型/服务层处理业务逻辑 +5. 返回 JSON 响应 + +### SSE 流式响应流程 +1. 客户端发送 POST 请求到 `/api/chat/completions` +2. ChatController 设置 SSE 响应头 +3. AIService 通过 cURL 连接 AI 供应商 API +4. 使用 CURLOPT_WRITEFUNCTION 回调逐块接收 AI 响应 +5. 每块数据通过 SSE 格式发送给客户端 +6. AI 响应完成后发送 `[DONE]` 信号 + +### 页面请求流程 +1. 客户端访问 PHP 页面(如 `/chat.php`) +2. Nginx 通过 PHP-FPM 执行 PHP 文件 +3. PHP 文件引入布局模板和视图 +4. 客户端通过 AJAX 加载数据 diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md new file mode 100644 index 0000000..65397b6 --- /dev/null +++ b/docs/DEPLOY.md @@ -0,0 +1,71 @@ +# 部署指南 + +## 宝塔面板部署 + +### 1. 环境准备 + +在宝塔面板"软件商店"中安装: +- Nginx 1.22+ +- PHP 8.0(在 PHP 设置中确保以下扩展已启用:pdo_mysql、curl、mbstring、json、openssl) +- MySQL 5.7 或 8.0 + +### 2. 创建网站 + +1. 网站 → 添加站点 +2. 填写域名 +3. PHP 版本选择 **PHP 8.0** +4. 数据库选择"不创建" + +### 3. 部署代码 + +```bash +cd /www/wwwroot/your-domain.com +git clone . +composer install +``` + +### 4. 设置权限 + +```bash +chmod -R 755 . +chown -R www:www . +``` + +### 5. 配置 Nginx + +1. 站点设置 → 网站目录 → 运行目录改为 `/public` +2. 站点设置 → 配置文件 → 添加 `docs/baota-nginx-snippet.conf` 中的配置 + +### 6. 运行安装向导 + +访问 `http://your-domain.com/install.php` + +### 7. SSL 配置(可选) + +站点设置 → SSL → Let's Encrypt → 申请证书 + +## 手动部署 + +### Nginx 配置 + +参考 `docs/nginx.conf` 文件,将 `root` 指向 `public/` 目录。 + +### PHP-FPM 配置 + +确保 PHP-FPM 监听 socket 配置正确(通常为 `/tmp/php-cgi-80.sock` 或 `127.0.0.1:9000`)。 + +### 目录权限 + +```bash +chown -R www-data:www-data /path/to/ai-chat +chmod 755 /path/to/ai-chat/uploads +chmod 755 /path/to/ai-chat/config +``` + +## 安全建议 + +1. 配置 SSL 证书(HTTPS) +2. 修改数据库默认端口 +3. 定期更新 PHP 和 Nginx 版本 +4. 限制 uploads 目录的执行权限 +5. 禁止访问 app/、config/、vendor/ 等非公开目录(已在 Nginx 配置中设置) diff --git a/docs/PERSONALITY.md b/docs/PERSONALITY.md new file mode 100644 index 0000000..551b665 --- /dev/null +++ b/docs/PERSONALITY.md @@ -0,0 +1,35 @@ +# 人格系统说明 + +## 概述 + +人格系统允许用户选择不同的 AI 角色,每个角色有特定的系统提示词,从而影响 AI 的回答风格和专业领域。 + +## 预设人格 + +系统安装后自动创建以下 5 个预设人格: + +| 名称 | 图标 | 说明 | +|------|------|------| +| 智能助手 | 🤖 | 全能智能助手,善于回答各种问题 | +| 代码专家 | 💻 | 专业编程专家,精通多种语言和框架 | +| 翻译官 | 🌐 | 多语言翻译专家 | +| 写作助手 | ✍️ | 专业写作助手,擅长各类文体 | +| 数学家 | 🔢 | 数学专家,善于解答数学问题 | + +预设人格不可编辑和删除。 + +## 自定义人格 + +管理员可以在配置页面(`/config.php`)创建自定义人格: + +- **名称**:人格的显示名称 +- **图标**:Emoji 图标 +- **提示词**:系统提示词(定义 AI 的角色和行为) +- **描述**:简短描述 + +## 技术实现 + +- 人格数据存储在 `personalities` 数据表中 +- 预设人格 `is_preset = 1`,自定义人格 `is_preset = 0` +- 选择人格后,其 `prompt` 字段作为 `system_prompt` 传递给 AI API +- 人格 API 路径:`/api/personalities` diff --git a/docs/baota-nginx-snippet.conf b/docs/baota-nginx-snippet.conf new file mode 100644 index 0000000..bd7d395 --- /dev/null +++ b/docs/baota-nginx-snippet.conf @@ -0,0 +1,86 @@ +# ============================================================ +# AI Chat 宝塔面板 Nginx 配置片段(PHP-FPM 版本) +# ============================================================ +# +# 【使用说明】 +# 1. 本文件为宝塔面板专用的 Nginx 配置片段,不是完整的站点配置文件。 +# 2. 使用步骤: +# a. 在宝塔面板中创建网站,PHP 版本选择 PHP 8.0 +# b. 将网站根目录指向 /path/to/ai-chat/public +# c. 登录宝塔面板 → 网站 → 找到你的站点 → 点击"设置" +# d. 在左侧菜单选择"配置文件" +# e. 将下方"配置内容开始"到"配置内容结束"之间的内容, +# 复制并粘贴到 server { } 块内(注意不要重复嵌套 server 块) +# f. 保存即可生效 +# 3. 前置准备: +# - 在宝塔面板中安装 PHP 8.0 并启用 +# - 安装 MySQL 5.7+ 或 8.0 +# - 安装 Composer +# - 运行 composer install 安装 PHP 依赖 +# +# ============================================================ + +# ↓↓↓ 配置内容开始 ↓↓↓ + +# === AI Chat 网站配置(PHP-FPM) === + +# 1. 默认路由 +location / { + try_files $uri $uri/ /login.php; +} + +# 2. API 路由 - 转发到 api.php +location /api/ { + try_files $uri /api.php$is_args$args; + fastcgi_pass unix:/tmp/php-cgi-80.sock; + fastcgi_index index.php; + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME $document_root/api.php; + fastcgi_param PATH_INFO $fastcgi_path_info; + fastcgi_param REQUEST_URI $request_uri; +} + +# 3. SSE 流式响应特殊配置(AI对话需要) +# 针对 Chat API 的 SSE(Server-Sent Events)流式响应, +# 需要禁用所有缓冲以确保实时输出 +location /api/chat/completions { + fastcgi_pass unix:/tmp/php-cgi-80.sock; + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME $document_root/api.php; + fastcgi_param REQUEST_URI $request_uri; + # SSE 关键配置 + fastcgi_buffering off; + proxy_buffering off; + add_header X-Accel-Buffering no; + # 超时设置(5分钟) + fastcgi_read_timeout 300s; +} + +# 4. 静态资源缓存 +location /assets/ { + expires 30d; + add_header Cache-Control "public, immutable"; +} + +# 5. 上传文件访问 +location /uploads/ { + expires 7d; + add_header Cache-Control "public"; +} + +# 6. 禁止访问非公开目录(安全) +location ~ /(app|config|vendor|tests|scripts|)/ { + deny all; + return 404; +} + +# 7. 禁止访问隐藏文件 +location ~ /\. { + deny all; + return 404; +} + +# 8. 上传文件大小限制 +client_max_body_size 10m; + +# ↑↑↑ 配置内容结束 ↑↑↑ diff --git a/docs/nginx.conf b/docs/nginx.conf new file mode 100644 index 0000000..7faf87b --- /dev/null +++ b/docs/nginx.conf @@ -0,0 +1,110 @@ +# AI Chat 应用 Nginx 配置(PHP-FPM 版本) +# 将此文件复制到 /etc/nginx/sites-available/ 并创建软链接到 sites-enabled/ +# +# 注意:本文件为完整的 Nginx 站点配置,适用于直接部署到 Nginx + PHP-FPM 的场景。 +# 如果你使用宝塔面板管理服务器,请参考 baota-nginx-snippet.conf 文件中的配置片段。 +# +# 前置条件: +# - PHP 8.0+ 已安装并启用 PHP-FPM +# - MySQL 5.7+ 已安装 +# - Composer 已安装 +# - 项目已部署到 /path/to/ai-chat/ 目录 + +server { + listen 80; + server_name your-domain.com; + + # 网站根目录指向 public/ + root /path/to/ai-chat/public; + index index.php login.php install.php; + + # 安全 Headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # Gzip 压缩 + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_min_length 256; + gzip_types + text/plain + text/css + text/javascript + application/javascript + application/json + application/xml + image/svg+xml; + + # 默认路由 - PHP 文件 + location / { + try_files $uri $uri/ /login.php; + } + + # API 路由 - 所有 /api/ 请求转发到 api.php + location /api/ { + try_files $uri /api.php$is_args$args; + fastcgi_pass unix:/tmp/php-cgi-80.sock; + fastcgi_index index.php; + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME $document_root/api.php; + fastcgi_param PATH_INFO $fastcgi_path_info; + fastcgi_param REQUEST_URI $request_uri; + } + + # SSE 流式响应专用配置(AI对话 API) + location /api/chat/completions { + fastcgi_pass unix:/tmp/php-cgi-80.sock; + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME $document_root/api.php; + fastcgi_param REQUEST_URI $request_uri; + # SSE 关键配置:禁用所有缓冲 + fastcgi_buffering off; + fastcgi_cache off; + proxy_buffering off; + # 禁用 Nginx 缓冲 + add_header X-Accel-Buffering no; + # SSE 超时设置(5分钟) + fastcgi_read_timeout 300s; + } + + # 静态资源直接服务 + location /assets/ { + alias /path/to/ai-chat/assets/; + expires 30d; + add_header Cache-Control "public, immutable"; + } + + # 上传文件直接服务 + location /uploads/ { + alias /path/to/ai-chat/uploads/; + expires 7d; + add_header Cache-Control "public"; + } + + # PHP 文件处理 + location ~ \.php$ { + fastcgi_pass unix:/tmp/php-cgi-80.sock; + fastcgi_index index.php; + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + } + + # 禁止访问非公开目录 + location ~ /(app|config|vendor|tests|scripts|\.cospec)/ { + deny all; + return 404; + } + + # 禁止访问隐藏文件 + location ~ /\. { + deny all; + return 404; + } + + # 上传文件大小限制 + client_max_body_size 10m; +} diff --git a/public/.gitkeep b/public/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/public/api.php b/public/api.php new file mode 100644 index 0000000..6741f78 --- /dev/null +++ b/public/api.php @@ -0,0 +1,138 @@ + false, 'message' => '接口不存在']); + } +} catch (\Throwable $e) { + http_response_code(500); + echo json_encode(['success' => false, 'message' => '服务器内部错误']); +} diff --git a/public/chat.php b/public/chat.php new file mode 100644 index 0000000..6c75faf --- /dev/null +++ b/public/chat.php @@ -0,0 +1,5 @@ + + + + + + + AI Chat - 安装向导 + + + + +
+

🤖 AI Chat 安装向导

+ + +
    +
  • 1. 环境检查
  • +
  • 2. 数据库配置
  • +
  • 3. 应用配置
  • +
  • 4. 管理员账户
  • +
  • 5. AI供应商
  • +
+ + +
+

环境检查

+
+ +
+
+ +
+
+ + +
+

数据库配置

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + +
+
+ + +
+

应用配置

+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+

创建管理员账户

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+

AI 供应商配置

+

至少配置一个 AI 供应商

+
+ +
+ +
+
+ + +
+
+
+ + + + diff --git a/public/login.php b/public/login.php new file mode 100644 index 0000000..dd01045 --- /dev/null +++ b/public/login.php @@ -0,0 +1,4 @@ +