Compare commits
102 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e7a3ecd443 | |||
| d42b250c17 | |||
| 5c3a148a03 | |||
| 3bedc6789a | |||
| 87b1742495 | |||
| 8214f31b5b | |||
| b374179094 | |||
| 30502cf2cf | |||
| c9be0d07bc | |||
| 6cdafddd8e | |||
| 5dd77abb0f | |||
| c554c07bfc | |||
| 8739e9ad26 | |||
| 3839a60459 | |||
| 807242dd78 | |||
| ea4e43bf97 | |||
| 90df082a92 | |||
| 2de699e2aa | |||
| 1f19bb0c57 | |||
| 69a79ac86d | |||
| 9f470cb97d | |||
| da02a729a3 | |||
| aa9b25c829 | |||
| 17978cc71f | |||
| 8802604568 | |||
| f585164cc1 | |||
| c046e6ff6a | |||
| 6749811e49 | |||
| 0f9001beef | |||
| 3a13753d36 | |||
| fa458dcb0d | |||
| 9bc7ac7560 | |||
| 20319f48d5 | |||
| a930648c95 | |||
| 2d2c87d505 | |||
| 159d22510b | |||
| cbc7b13dbc | |||
| cc5ea69eda | |||
| ecb74cebff | |||
| dd00ddaa8b | |||
| 5e1aba3c60 | |||
| 1b5a3adf3e | |||
| 64b31bbf4f | |||
| df156ba840 | |||
| 4fc8ca944b | |||
| 006f3c413f | |||
| a1654db365 | |||
| 2cc4d8b907 | |||
| 5f7c95e861 | |||
| a794c33545 | |||
| 6d49061539 | |||
| a5e71da88c | |||
| d4267e1a8d | |||
| bb426cdb45 | |||
| bc999b18f1 | |||
| 529672a93a | |||
| 00fd75a0a6 | |||
| eab18889ff | |||
| d971afb934 | |||
| 1a394ddd2b | |||
| 12ab1df7f5 | |||
| c1dc3f647b | |||
| 4901d3882f | |||
| 3de418b7e8 | |||
| 039dc41c0b | |||
| 39eee19a3e | |||
| 1fa548da3c | |||
| 7543fcae72 | |||
| b9e4bb164d | |||
| 49d6906f15 | |||
| d228d119f5 | |||
| c72fc5f38f | |||
| 0ccd02b0af | |||
| bddb5498e9 | |||
| 598e03e2eb | |||
| 8b34725872 | |||
| 4d64da743a | |||
| cc2241cac8 | |||
| e7ddaee872 | |||
| 84f217ef55 | |||
| 58dc93f061 | |||
| f0e4c89ac0 | |||
| d676d34cd8 | |||
| ac113d6fb0 | |||
| fd2c688321 | |||
| ae04e4ddbe | |||
| ad382602f2 | |||
| aefaea7b90 | |||
| 05fc252db0 | |||
| b4456a9c5b | |||
| 46008c14c5 | |||
| 0ebd1d7ac6 | |||
| de09eacabc | |||
| 14af13c050 | |||
| 47c5b99617 | |||
| e975fe409b | |||
| 0270e17a76 | |||
| fc5335a5c6 | |||
| 2aa6e9240f | |||
| 535fca933a | |||
| b7b2601180 | |||
| a1b60ed936 |
127
.gitea/workflows/build-binaries.yml
Normal file
127
.gitea/workflows/build-binaries.yml
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
name: test-build-release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
env:
|
||||||
|
GIT_SSH_COMMAND: ssh -o StrictHostKeyChecking=no
|
||||||
|
run: |
|
||||||
|
git clone --depth 1 ssh://git@jianfgit.xyz:220/jianf/TrulyMEM-TrueHumanMEM.git ./
|
||||||
|
|
||||||
|
- name: Verify workspace and Python
|
||||||
|
run: |
|
||||||
|
pwd
|
||||||
|
ls -la
|
||||||
|
python3 --version
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: |
|
||||||
|
python3 -m venv .venv-ci
|
||||||
|
. .venv-ci/bin/activate
|
||||||
|
pip install --upgrade pip
|
||||||
|
pip install -r requirements.txt pytest
|
||||||
|
pytest -q
|
||||||
|
|
||||||
|
build-linux:
|
||||||
|
needs: test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
env:
|
||||||
|
GIT_SSH_COMMAND: ssh -o StrictHostKeyChecking=no
|
||||||
|
run: |
|
||||||
|
git clone --depth 1 ssh://git@jianfgit.xyz:220/jianf/TrulyMEM-TrueHumanMEM.git ./
|
||||||
|
|
||||||
|
- name: Verify build environment
|
||||||
|
run: |
|
||||||
|
pwd
|
||||||
|
python3 --version
|
||||||
|
tar --version | head -1
|
||||||
|
|
||||||
|
- name: Build Linux binary
|
||||||
|
run: |
|
||||||
|
bash build/build_linux.sh
|
||||||
|
|
||||||
|
- name: Pack release bundle
|
||||||
|
run: |
|
||||||
|
mkdir -p dist/release
|
||||||
|
cp dist/TrulyMEM dist/release/
|
||||||
|
cp dist/TrulyMEM.desktop dist/release/
|
||||||
|
[ -f pic/image.png ] && cp pic/image.png dist/release/ || true
|
||||||
|
tar -C dist/release -czf dist/TrulyMEM-linux-amd64.tar.gz .
|
||||||
|
ls -lh dist/TrulyMEM-linux-amd64.tar.gz
|
||||||
|
|
||||||
|
- name: Build AppImage if preinstalled
|
||||||
|
run: |
|
||||||
|
if command -v appimagetool >/dev/null 2>&1; then
|
||||||
|
bash build/build_appimage.sh
|
||||||
|
else
|
||||||
|
echo "appimagetool not found on runner, skip AppImage build"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Show build outputs
|
||||||
|
run: |
|
||||||
|
ls -lah dist/
|
||||||
|
|
||||||
|
- name: Create Gitea release
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ vars.GITEA_RELEASE_TOKEN }}
|
||||||
|
TAG_NAME: ${{ github.ref_name }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
SERVER_URL: ${{ github.server_url }}
|
||||||
|
run: |
|
||||||
|
test -n "$GITEA_TOKEN"
|
||||||
|
|
||||||
|
# Build release payload via env var — avoids heredoc in YAML
|
||||||
|
export TAG_NAME
|
||||||
|
release_payload=$(python3 -c 'import os,json; t=os.environ["TAG_NAME"]; print(json.dumps({"tag_name":t,"name":t,"draft":false,"prerelease":false}))')
|
||||||
|
|
||||||
|
release_response=$(curl -fsSL \
|
||||||
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-X POST \
|
||||||
|
"$SERVER_URL/api/v1/repos/$REPO/releases" \
|
||||||
|
-d "$release_payload" \
|
||||||
|
|| true)
|
||||||
|
|
||||||
|
# Extract release id or fallback to existing
|
||||||
|
export RELEASE_RESPONSE
|
||||||
|
release_id=$(python3 -c '
|
||||||
|
import os, json
|
||||||
|
raw = os.environ.get("RELEASE_RESPONSE", "").strip()
|
||||||
|
if raw:
|
||||||
|
try:
|
||||||
|
print(json.loads(raw)["id"])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
')
|
||||||
|
|
||||||
|
if [ -z "$release_id" ]; then
|
||||||
|
release_id=$(curl -fsSL \
|
||||||
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
"$SERVER_URL/api/v1/repos/$REPO/releases/tags/$TAG_NAME" \
|
||||||
|
| python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])' \
|
||||||
|
2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
|
||||||
|
for file in dist/TrulyMEM dist/TrulyMEM.desktop dist/TrulyMEM-linux-amd64.tar.gz dist/TrulyMEM.AppImage; do
|
||||||
|
if [ -f "$file" ]; then
|
||||||
|
name=$(basename "$file")
|
||||||
|
curl -fsSL \
|
||||||
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary @"$file" \
|
||||||
|
"$SERVER_URL/api/v1/repos/$REPO/releases/$release_id/assets?name=$name"
|
||||||
|
fi
|
||||||
|
done
|
||||||
93
.gitignore
vendored
93
.gitignore
vendored
@ -1,66 +1,41 @@
|
|||||||
# Python
|
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.pyc
|
||||||
*$py.class
|
*.pyo
|
||||||
*.so
|
.DS_Store
|
||||||
.Python
|
harmonyos/.hvigor/
|
||||||
develop-eggs/
|
harmonyos/build/
|
||||||
dist/
|
harmonyos/entry/build/
|
||||||
downloads/
|
harmonyos.bak/
|
||||||
eggs/
|
node_modules/
|
||||||
.lib/
|
ts/node_modules/
|
||||||
lib64/
|
ts/dist/
|
||||||
parts/
|
ts/build/
|
||||||
sdist/
|
*.hap
|
||||||
var/
|
*.hsp
|
||||||
wheels/
|
.env
|
||||||
*.egg-info/
|
|
||||||
.installed.cfg
|
|
||||||
*.egg
|
|
||||||
|
|
||||||
# Virtual Environment
|
|
||||||
venv/
|
venv/
|
||||||
test_venv/
|
.venv/
|
||||||
ENV/
|
dist/
|
||||||
env/
|
build/trulymem/
|
||||||
|
build/trulymem/*
|
||||||
# IDE
|
|
||||||
.vscode/
|
|
||||||
.idea/
|
|
||||||
.arts/
|
|
||||||
.codeartsdoer/
|
|
||||||
*.swp
|
|
||||||
*.swo
|
|
||||||
*~
|
|
||||||
|
|
||||||
# Database
|
|
||||||
*.db
|
*.db
|
||||||
*.sqlite
|
task_archive/
|
||||||
*.sqlite3
|
ts/task_archive/
|
||||||
|
core/web_config.json
|
||||||
|
|
||||||
# Logs
|
# Build artifacts
|
||||||
|
build/trulymem/
|
||||||
|
dist/
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# HarmonyOS / Hvigor build cache
|
||||||
|
.hvigor/
|
||||||
|
entry/build/
|
||||||
|
|
||||||
|
# Log files
|
||||||
*.log
|
*.log
|
||||||
logs/
|
full_output.log
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
__pycache__/
|
||||||
Thumbs.db
|
*.pyc
|
||||||
|
|
||||||
# Sensitive
|
|
||||||
.env
|
|
||||||
*.key
|
|
||||||
*.pem
|
|
||||||
config.json
|
|
||||||
|
|
||||||
# Build
|
|
||||||
dist/
|
|
||||||
|
|
||||||
# Temporary
|
|
||||||
*.tmp
|
|
||||||
*.bak
|
|
||||||
|
|
||||||
# AI Generated
|
|
||||||
jimeng*.png
|
|
||||||
|
|
||||||
# Test Cache
|
|
||||||
.pytest_cache/
|
|
||||||
|
|||||||
90
README.md
90
README.md
@ -5,100 +5,62 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
> **📜 开源协议**: [GNU General Public License v3.0 (GPLv3)](https://www.gnu.org/licenses/gpl-3.0)
|
> **📜 开源协议**: [GNU General Public License v3.0 (GPLv3)](https://www.gnu.org/licenses/gpl-3.0)
|
||||||
> 本项目自由开源,可自由使用、修改和分发,但修改后的作品必须以相同许可证发布。
|
|
||||||
|
|
||||||
> **English**: [Switch to English version](./README_EN.md)
|
> **English**: [README_EN.md](./README_EN.md)
|
||||||
|
|
||||||
**让 AI 拥有自知、可塑、有分寸感的长期记忆**
|
**让 AI 拥有自知、可塑、有分寸感的长期记忆** — *The More Human Choice.*
|
||||||
|
|
||||||
*The More Human Choice.*
|
|
||||||
|
|
||||||
[](https://www.gnu.org/licenses/gpl-3.0)
|
[](https://www.gnu.org/licenses/gpl-3.0)
|
||||||
[](https://www.python.org/downloads/)
|
[](https://www.python.org/downloads/)
|
||||||
[]()
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 故事的开头
|
## 一句话
|
||||||
|
|
||||||
行业普遍认为,LLM 海量参数让其涌现了智能。但这个智能是「死的」——它不会真的记住,也不理解「记住」的概念。它输出的一切,都是当前输入的全部文本经历无数次前向传播计算出的概率最优解。LLM 不会因为某次对话意识到错误而去修正权重,也无法因此针对模型进行一次反向传播。它的意识是被冻结的,展现出的智能只是冻结的意识的回响。
|
TrulyMEM 将记忆权交还给 LLM。通过图数据库(三元组)替代传统 messages 数组,让 LLM 自主决定记什么、忘什么。
|
||||||
|
|
||||||
现在的所谓记忆系统,只是将记忆外化,让「系统」去替 LLM 记住。或者就是粗暴地将一切上下文文本丢给 LLM。这就是对模型输入的浪费。
|
|
||||||
|
|
||||||
**TrulyMEM 想,既然 LLM 无法实时纠正模型权重,为什么不把记忆权交还给 LLM 呢?**
|
|
||||||
|
|
||||||
我们提供一系列机制,让 LLM 决定它要记住什么、遗忘,什么是重点、什么是糟粕。LLM 推理的过程,就是思考的过程,也是回忆的过程。完全摒弃传统的 messages 数组上下文,将全部记忆以**三元组(图)**的形式保存在图数据库中。在 LLM 思考时,可以按照图数据库的链接自主跳转、联想相关关系,让 LLM 自然地实现联想与回忆。
|
|
||||||
|
|
||||||
赋予 LLM 真正的记忆。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
## 项目截图
|
||||||
|

|
||||||
|

|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
### 方式一:打包后的可执行文件
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Windows: TrulyMEM.exe
|
python trulymem_entry.py # 从源码
|
||||||
# Linux/macOS: TrulyMEM
|
./dist/TrulyMEM # 打包后
|
||||||
chmod +x TrulyMEM
|
|
||||||
./TrulyMEM
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 方式二:从源码运行
|
首次启动 → TUI 登录页面 → 创建/登录账号 → 按 **F2** 配置 API Key → 开始聊天
|
||||||
|
|
||||||
```bash
|
📖 **详细启动文档**: [docs/zh/quick_start.md](docs/zh/quick_start.md)
|
||||||
git clone <repo-url>
|
|
||||||
cd TrulyMEM-TrueHumanMEM
|
|
||||||
|
|
||||||
pip install -r requirements.txt
|
---
|
||||||
|
|
||||||
python trulymem_entry.py
|
## 主要特性
|
||||||
```
|
|
||||||
|
|
||||||
### 配置
|
| 特性 | 说明 |
|
||||||
|
|------|------|
|
||||||
1. 按 **F2** 展开侧边栏
|
| 🧠 **图记忆** | 三元组存储,LLM 自主推理跳转 |
|
||||||
2. 输入 **API Key**(支持 DeepSeek、OpenAI 等兼容 API)
|
| 🔐 **多用户** | 用户隔离 + Admin/User 角色权限 |
|
||||||
3. 按 **Enter** 保存配置
|
| 🌐 **Web 可视化** | 内嵌 Flask 服务(线程模式),实时浏览知识图谱 + 聊天上传文件(支持 PDF/Word/文本) |
|
||||||
4. 开始对话!
|
| 🎮 **TUI 界面** | Textual 终端界面,F2 配置面板 |
|
||||||
|
| 📦 **单文件打包** | PyInstaller 打包,Web 服务内嵌于主二进制 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 文档索引
|
## 文档索引
|
||||||
|
|
||||||
详细技术文档请参阅 [docs/zh/](docs/zh/) 目录:
|
|
||||||
|
|
||||||
| 文档 | 内容 |
|
| 文档 | 内容 |
|
||||||
|------|------|
|
|------|------|
|
||||||
|
| [docs/zh/quick_start.md](docs/zh/quick_start.md) | 🔥 **完整启动指南**(含 Web、多用户、打包) |
|
||||||
| [docs/zh/architecture.md](docs/zh/architecture.md) | 系统架构和技术设计 |
|
| [docs/zh/architecture.md](docs/zh/architecture.md) | 系统架构和技术设计 |
|
||||||
| [docs/zh/quick_start.md](docs/zh/quick_start.md) | 完整启动指南与配置说明 |
|
|
||||||
| [docs/zh/memory.md](docs/zh/memory.md) | 内部记忆工作机制 |
|
| [docs/zh/memory.md](docs/zh/memory.md) | 内部记忆工作机制 |
|
||||||
| [docs/zh/persona.md](docs/zh/persona.md) | 人设图机制 |
|
| [docs/zh/persona.md](docs/zh/persona.md) | 人设图机制 |
|
||||||
| [docs/zh/working_memory.md](docs/zh/working_memory.md) | 连续性任务处理机制 |
|
| [docs/zh/api.md](docs/zh/api.md) | 后端 API 接口 |
|
||||||
| [docs/zh/api.md](docs/zh/api.md) | 后端 API 接口(供扩展开发) |
|
|
||||||
| [docs/zh/prompts.md](docs/zh/prompts.md) | 提示词管理模块 |
|
| [docs/zh/prompts.md](docs/zh/prompts.md) | 提示词管理模块 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 贡献
|
|
||||||
|
|
||||||
欢迎提交 Issue 和 Pull Request!
|
|
||||||
|
|
||||||
1. Fork 本仓库
|
|
||||||
2. 创建特性分支 (`git checkout -b feature/AmazingFeature`)
|
|
||||||
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
|
|
||||||
4. 推送到分支 (`git push origin feature/AmazingFeature`)
|
|
||||||
5. 创建 Pull Request
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 许可证
|
|
||||||
|
|
||||||
本项目采用 **GNU General Public License v3.0 (GPLv3)** 许可证开源。
|
|
||||||
详见 [LICENSE](LICENSE) 文件。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 特别鸣谢
|
## 特别鸣谢
|
||||||
|
|
||||||
- [Prof. Meiting Wang](https://www.xxmu.edu.cn/yxgcxy/info/1260/4252.htm) — 学术指导
|
- [Prof. Meiting Wang](https://www.xxmu.edu.cn/yxgcxy/info/1260/4252.htm) — 学术指导
|
||||||
@ -107,3 +69,11 @@ python trulymem_entry.py
|
|||||||
- 崔莉萍老师 — 理论指导
|
- 崔莉萍老师 — 理论指导
|
||||||
- Annie — 专业指导
|
- Annie — 专业指导
|
||||||
- 王梓沣、马悦华、隆梦婷 — 神经科学理论支持
|
- 王梓沣、马悦华、隆梦婷 — 神经科学理论支持
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
|
GNU General Public License v3.0 (GPLv3)
|
||||||
|
# CI trigger Wednesday, May 13, 2026 AM06:17:55 UTC
|
||||||
|
# runner cache cleared Wednesday, May 13, 2026 AM06:23:27 UTC
|
||||||
|
|||||||
100
README_EN.md
100
README_EN.md
@ -5,97 +5,61 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
> **📜 License**: [GNU General Public License v3.0 (GPLv3)](https://www.gnu.org/licenses/gpl-3.0)
|
> **📜 License**: [GNU General Public License v3.0 (GPLv3)](https://www.gnu.org/licenses/gpl-3.0)
|
||||||
> This project is free and open source. You are free to use, modify, and distribute, but modified works must be distributed under the same license.
|
|
||||||
|
|
||||||
> **中文**: [切换到中文版](./README.md)
|
> **中文**: [README.md](./README.md)
|
||||||
|
|
||||||
**Give AI self-awareness, plasticity, and a sense of proportion in long-term memory**
|
**Give AI self-awareness, plasticity, and a sense of proportion in long-term memory** — *The More Human Choice.*
|
||||||
|
|
||||||
*The More Human Choice.*
|
|
||||||
|
|
||||||
[](https://www.gnu.org/licenses/gpl-3.0)
|
[](https://www.gnu.org/licenses/gpl-3.0)
|
||||||
[](https://www.python.org/downloads/)
|
[](https://www.python.org/downloads/)
|
||||||
[]()
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## The Story
|
## In a Nutshell
|
||||||
|
|
||||||
Industry believes that LLMs' massive parameters give them emergent intelligence. But this intelligence is "dead" — it cannot truly remember, nor understand the concept of "remembering". Everything it outputs is the probabilistic optimal solution calculated through countless forward passes on the current input text. The LLM cannot correct its weights based on errors in a conversation, nor perform a backward pass. Its consciousness is frozen — what appears as intelligence is merely the echo of this frozen consciousness.
|
TrulyMEM gives memory authority back to the LLM. Using graph database (triplets) instead of the traditional messages array, the LLM autonomously decides what to remember and what to forget.
|
||||||
|
|
||||||
Current "memory systems" merely externalize memory, letting the "system" remember for the LLM. Or they dump all context text to the LLM. This is a waste of the model's limited input context.
|
|
||||||
|
|
||||||
**TrulyMEM asks: since the LLM cannot correct model weights in real-time, why not give the memory authority back to the LLM?**
|
|
||||||
|
|
||||||
We provide a series of mechanisms for the LLM to decide what to remember, what to forget, what's important, what's trivial. The LLM's reasoning process is also its thinking and recalling process. Abandoning the traditional messages array context, all memories are stored as **triplets (graph)** in the graph database. When the LLM thinks, it can autonomously jump through graph links to associate related relationships, enabling natural association and recall.
|
|
||||||
|
|
||||||
Give the LLM true memory.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Screen Shot
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
### Method 1: Run Packaged Executable
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Windows: TrulyMEM.exe
|
python trulymem_entry.py # from source
|
||||||
# Linux/macOS: TrulyMEM
|
./dist/TrulyMEM # packaged binary
|
||||||
chmod +x TrulyMEM
|
|
||||||
./TrulyMEM
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Method 2: Run from Source
|
First run → TUI login screen → create/sign in → press **F2** for API Key → start chatting
|
||||||
|
|
||||||
```bash
|
📖 **Full guide**: [docs/en/quick_start.md](docs/en/quick_start.md)
|
||||||
git clone <repo-url>
|
|
||||||
cd TrulyMEM-TrueHumanMEM
|
|
||||||
|
|
||||||
pip install -r requirements.txt
|
|
||||||
|
|
||||||
python trulymem_entry.py
|
|
||||||
```
|
|
||||||
|
|
||||||
### Configuration
|
|
||||||
|
|
||||||
1. Press **F2** to expand sidebar
|
|
||||||
2. Enter **API Key** (supports DeepSeek, OpenAI, etc.)
|
|
||||||
3. Press **Enter** to save
|
|
||||||
4. Start chatting!
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Documentation Index
|
## Features
|
||||||
|
|
||||||
Detailed technical documentation in the [docs/en/](docs/en/) directory:
|
| Feature | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| 🧠 **Graph Memory** | Triplet storage, LLM autonomous navigation |
|
||||||
|
| 🔐 **Multi-User** | Isolated profiles + Admin/User roles |
|
||||||
|
| 🌐 **Web UI** | Embedded Flask server (thread mode), graph browsing + file upload in chat (PDF/Word/text) |
|
||||||
|
| 🎮 **TUI** | Textual-based terminal UI with F2 config panel |
|
||||||
|
| 📦 **Single Binary** | PyInstaller build, Web server embedded |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
| Document | Content |
|
| Document | Content |
|
||||||
|----------|---------|
|
|----------|---------|
|
||||||
| [docs/en/architecture.md](docs/en/architecture.md) | System architecture and technical design |
|
| [docs/en/quick_start.md](docs/en/quick_start.md) | 🔥 **Full setup guide** (Web, multi-user, building) |
|
||||||
| [docs/en/quick_start.md](docs/en/quick_start.md) | Complete startup guide and configuration |
|
| [docs/en/architecture.md](docs/en/architecture.md) | System architecture and design |
|
||||||
| [docs/en/memory.md](docs/en/memory.md) | Internal memory working mechanism |
|
| [docs/en/memory.md](docs/en/memory.md) | Memory working mechanism |
|
||||||
| [docs/en/persona.md](docs/en/persona.md) | Persona Graph mechanism |
|
| [docs/en/persona.md](docs/en/persona.md) | Persona Graph mechanism |
|
||||||
| [docs/en/working_memory.md](docs/en/working_memory.md) | Continuous task handling mechanism |
|
| [docs/en/api.md](docs/en/api.md) | Backend API |
|
||||||
| [docs/en/api.md](docs/en/api.md) | BackendServer API (for extension development) |
|
| [docs/en/prompts.md](docs/en/prompts.md) | Prompt management |
|
||||||
| [docs/en/prompts.md](docs/en/prompts.md) | Prompt management module |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
Welcome to submit Issues and Pull Requests!
|
|
||||||
|
|
||||||
1. Fork this repository
|
|
||||||
2. Create feature branch (`git checkout -b feature/AmazingFeature`)
|
|
||||||
3. Commit changes (`git commit -m 'Add some AmazingFeature'`)
|
|
||||||
4. Push to branch (`git push origin feature/AmazingFeature`)
|
|
||||||
5. Create Pull Request
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
This project is licensed under the **GNU General Public License v3.0 (GPLv3)**.
|
|
||||||
See [LICENSE](LICENSE) file for details.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -107,3 +71,9 @@ See [LICENSE](LICENSE) file for details.
|
|||||||
- 崔莉萍老师 — Theoretical guidance
|
- 崔莉萍老师 — Theoretical guidance
|
||||||
- Annie — Professional guidance
|
- Annie — Professional guidance
|
||||||
- 王梓沣、马悦华、隆梦婷 — Neuroscience theory support
|
- 王梓沣、马悦华、隆梦婷 — Neuroscience theory support
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
GNU General Public License v3.0 (GPLv3)
|
||||||
|
|||||||
@ -1,19 +1,80 @@
|
|||||||
# -*- mode: python ; coding: utf-8 -*-
|
# -*- mode: python ; coding: utf-8 -*-
|
||||||
from PyInstaller.utils.hooks import collect_all
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
datas = [('ui/styles', 'ui/styles'), ('core/prompts/templates', 'core/prompts/templates')]
|
block_cipher = None
|
||||||
binaries = []
|
|
||||||
hiddenimports = ['textual', 'textual.app', 'textual.widgets', 'textual.css', 'openai', 'openai._client', 'neo4j', 'sqlite3', 'core', 'core.embedded_db', 'core.graph_client', 'core.tool_executor', 'core.tool_limiter', 'core.tools', 'core.tools.memory_tools', 'core.prompts', 'core.prompts.prompt_manager', 'ui', 'ui.app', 'ui.models', 'ui.models.message', 'ui.models.config', 'ui.models.log_entry', 'ui.widgets', 'ui.handlers', 'ui.services', 'ui.services.config_manager', 'ui.services.config_service']
|
|
||||||
tmp_ret = collect_all('textual')
|
|
||||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
|
||||||
|
|
||||||
|
project_root = os.path.dirname(os.path.abspath(SPEC))
|
||||||
|
sys.path.insert(0, project_root)
|
||||||
|
|
||||||
|
datas = []
|
||||||
|
# UI 样式
|
||||||
|
ui_styles_dir = os.path.join(project_root, 'ui', 'styles')
|
||||||
|
if os.path.exists(ui_styles_dir):
|
||||||
|
for root, dirs, files in os.walk(ui_styles_dir):
|
||||||
|
for f in files:
|
||||||
|
datas.append((os.path.join(root, f), 'ui/styles'))
|
||||||
|
|
||||||
|
# Prompt 模板
|
||||||
|
prompt_tmpl_dir = os.path.join(project_root, 'core', 'prompts', 'templates')
|
||||||
|
if os.path.exists(prompt_tmpl_dir):
|
||||||
|
for root, dirs, files in os.walk(prompt_tmpl_dir):
|
||||||
|
for f in files:
|
||||||
|
datas.append((os.path.join(root, f), 'core/prompts/templates'))
|
||||||
|
|
||||||
|
# Web 静态文件
|
||||||
|
static_dir = os.path.join(project_root, 'ui', 'static')
|
||||||
|
if os.path.exists(static_dir):
|
||||||
|
for root, dirs, files in os.walk(static_dir):
|
||||||
|
for f in files:
|
||||||
|
rel_dir = os.path.relpath(root, project_root)
|
||||||
|
datas.append((os.path.join(root, f), rel_dir))
|
||||||
|
|
||||||
|
# 兼容旧的 static 目录(如果存在)
|
||||||
|
static_dir_old = os.path.join(project_root, 'static')
|
||||||
|
if os.path.exists(static_dir_old):
|
||||||
|
for root, dirs, files in os.walk(static_dir_old):
|
||||||
|
for f in files:
|
||||||
|
datas.append((os.path.join(root, f), 'static'))
|
||||||
|
|
||||||
|
# Web 模板(Flask template_folder 指向 ui/templates/)
|
||||||
|
templates_dir = os.path.join(project_root, 'ui', 'templates')
|
||||||
|
if os.path.exists(templates_dir):
|
||||||
|
for root, dirs, files in os.walk(templates_dir):
|
||||||
|
for f in files:
|
||||||
|
rel_dir = os.path.relpath(root, project_root)
|
||||||
|
datas.append((os.path.join(root, f), rel_dir))
|
||||||
|
|
||||||
|
# Web API 脚本(以便子进程模式回退使用)
|
||||||
|
web_api_src = os.path.join(project_root, 'core', 'web_api.py')
|
||||||
|
if os.path.exists(web_api_src):
|
||||||
|
datas.append((web_api_src, 'core'))
|
||||||
|
|
||||||
|
# ——— TUI 主二进制 ———
|
||||||
a = Analysis(
|
a = Analysis(
|
||||||
['trulymem_entry.py'],
|
[os.path.join(project_root, 'trulymem_entry.py')],
|
||||||
pathex=[],
|
pathex=[],
|
||||||
binaries=binaries,
|
binaries=[],
|
||||||
datas=datas,
|
datas=datas,
|
||||||
hiddenimports=hiddenimports,
|
hiddenimports=[
|
||||||
|
'textual', 'textual.app', 'textual.widgets', 'textual.css',
|
||||||
|
'openai', 'openai._client',
|
||||||
|
'neo4j',
|
||||||
|
'sqlite3',
|
||||||
|
'core', 'core.embedded_db', 'core.graph_client',
|
||||||
|
'core.tool_executor', 'core.tool_limiter',
|
||||||
|
'core.tools', 'core.tools.memory_tools',
|
||||||
|
'core.prompts', 'core.prompts.prompt_manager',
|
||||||
|
'core.server', 'core.client', 'core.web_api',
|
||||||
|
'core.migrate',
|
||||||
|
'ui', 'ui.app', 'ui.login_screen',
|
||||||
|
'ui.models', 'ui.models.message', 'ui.models.config', 'ui.models.log_entry',
|
||||||
|
'ui.widgets', 'ui.widgets.left_panel', 'ui.widgets.right_panel',
|
||||||
|
'ui.widgets.input_box', 'ui.widgets.message_history', 'ui.widgets.status_bar',
|
||||||
|
'ui.handlers',
|
||||||
|
'ui.services', 'ui.services.config_manager', 'ui.services.config_service',
|
||||||
|
'flask', 'flask_cors', 'werkzeug',
|
||||||
|
],
|
||||||
hookspath=[],
|
hookspath=[],
|
||||||
hooksconfig={},
|
hooksconfig={},
|
||||||
runtime_hooks=[],
|
runtime_hooks=[],
|
||||||
|
|||||||
@ -1,114 +1,100 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -e
|
set -euo pipefail
|
||||||
echo "===== Building TrulyMEM AppImage for Linux ====="
|
|
||||||
|
echo "===== Building TrulyMEM AppImage ====="
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||||
cd "$PROJECT_ROOT"
|
cd "$PROJECT_ROOT"
|
||||||
if ! command -v python3 &> /dev/null; then
|
echo "Project root: $PROJECT_ROOT"
|
||||||
echo "Error: python3 not found"
|
|
||||||
exit 1
|
APP_NAME="TrulyMEM"
|
||||||
|
APP_DIR="$PROJECT_ROOT/build/appimage-build"
|
||||||
|
|
||||||
|
# ── Step 1: 复用 build_linux.sh 完成 PyInstaller 构建 ──
|
||||||
|
echo ""
|
||||||
|
echo "Step 1: Running build_linux.sh (PyInstaller build)..."
|
||||||
|
bash "$SCRIPT_DIR/build_linux.sh"
|
||||||
|
|
||||||
|
# ── Step 2: 检查 dist/ 中是否有二进制 ──
|
||||||
|
echo ""
|
||||||
|
echo "Step 2: Checking PyInstaller output..."
|
||||||
|
if [ ! -f "dist/$APP_NAME" ]; then
|
||||||
|
echo "Error: dist/$APP_NAME not found after build_linux.sh"; exit 1
|
||||||
fi
|
fi
|
||||||
APPDIR="$PROJECT_ROOT/TrulyMEM.AppDir"
|
echo "✅ Found dist/$APP_NAME ($(ls -lh "dist/$APP_NAME" | awk '{print $5}'))"
|
||||||
rm -rf "$APPDIR"
|
|
||||||
mkdir -p "$APPDIR/usr/bin"
|
# ── Step 3: 组织 AppDir 结构 ──
|
||||||
echo "===== Step 1: Build binary with PyInstaller ====="
|
echo ""
|
||||||
VENV_DIR="$PROJECT_ROOT/.venv_appimage"
|
echo "Step 3: Preparing AppDir structure..."
|
||||||
rm -rf "$VENV_DIR"
|
rm -rf "$APP_DIR" 2>/dev/null || true
|
||||||
python3 -m venv "$VENV_DIR"
|
mkdir -p "$APP_DIR/usr/bin"
|
||||||
source "$VENV_DIR/bin/activate"
|
mkdir -p "$APP_DIR/usr/share/applications"
|
||||||
pip install --upgrade pip
|
mkdir -p "$APP_DIR/usr/share/icons/hicolor/256x256/apps"
|
||||||
pip install -r requirements.txt
|
mkdir -p "$APP_DIR/usr/share/icons/hicolor/48x48/apps"
|
||||||
pip install pyinstaller
|
|
||||||
rm -rf "$PROJECT_ROOT/build/pyinstaller_build" "$PROJECT_ROOT/dist"
|
cp "dist/$APP_NAME" "$APP_DIR/usr/bin/"
|
||||||
echo "Running PyInstaller..."
|
|
||||||
pyinstaller trulymem_entry.py \
|
# 图标处理
|
||||||
--clean \
|
ICON_SOURCE=""
|
||||||
--onefile \
|
if [ -f "pic/image.png" ]; then
|
||||||
--console \
|
ICON_SOURCE="pic/image.png"
|
||||||
--name TrulyMEM \
|
elif [ -f "pic/TrulyMEM.ico" ]; then
|
||||||
--distpath "$PROJECT_ROOT/dist" \
|
echo "⚠️ No pic/image.png found; .ico will not display as AppImage icon"
|
||||||
--workpath "$PROJECT_ROOT/build/pyinstaller_build" \
|
echo " To generate a PNG: convert pic/TrulyMEM.ico pic/image.png"
|
||||||
--add-data "ui/styles:ui/styles" \
|
|
||||||
--add-data "core/prompts/templates:core/prompts/templates" \
|
|
||||||
--hidden-import textual \
|
|
||||||
--hidden-import textual.app \
|
|
||||||
--hidden-import textual.widgets \
|
|
||||||
--hidden-import textual.css \
|
|
||||||
--hidden-import openai \
|
|
||||||
--hidden-import openai._client \
|
|
||||||
--hidden-import neo4j \
|
|
||||||
--hidden-import sqlite3 \
|
|
||||||
--hidden-import core \
|
|
||||||
--hidden-import core.embedded_db \
|
|
||||||
--hidden-import core.graph_client \
|
|
||||||
--hidden-import core.tool_executor \
|
|
||||||
--hidden-import core.tool_limiter \
|
|
||||||
--hidden-import core.tools \
|
|
||||||
--hidden-import core.tools.memory_tools \
|
|
||||||
--hidden-import core.prompts \
|
|
||||||
--hidden-import core.prompts.prompt_manager \
|
|
||||||
--hidden-import ui \
|
|
||||||
--hidden-import ui.app \
|
|
||||||
--hidden-import ui.models \
|
|
||||||
--hidden-import ui.models.message \
|
|
||||||
--hidden-import ui.models.config \
|
|
||||||
--hidden-import ui.models.log_entry \
|
|
||||||
--hidden-import ui.widgets \
|
|
||||||
--hidden-import ui.handlers \
|
|
||||||
--hidden-import ui.services \
|
|
||||||
--hidden-import ui.services.config_manager \
|
|
||||||
--hidden-import ui.services.config_service \
|
|
||||||
--collect-all textual \
|
|
||||||
--noconfirm
|
|
||||||
cp "$PROJECT_ROOT/dist/TrulyMEM" "$APPDIR/usr/bin/"
|
|
||||||
cp "$PROJECT_ROOT/trulymem_entry.py" "$APPDIR/usr/share/trulymem/"
|
|
||||||
echo "===== Step 2: Create AppImage structure ====="
|
|
||||||
cat > "$APPDIR/AppRun" << 'EOF'
|
|
||||||
#!/bin/bash
|
|
||||||
set -e
|
|
||||||
SELF=$(readlink -f "$0")
|
|
||||||
APPDIR=$(dirname "$SELF")
|
|
||||||
export PATH="$APPDIR/usr/bin:$PATH"
|
|
||||||
exec "$APPDIR/usr/bin/TrulyMEM" "$@"
|
|
||||||
EOF
|
|
||||||
chmod +x "$APPDIR/AppRun"
|
|
||||||
cat > "$APPDIR/trulymem.desktop" << 'EOF'
|
|
||||||
[Desktop Entry]
|
|
||||||
Name=TrulyMEM
|
|
||||||
Comment=AI Memory System with Long-term Memory
|
|
||||||
Exec=TrulyMEM %U
|
|
||||||
Icon=trulymem
|
|
||||||
Terminal=true
|
|
||||||
Type=Application
|
|
||||||
Categories=Utility;AI;
|
|
||||||
EOF
|
|
||||||
[ -f "$PROJECT_ROOT/pic/TrulyMEM.png" ] && cp "$PROJECT_ROOT/pic/TrulyMEM.png" "$APPDIR/trulymem.png"
|
|
||||||
APPIMAGE="$PROJECT_ROOT/TrulyMEM.AppImage"
|
|
||||||
rm -f "$APPIMAGE"
|
|
||||||
echo "===== Step 3: Package as AppImage ====="
|
|
||||||
cd /tmp
|
|
||||||
if ! command -v appimagetool &> /dev/null; then
|
|
||||||
echo "Downloading appimagetool..."
|
|
||||||
wget -q https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage -O appimagetool 2>/dev/null || \
|
|
||||||
curl -sL https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage -o appimagetool
|
|
||||||
chmod +x appimagetool 2>/dev/null || true
|
|
||||||
fi
|
fi
|
||||||
cd "$PROJECT_ROOT"
|
|
||||||
if [ -x /tmp/appimagetool ]; then
|
if [ -n "$ICON_SOURCE" ]; then
|
||||||
/tmp/appimagetool "$APPDIR" "$APPIMAGE" || {
|
cp "$ICON_SOURCE" "$APP_DIR/usr/share/icons/hicolor/256x256/apps/${APP_NAME}.png"
|
||||||
echo "appimagetool failed, keeping AppDir for manual packaging"
|
cp "$ICON_SOURCE" "$APP_DIR/usr/share/icons/hicolor/48x48/apps/${APP_NAME}.png"
|
||||||
}
|
cp "$ICON_SOURCE" "$APP_DIR/${APP_NAME}.png"
|
||||||
elif command -v appimagetool &> /dev/null; then
|
echo "✅ Icon: $ICON_SOURCE"
|
||||||
appimagetool "$APPDIR" "$APPIMAGE"
|
|
||||||
else
|
else
|
||||||
echo "Warning: appimagetool not available"
|
echo "⚠️ No icon found, creating placeholder"
|
||||||
echo "AppDir created at: $APPDIR"
|
touch "$APP_DIR/${APP_NAME}.png"
|
||||||
echo "You can manually run: appimagetool $APPDIR $APPIMAGE"
|
|
||||||
fi
|
fi
|
||||||
echo "===== Build Complete ====="
|
|
||||||
[ -f "$APPIMAGE" ] && echo "AppImage: $APPIMAGE" && ls -la "$APPIMAGE"
|
# .desktop 文件
|
||||||
[ -d "$APPDIR" ] && echo "AppDir: $APPDIR (can be packaged manually with appimagetool)"
|
cat > "$APP_DIR/${APP_NAME}.desktop" <<EOF
|
||||||
deactivate
|
[Desktop Entry]
|
||||||
rm -rf "$VENV_DIR"
|
Name=${APP_NAME}
|
||||||
rm -f /tmp/appimagetool
|
Comment=True Human Memory - TUI & Web Mode
|
||||||
echo "Done!"
|
Exec=${APP_NAME}
|
||||||
|
Icon=${APP_NAME}
|
||||||
|
Type=Application
|
||||||
|
Categories=Utility;Office;
|
||||||
|
Terminal=true
|
||||||
|
StartupNotify=true
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cp "$APP_DIR/${APP_NAME}.desktop" "$APP_DIR/usr/share/applications/"
|
||||||
|
|
||||||
|
# AppRun 入口
|
||||||
|
cat > "$APP_DIR/AppRun" <<'APPRUN'
|
||||||
|
#!/bin/bash
|
||||||
|
HERE="$(dirname "$(readlink -f "$0")")"
|
||||||
|
exec "$HERE/usr/bin/TrulyMEM" "$@"
|
||||||
|
APPRUN
|
||||||
|
chmod +x "$APP_DIR/AppRun"
|
||||||
|
|
||||||
|
# ── Step 4: 打包 AppImage ──
|
||||||
|
echo ""
|
||||||
|
echo "Step 4: Building AppImage..."
|
||||||
|
if command -v appimagetool &> /dev/null; then
|
||||||
|
ARCH="${ARCH:-$(uname -m)}" appimagetool "$APP_DIR" "dist/${APP_NAME}.AppImage"
|
||||||
|
echo "✅ AppImage: dist/${APP_NAME}.AppImage"
|
||||||
|
ls -lh "dist/${APP_NAME}.AppImage"
|
||||||
|
else
|
||||||
|
echo "⚠️ appimagetool not found. AppDir ready at: $APP_DIR"
|
||||||
|
echo ""
|
||||||
|
echo "To complete manually, install appimagetool:"
|
||||||
|
echo " wget https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-$(uname -m).AppImage"
|
||||||
|
echo " chmod +x appimagetool-*.AppImage"
|
||||||
|
echo " ./appimagetool-*.AppImage '$APP_DIR' 'dist/${APP_NAME}.AppImage'"
|
||||||
|
echo ""
|
||||||
|
echo "AppDir contents:"
|
||||||
|
find "$APP_DIR" -type f | head -20
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "===== AppImage Build Complete ====="
|
||||||
|
|||||||
@ -1,83 +1,109 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -e
|
set -euo pipefail
|
||||||
|
|
||||||
echo "===== Building TrulyMEM for Linux ====="
|
echo "===== Building TrulyMEM for Linux ====="
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||||
cd "$PROJECT_ROOT"
|
cd "$PROJECT_ROOT"
|
||||||
|
|
||||||
echo "Project root: $PROJECT_ROOT"
|
echo "Project root: $PROJECT_ROOT"
|
||||||
|
|
||||||
|
# ── 前置检查 ──
|
||||||
if ! command -v python3 &> /dev/null; then
|
if ! command -v python3 &> /dev/null; then
|
||||||
echo "Error: python3 not found"
|
echo "Error: python3 not found"; exit 1
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 创建并激活虚拟环境
|
if [ ! -f requirements.txt ]; then
|
||||||
|
echo "Error: requirements.txt not found in $PROJECT_ROOT"; exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 检测 python3-venv ──
|
||||||
|
VENV_AVAILABLE=false
|
||||||
|
if python3 -c "import ensurepip" 2>/dev/null && python3 -m venv --help &>/dev/null; then
|
||||||
|
VENV_AVAILABLE=true
|
||||||
|
else
|
||||||
|
echo "⚠️ python3-venv 未安装(或缺少 ensurepip),建议安装以获得干净构建环境:"
|
||||||
|
echo " sudo apt install python3-venv # Debian/Ubuntu"
|
||||||
|
echo " sudo dnf install python3-virtualenv # Fedora"
|
||||||
|
echo "将使用系统 Python 环境继续(依赖全局包)..."
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 尝试 venv 隔离构建,失败则用系统环境 ──
|
||||||
|
USE_VENV=false
|
||||||
|
if [ "$VENV_AVAILABLE" = true ]; then
|
||||||
VENV_DIR="$PROJECT_ROOT/.venv_build"
|
VENV_DIR="$PROJECT_ROOT/.venv_build"
|
||||||
|
echo "Creating virtual environment..."
|
||||||
echo "Creating virtual environment: $VENV_DIR"
|
if python3 -m venv "$VENV_DIR" 2>/dev/null; then
|
||||||
python3 -m venv "$VENV_DIR"
|
|
||||||
|
|
||||||
echo "Activating virtual environment..."
|
|
||||||
source "$VENV_DIR/bin/activate"
|
source "$VENV_DIR/bin/activate"
|
||||||
|
USE_VENV=true
|
||||||
|
echo "✅ Using virtual environment: $VENV_DIR"
|
||||||
|
pip install --upgrade pip -q
|
||||||
|
pip install -r requirements.txt -q
|
||||||
|
else
|
||||||
|
echo "⚠️ venv creation failed, falling back to system Python"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Upgrading pip in virtual environment..."
|
if [ "$USE_VENV" = false ]; then
|
||||||
pip install --upgrade pip
|
echo "Installing dependencies (system Python)..."
|
||||||
|
pip install -r requirements.txt --break-system-packages -q 2>/dev/null || \
|
||||||
echo "Installing dependencies in virtual environment..."
|
pip install -r requirements.txt -q 2>/dev/null || {
|
||||||
pip install -r requirements.txt
|
echo "⚠️ pip install failed, trying pip3..."
|
||||||
|
pip3 install -r requirements.txt --break-system-packages -q 2>/dev/null || \
|
||||||
|
pip3 install -r requirements.txt -q 2>/dev/null || \
|
||||||
|
echo "⚠️ Some dependencies may be missing; build will proceed anyway"
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 清理旧构建 ──
|
||||||
|
echo ""
|
||||||
echo "Cleaning previous builds..."
|
echo "Cleaning previous builds..."
|
||||||
rm -rf build/dist build/__pycache__ 2>/dev/null || true
|
rm -rf dist/ build/trulymem/ 2>/dev/null || true
|
||||||
|
|
||||||
echo "Running PyInstaller..."
|
# ── PyInstaller 构建 ──
|
||||||
python -m PyInstaller trulymem_entry.py \
|
echo ""
|
||||||
--clean \
|
echo "================================"
|
||||||
--onefile \
|
echo "Building TrulyMEM (TUI + Web embedded)"
|
||||||
--console \
|
echo "================================"
|
||||||
--name TrulyMEM \
|
python3 -m PyInstaller --clean build/trulymem.spec --noconfirm
|
||||||
--add-data "ui/styles:ui/styles" \
|
|
||||||
--add-data "core/prompts/templates:core/prompts/templates" \
|
|
||||||
--hidden-import textual \
|
|
||||||
--hidden-import textual.app \
|
|
||||||
--hidden-import textual.widgets \
|
|
||||||
--hidden-import textual.css \
|
|
||||||
--hidden-import openai \
|
|
||||||
--hidden-import openai._client \
|
|
||||||
--hidden-import neo4j \
|
|
||||||
--hidden-import sqlite3 \
|
|
||||||
--hidden-import core \
|
|
||||||
--hidden-import core.embedded_db \
|
|
||||||
--hidden-import core.graph_client \
|
|
||||||
--hidden-import core.tool_executor \
|
|
||||||
--hidden-import core.tool_limiter \
|
|
||||||
--hidden-import core.tools \
|
|
||||||
--hidden-import core.tools.memory_tools \
|
|
||||||
--hidden-import core.prompts \
|
|
||||||
--hidden-import core.prompts.prompt_manager \
|
|
||||||
--hidden-import ui \
|
|
||||||
--hidden-import ui.app \
|
|
||||||
--hidden-import ui.models \
|
|
||||||
--hidden-import ui.models.message \
|
|
||||||
--hidden-import ui.models.config \
|
|
||||||
--hidden-import ui.models.log_entry \
|
|
||||||
--hidden-import ui.widgets \
|
|
||||||
--hidden-import ui.handlers \
|
|
||||||
--hidden-import ui.services \
|
|
||||||
--hidden-import ui.services.config_manager \
|
|
||||||
--hidden-import ui.services.config_service \
|
|
||||||
--collect-all textual \
|
|
||||||
--noconfirm
|
|
||||||
|
|
||||||
|
# ── 创建 Linux `.desktop` 文件(打包图标不能嵌入 ELF,通过 .desktop 引用)──
|
||||||
|
echo ""
|
||||||
|
echo "Generating .desktop file for Linux..."
|
||||||
|
BINARY_PATH="$(cd dist && pwd)/TrulyMEM"
|
||||||
|
ICON_PATH="$(cd pic && pwd)/image.png"
|
||||||
|
|
||||||
|
cat > "dist/TrulyMEM.desktop" <<EOF
|
||||||
|
[Desktop Entry]
|
||||||
|
Name=TrulyMEM
|
||||||
|
Comment=True Human Memory - TUI & Web Mode
|
||||||
|
Exec=${BINARY_PATH}
|
||||||
|
Icon=${ICON_PATH}
|
||||||
|
Terminal=true
|
||||||
|
Type=Application
|
||||||
|
Categories=Utility;Office;
|
||||||
|
StartupNotify=true
|
||||||
|
EOF
|
||||||
|
|
||||||
|
chmod +x "dist/TrulyMEM.desktop"
|
||||||
|
echo "✅ dist/TrulyMEM.desktop created (icon: pic/image.png)"
|
||||||
|
|
||||||
|
# ── 构建完成 ──
|
||||||
|
echo ""
|
||||||
|
echo "================================"
|
||||||
echo "===== Build Complete ====="
|
echo "===== Build Complete ====="
|
||||||
echo "Binary: dist/TrulyMEM"
|
echo "Binary: dist/TrulyMEM"
|
||||||
ls -la dist/
|
echo "Desktop: dist/TrulyMEM.desktop"
|
||||||
|
echo "------------------------------"
|
||||||
|
ls -lh dist/ 2>/dev/null || ls -la dist/
|
||||||
|
echo "================================"
|
||||||
|
|
||||||
# 清理虚拟环境
|
# ── 清理 venv ──
|
||||||
echo "Cleaning up virtual environment..."
|
if [ "$USE_VENV" = true ]; then
|
||||||
deactivate
|
deactivate 2>/dev/null || true
|
||||||
rm -rf "$VENV_DIR"
|
rm -rf "$VENV_DIR"
|
||||||
|
echo "Virtual environment cleaned up."
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Build finished successfully!"
|
echo "Build finished successfully!"
|
||||||
68
build/build_macos.sh
Normal file → Executable file
68
build/build_macos.sh
Normal file → Executable file
@ -6,85 +6,33 @@ echo "===== Building TrulyMEM for macOS ====="
|
|||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||||
cd "$PROJECT_ROOT"
|
cd "$PROJECT_ROOT"
|
||||||
|
|
||||||
echo "Project root: $PROJECT_ROOT"
|
echo "Project root: $PROJECT_ROOT"
|
||||||
|
|
||||||
if ! command -v python3 &> /dev/null; then
|
if ! command -v python3 &> /dev/null; then
|
||||||
echo "Error: python3 not found"
|
echo "Error: python3 not found"; exit 1
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 创建并激活虚拟环境
|
|
||||||
VENV_DIR="$PROJECT_ROOT/.venv_build"
|
VENV_DIR="$PROJECT_ROOT/.venv_build"
|
||||||
|
|
||||||
echo "Creating virtual environment: $VENV_DIR"
|
echo "Creating virtual environment: $VENV_DIR"
|
||||||
python3 -m venv "$VENV_DIR"
|
python3 -m venv "$VENV_DIR"
|
||||||
|
|
||||||
echo "Activating virtual environment..."
|
|
||||||
source "$VENV_DIR/bin/activate"
|
source "$VENV_DIR/bin/activate"
|
||||||
|
|
||||||
echo "Upgrading pip in virtual environment..."
|
|
||||||
pip install --upgrade pip
|
pip install --upgrade pip
|
||||||
|
|
||||||
echo "Installing dependencies in virtual environment..."
|
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
|
|
||||||
echo "Generating ICNS icon..."
|
|
||||||
if [ -d "pic/TrulyMEM.iconset" ]; then
|
|
||||||
iconutil -c icns pic/TrulyMEM.iconset -o pic/TrulyMEM.icns
|
|
||||||
echo "ICNS icon generated: pic/TrulyMEM.icns"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Cleaning previous builds..."
|
echo "Cleaning previous builds..."
|
||||||
rm -rf build/dist build/__pycache__ 2>/dev/null || true
|
rm -rf dist/ build/trulymem/ 2>/dev/null || true
|
||||||
|
|
||||||
echo "Running PyInstaller..."
|
echo "================================"
|
||||||
python -m PyInstaller trulymem_entry.py \
|
echo "Building TrulyMEM (TUI + Web embedded)"
|
||||||
--clean \
|
echo "================================"
|
||||||
--onefile \
|
python -m PyInstaller --clean build/trulymem.spec --noconfirm
|
||||||
--console \
|
|
||||||
--name TrulyMEM \
|
|
||||||
--icon "pic/TrulyMEM.icns" \
|
|
||||||
--add-data "ui/styles:ui/styles" \
|
|
||||||
--add-data "core/prompts/templates:core/prompts/templates" \
|
|
||||||
--hidden-import textual \
|
|
||||||
--hidden-import textual.app \
|
|
||||||
--hidden-import textual.widgets \
|
|
||||||
--hidden-import textual.css \
|
|
||||||
--hidden-import openai \
|
|
||||||
--hidden-import openai._client \
|
|
||||||
--hidden-import neo4j \
|
|
||||||
--hidden-import sqlite3 \
|
|
||||||
--hidden-import core \
|
|
||||||
--hidden-import core.embedded_db \
|
|
||||||
--hidden-import core.graph_client \
|
|
||||||
--hidden-import core.tool_executor \
|
|
||||||
--hidden-import core.tool_limiter \
|
|
||||||
--hidden-import core.tools \
|
|
||||||
--hidden-import core.tools.memory_tools \
|
|
||||||
--hidden-import core.prompts \
|
|
||||||
--hidden-import core.prompts.prompt_manager \
|
|
||||||
--hidden-import ui \
|
|
||||||
--hidden-import ui.app \
|
|
||||||
--hidden-import ui.models \
|
|
||||||
--hidden-import ui.models.message \
|
|
||||||
--hidden-import ui.models.config \
|
|
||||||
--hidden-import ui.models.log_entry \
|
|
||||||
--hidden-import ui.widgets \
|
|
||||||
--hidden-import ui.handlers \
|
|
||||||
--hidden-import ui.services \
|
|
||||||
--hidden-import ui.services.config_manager \
|
|
||||||
--hidden-import ui.services.config_service \
|
|
||||||
--collect-all textual \
|
|
||||||
--noconfirm
|
|
||||||
|
|
||||||
|
echo "================================"
|
||||||
echo "===== Build Complete ====="
|
echo "===== Build Complete ====="
|
||||||
echo "Binary: dist/TrulyMEM"
|
echo "Binary: dist/TrulyMEM"
|
||||||
|
echo " -> run: open dist/TrulyMEM"
|
||||||
ls -la dist/
|
ls -la dist/
|
||||||
|
|
||||||
# 清理虚拟环境
|
|
||||||
echo "Cleaning up virtual environment..."
|
|
||||||
deactivate
|
deactivate
|
||||||
rm -rf "$VENV_DIR"
|
rm -rf "$VENV_DIR"
|
||||||
|
|
||||||
echo "Build finished successfully!"
|
echo "Build finished successfully!"
|
||||||
@ -1,60 +1,35 @@
|
|||||||
@echo off
|
@echo off
|
||||||
|
setlocal enabledelayedexpansion
|
||||||
|
|
||||||
echo ===== Building TrulyMEM for Windows =====
|
echo ===== Building TrulyMEM for Windows =====
|
||||||
|
|
||||||
REM 切换到脚本所在目录的上一级目录(项目根目录)
|
set "SCRIPT_DIR=%~dp0"
|
||||||
cd /d "%~dp0.."
|
set "PROJECT_ROOT=%SCRIPT_DIR%.."
|
||||||
echo Project root: %CD%
|
cd /d "%PROJECT_ROOT%"
|
||||||
|
echo Project root: %PROJECT_ROOT%
|
||||||
|
|
||||||
python --version >nul 2>&1
|
echo Creating virtual environment: .venv_build
|
||||||
if errorlevel 1 (
|
python -m venv .venv_build
|
||||||
echo Error: python not found
|
call .venv_build\Scripts\activate.bat
|
||||||
exit /b 1
|
pip install --upgrade pip
|
||||||
)
|
|
||||||
|
|
||||||
echo Installing dependencies...
|
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
|
|
||||||
echo Running PyInstaller...
|
echo Cleaning previous builds...
|
||||||
python -m PyInstaller trulymem_entry.py ^
|
if exist dist rmdir /s /q dist
|
||||||
--clean ^
|
if exist build\trulymem rmdir /s /q build\trulymem
|
||||||
--onefile ^
|
|
||||||
--console ^
|
|
||||||
--name TrulyMEM ^
|
|
||||||
--icon "pic/TrulyMEM.ico" ^
|
|
||||||
--add-data "ui/styles;ui/styles" ^
|
|
||||||
--add-data "core/prompts/templates;core/prompts/templates" ^
|
|
||||||
--hidden-import textual ^
|
|
||||||
--hidden-import textual.app ^
|
|
||||||
--hidden-import textual.widgets ^
|
|
||||||
--hidden-import textual.css ^
|
|
||||||
--hidden-import openai ^
|
|
||||||
--hidden-import openai._client ^
|
|
||||||
--hidden-import neo4j ^
|
|
||||||
--hidden-import sqlite3 ^
|
|
||||||
--hidden-import core ^
|
|
||||||
--hidden-import core.embedded_db ^
|
|
||||||
--hidden-import core.graph_client ^
|
|
||||||
--hidden-import core.tool_executor ^
|
|
||||||
--hidden-import core.tool_limiter ^
|
|
||||||
--hidden-import core.tools ^
|
|
||||||
--hidden-import core.tools.memory_tools ^
|
|
||||||
--hidden-import core.prompts ^
|
|
||||||
--hidden-import core.prompts.prompt_manager ^
|
|
||||||
--hidden-import ui ^
|
|
||||||
--hidden-import ui.app ^
|
|
||||||
--hidden-import ui.models ^
|
|
||||||
--hidden-import ui.models.message ^
|
|
||||||
--hidden-import ui.models.config ^
|
|
||||||
--hidden-import ui.models.log_entry ^
|
|
||||||
--hidden-import ui.widgets ^
|
|
||||||
--hidden-import ui.handlers ^
|
|
||||||
--hidden-import ui.services ^
|
|
||||||
--hidden-import ui.services.config_manager ^
|
|
||||||
--hidden-import ui.services.config_service ^
|
|
||||||
--collect-all textual ^
|
|
||||||
--noconfirm
|
|
||||||
|
|
||||||
|
echo ================================
|
||||||
|
echo Building TrulyMEM (TUI + Web embedded)
|
||||||
|
echo ================================
|
||||||
|
python -m PyInstaller --clean build\trulymem.spec --noconfirm
|
||||||
|
|
||||||
|
echo ================================
|
||||||
echo ===== Build Complete =====
|
echo ===== Build Complete =====
|
||||||
echo Binary: dist\TrulyMEM.exe
|
echo Binary: dist\TrulyMEM.exe
|
||||||
dir dist\TrulyMEM.exe
|
dir dist
|
||||||
pause
|
|
||||||
|
call .venv_build\Scripts\deactivate.bat
|
||||||
|
if exist .venv_build rmdir /s /q .venv_build
|
||||||
|
|
||||||
|
echo Build finished successfully!
|
||||||
|
endlocal
|
||||||
@ -4,63 +4,68 @@ import sys
|
|||||||
|
|
||||||
block_cipher = None
|
block_cipher = None
|
||||||
|
|
||||||
project_root = os.path.dirname(os.path.abspath(SPEC))
|
project_root = os.path.dirname(os.path.dirname(os.path.abspath(SPEC)))
|
||||||
sys.path.insert(0, project_root)
|
sys.path.insert(0, project_root)
|
||||||
|
|
||||||
datas = []
|
datas = []
|
||||||
if os.path.exists(os.path.join(project_root, 'ui', 'styles')):
|
# UI 样式
|
||||||
for root, dirs, files in os.walk(os.path.join(project_root, 'ui', 'styles')):
|
ui_styles_dir = os.path.join(project_root, 'ui', 'styles')
|
||||||
|
if os.path.exists(ui_styles_dir):
|
||||||
|
for root, dirs, files in os.walk(ui_styles_dir):
|
||||||
for f in files:
|
for f in files:
|
||||||
src = os.path.join(root, f)
|
datas.append((os.path.join(root, f), 'ui/styles'))
|
||||||
dst = os.path.join('ui', 'styles', os.path.relpath(src, os.path.join(project_root, 'ui', 'styles')))
|
|
||||||
datas.append((src, dst))
|
|
||||||
|
|
||||||
if os.path.exists(os.path.join(project_root, 'core', 'prompts', 'templates')):
|
# Prompt 模板
|
||||||
for root, dirs, files in os.walk(os.path.join(project_root, 'core', 'prompts', 'templates')):
|
prompt_tmpl_dir = os.path.join(project_root, 'core', 'prompts', 'templates')
|
||||||
|
if os.path.exists(prompt_tmpl_dir):
|
||||||
|
for root, dirs, files in os.walk(prompt_tmpl_dir):
|
||||||
for f in files:
|
for f in files:
|
||||||
src = os.path.join(root, f)
|
datas.append((os.path.join(root, f), 'core/prompts/templates'))
|
||||||
dst = os.path.join('core', 'prompts', 'templates', os.path.relpath(src, os.path.join(project_root, 'core', 'prompts', 'templates')))
|
|
||||||
datas.append((src, dst))
|
|
||||||
|
|
||||||
|
# Web 静态文件
|
||||||
|
static_dir = os.path.join(project_root, 'ui', 'static')
|
||||||
|
if os.path.exists(static_dir):
|
||||||
|
for root, dirs, files in os.walk(static_dir):
|
||||||
|
for f in files:
|
||||||
|
datas.append((os.path.join(root, f), 'ui/static'))
|
||||||
|
|
||||||
|
# Web 模板
|
||||||
|
templates_dir = os.path.join(project_root, 'ui', 'templates')
|
||||||
|
if os.path.exists(templates_dir):
|
||||||
|
for root, dirs, files in os.walk(templates_dir):
|
||||||
|
for f in files:
|
||||||
|
datas.append((os.path.join(root, f), 'ui/templates'))
|
||||||
|
|
||||||
|
# Web API 脚本(以便子进程模式回退使用)
|
||||||
|
web_api_src = os.path.join(project_root, 'core', 'web_api.py')
|
||||||
|
if os.path.exists(web_api_src):
|
||||||
|
datas.append((web_api_src, '.'))
|
||||||
|
|
||||||
|
# ——— TUI 主二进制 ———
|
||||||
a = Analysis(
|
a = Analysis(
|
||||||
['trulymem_entry.py'],
|
[os.path.join(project_root, 'trulymem_entry.py')],
|
||||||
pathex=[],
|
pathex=[],
|
||||||
binaries=[],
|
binaries=[],
|
||||||
datas=datas,
|
datas=datas,
|
||||||
hiddenimports=[
|
hiddenimports=[
|
||||||
'textual',
|
'textual', 'textual.app', 'textual.widgets', 'textual.css',
|
||||||
'textual.app',
|
'openai', 'openai._client',
|
||||||
'textual.widgets',
|
|
||||||
'textual.css',
|
|
||||||
'openai',
|
|
||||||
'openai._client',
|
|
||||||
'neo4j',
|
'neo4j',
|
||||||
'sqlite3',
|
'sqlite3',
|
||||||
'graph_memory_tui',
|
'core', 'core.embedded_db', 'core.graph_client',
|
||||||
'core',
|
'core.tool_executor', 'core.tool_limiter',
|
||||||
'core.embedded_db',
|
'core.tools', 'core.tools.memory_tools',
|
||||||
'core.graph_client',
|
'core.prompts', 'core.prompts.prompt_manager',
|
||||||
'core.tool_executor',
|
'core.server', 'core.client',
|
||||||
'core.tool_limiter',
|
'core.migrate',
|
||||||
'core.tools',
|
'ui', 'ui.app', 'ui.login_screen',
|
||||||
'core.tools.memory_tools',
|
'ui.models', 'ui.models.message', 'ui.models.config', 'ui.models.log_entry',
|
||||||
'core.prompts',
|
'ui.widgets', 'ui.widgets.left_panel', 'ui.widgets.right_panel',
|
||||||
'core.prompts.prompt_manager',
|
'ui.widgets.input_box', 'ui.widgets.message_history', 'ui.widgets.status_bar',
|
||||||
'ui',
|
|
||||||
'ui.app',
|
|
||||||
'ui.models',
|
|
||||||
'ui.models.message',
|
|
||||||
'ui.models.config',
|
|
||||||
'ui.models.log_entry',
|
|
||||||
'ui.widgets',
|
|
||||||
'ui.widgets.left_panel',
|
|
||||||
'ui.widgets.right_panel',
|
|
||||||
'ui.widgets.input_box',
|
|
||||||
'ui.widgets.message_history',
|
|
||||||
'ui.widgets.status_bar',
|
|
||||||
'ui.handlers',
|
'ui.handlers',
|
||||||
'ui.services',
|
'ui.services', 'ui.services.config_manager', 'ui.services.config_service',
|
||||||
'ui.services.config_manager',
|
'web_api',
|
||||||
|
'flask', 'flask_cors', 'werkzeug',
|
||||||
],
|
],
|
||||||
hookspath=[],
|
hookspath=[],
|
||||||
hooksconfig={},
|
hooksconfig={},
|
||||||
@ -69,7 +74,8 @@ a = Analysis(
|
|||||||
noarchive=False,
|
noarchive=False,
|
||||||
optimize=0,
|
optimize=0,
|
||||||
)
|
)
|
||||||
pyz = PYZ(a.pure, block_cipher)
|
pyz = PYZ(a.pure)
|
||||||
|
|
||||||
exe = EXE(
|
exe = EXE(
|
||||||
pyz,
|
pyz,
|
||||||
a.scripts,
|
a.scripts,
|
||||||
@ -77,6 +83,7 @@ exe = EXE(
|
|||||||
a.datas,
|
a.datas,
|
||||||
[],
|
[],
|
||||||
name='TrulyMEM',
|
name='TrulyMEM',
|
||||||
|
icon=os.path.join(project_root, 'pic', 'TrulyMEM.ico'),
|
||||||
debug=False,
|
debug=False,
|
||||||
bootloader_ignore_signals=False,
|
bootloader_ignore_signals=False,
|
||||||
strip=False,
|
strip=False,
|
||||||
|
|||||||
@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"app": {
|
||||||
|
"bundleName": "com.trulymem.app",
|
||||||
|
"debug": true,
|
||||||
|
"versionCode": 1000001,
|
||||||
|
"versionName": "1.0.0",
|
||||||
|
"minAPIVersion": 60100023,
|
||||||
|
"targetAPIVersion": 60100023,
|
||||||
|
"apiReleaseType": "Release",
|
||||||
|
"targetMinorAPIVersion": 0,
|
||||||
|
"targetPatchAPIVersion": 0,
|
||||||
|
"compileSdkVersion": "6.1.0.105",
|
||||||
|
"compileSdkType": "HarmonyOS",
|
||||||
|
"appEnvironments": [],
|
||||||
|
"bundleType": "app",
|
||||||
|
"buildMode": "debug"
|
||||||
|
},
|
||||||
|
"module": {
|
||||||
|
"name": "common",
|
||||||
|
"type": "har",
|
||||||
|
"description": "TrulyMEM common module",
|
||||||
|
"deviceTypes": [
|
||||||
|
"phone",
|
||||||
|
"tablet",
|
||||||
|
"2in1"
|
||||||
|
],
|
||||||
|
"packageName": "@ohos/common",
|
||||||
|
"installationFree": false
|
||||||
|
}
|
||||||
|
}
|
||||||
185
core/activity_recorder.py
Normal file
185
core/activity_recorder.py
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
"""
|
||||||
|
活动记录器 - 记录 AI 对图数据库的操作
|
||||||
|
使用 SQLite :memory: 供 WebUI 实时渲染,同时后台线程持久化到日志文件
|
||||||
|
日志每 6 小时自动压缩归档
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import time
|
||||||
|
import os
|
||||||
|
import gzip
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import shutil
|
||||||
|
from typing import List, Dict, Optional
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
|
||||||
|
# 日志目录
|
||||||
|
LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "logs")
|
||||||
|
# 归档间隔(秒)
|
||||||
|
ARCHIVE_INTERVAL = 6 * 3600 # 6 小时
|
||||||
|
# 轮询间隔(秒)
|
||||||
|
POLL_INTERVAL = 10
|
||||||
|
|
||||||
|
|
||||||
|
class ActivityRecorder:
|
||||||
|
"""记录 AI 对图数据库的操作到内存 SQLite"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.conn = sqlite3.connect(":memory:", check_same_thread=False)
|
||||||
|
self.conn.execute(
|
||||||
|
"CREATE TABLE activities (id INTEGER PRIMARY KEY AUTOINCREMENT, "
|
||||||
|
"timestamp REAL, action TEXT, tool_name TEXT, entity TEXT, detail TEXT)"
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def record(self, action: str, tool_name: str, entity: str, detail: str = "") -> None:
|
||||||
|
self.conn.execute(
|
||||||
|
"INSERT INTO activities (timestamp, action, tool_name, entity, detail) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(time.time(), action, tool_name, entity, detail)
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def get_all(self) -> List[Dict]:
|
||||||
|
cursor = self.conn.execute(
|
||||||
|
"SELECT id, timestamp, action, tool_name, entity, detail FROM activities ORDER BY id"
|
||||||
|
)
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
return [
|
||||||
|
{"id": r[0], "timestamp": r[1], "action": r[2],
|
||||||
|
"tool_name": r[3], "entity": r[4], "detail": r[5]}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_since_id(self, last_id: int) -> List[Dict]:
|
||||||
|
"""获取自 last_id 之后的新记录"""
|
||||||
|
cursor = self.conn.execute(
|
||||||
|
"SELECT id, timestamp, action, tool_name, entity, detail FROM activities WHERE id > ? ORDER BY id",
|
||||||
|
(last_id,)
|
||||||
|
)
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
return [
|
||||||
|
{"id": r[0], "timestamp": r[1], "action": r[2],
|
||||||
|
"tool_name": r[3], "entity": r[4], "detail": r[5]}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_max_id(self) -> int:
|
||||||
|
cursor = self.conn.execute("SELECT COALESCE(MAX(id), 0) FROM activities")
|
||||||
|
return cursor.fetchone()[0]
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
self.conn.execute("DELETE FROM activities")
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def get_summary(self) -> Dict[str, int]:
|
||||||
|
cursor = self.conn.execute("SELECT action, COUNT(*) FROM activities GROUP BY action")
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
return {r[0]: r[1] for r in rows}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 日志文件管理 ──
|
||||||
|
|
||||||
|
def _current_log_path() -> str:
|
||||||
|
"""返回当前日志文件路径(按日期命名)"""
|
||||||
|
os.makedirs(LOG_DIR, exist_ok=True)
|
||||||
|
date_str = datetime.now().strftime("%Y%m%d")
|
||||||
|
return os.path.join(LOG_DIR, f"operations.{date_str}.log")
|
||||||
|
|
||||||
|
|
||||||
|
def _archive_log(filepath: str) -> str:
|
||||||
|
"""压缩归档日志文件,返回归档文件路径"""
|
||||||
|
if not os.path.exists(filepath) or os.path.getsize(filepath) == 0:
|
||||||
|
return ""
|
||||||
|
archive_path = filepath + ".gz"
|
||||||
|
try:
|
||||||
|
with open(filepath, "rb") as f_in:
|
||||||
|
with gzip.open(archive_path, "wb") as f_out:
|
||||||
|
shutil.copyfileobj(f_in, f_out)
|
||||||
|
os.remove(filepath)
|
||||||
|
return archive_path
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
class LogPersister:
|
||||||
|
"""后台日志持久化线程 - 定期将内存记录写入日志文件并自动归档"""
|
||||||
|
|
||||||
|
def __init__(self, recorder: ActivityRecorder):
|
||||||
|
self.recorder = recorder
|
||||||
|
self._last_persisted_id = 0
|
||||||
|
self._last_archive_time = time.time()
|
||||||
|
self._running = True
|
||||||
|
self._thread = threading.Thread(target=self._run, daemon=True, name="log-persister")
|
||||||
|
self._thread.start()
|
||||||
|
|
||||||
|
def _run(self):
|
||||||
|
"""主循环"""
|
||||||
|
while self._running:
|
||||||
|
try:
|
||||||
|
self._persist_new()
|
||||||
|
self._check_archive()
|
||||||
|
except Exception:
|
||||||
|
pass # 不因日志异常影响主进程
|
||||||
|
time.sleep(POLL_INTERVAL)
|
||||||
|
|
||||||
|
def _persist_new(self):
|
||||||
|
"""增量写入新记录到日志文件"""
|
||||||
|
records = self.recorder.get_since_id(self._last_persisted_id)
|
||||||
|
if not records:
|
||||||
|
return
|
||||||
|
|
||||||
|
log_path = _current_log_path()
|
||||||
|
with open(log_path, "a", encoding="utf-8") as f:
|
||||||
|
for r in records:
|
||||||
|
line = json.dumps(r, ensure_ascii=False)
|
||||||
|
f.write(line + "\n")
|
||||||
|
|
||||||
|
# 更新水位
|
||||||
|
if records:
|
||||||
|
self._last_persisted_id = records[-1]["id"]
|
||||||
|
|
||||||
|
def _check_archive(self):
|
||||||
|
"""检查是否需要归档"""
|
||||||
|
elapsed = time.time() - self._last_archive_time
|
||||||
|
if elapsed < ARCHIVE_INTERVAL:
|
||||||
|
return
|
||||||
|
|
||||||
|
log_path = _current_log_path()
|
||||||
|
archived = _archive_log(log_path)
|
||||||
|
if archived:
|
||||||
|
dt = datetime.fromtimestamp(self._last_archive_time)
|
||||||
|
print(f"[日志归档] {dt.strftime('%H:%M')} → {os.path.basename(archived)} ({_fmt_size(archived)})")
|
||||||
|
self._last_archive_time = time.time()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_size(path: str) -> str:
|
||||||
|
size = os.path.getsize(path)
|
||||||
|
for unit in ("B", "KB", "MB"):
|
||||||
|
if size < 1024:
|
||||||
|
return f"{size:.1f}{unit}"
|
||||||
|
size /= 1024
|
||||||
|
return f"{size:.1f}GB"
|
||||||
|
|
||||||
|
|
||||||
|
# ── 单例 ──
|
||||||
|
|
||||||
|
_recorder: Optional[ActivityRecorder] = None
|
||||||
|
_persister: Optional[LogPersister] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_recorder() -> ActivityRecorder:
|
||||||
|
"""获取全局 ActivityRecorder(首次调用时自动启动日志持久化线程)"""
|
||||||
|
global _recorder, _persister
|
||||||
|
if _recorder is None:
|
||||||
|
_recorder = ActivityRecorder()
|
||||||
|
_persister = LogPersister(_recorder)
|
||||||
|
return _recorder
|
||||||
|
|
||||||
|
|
||||||
|
def get_persister() -> Optional[LogPersister]:
|
||||||
|
return _persister
|
||||||
@ -86,5 +86,43 @@ class BackendClient:
|
|||||||
response = self._server.send(packet)
|
response = self._server.send(packet)
|
||||||
return response.body.get("data", {})
|
return response.body.get("data", {})
|
||||||
|
|
||||||
|
def get_web_users(self) -> list:
|
||||||
|
"""获取 Web 用户列表"""
|
||||||
|
packet = Packet(
|
||||||
|
id=self._next_id(),
|
||||||
|
type=PacketType.GET_WEB_USERS,
|
||||||
|
body={}
|
||||||
|
)
|
||||||
|
return self._server.send(packet).body.get("users", [])
|
||||||
|
|
||||||
|
def set_web_user(self, username: str, password: str) -> Dict:
|
||||||
|
"""设置 Web 用户"""
|
||||||
|
packet = Packet(
|
||||||
|
id=self._next_id(),
|
||||||
|
type=PacketType.SET_WEB_USER,
|
||||||
|
body={"username": username, "password": password}
|
||||||
|
)
|
||||||
|
return self._server.send(packet).body.get("data", {"success": False})
|
||||||
|
|
||||||
|
def get_full_config(self) -> Dict:
|
||||||
|
"""获取完整配置"""
|
||||||
|
packet = Packet(
|
||||||
|
id=self._next_id(),
|
||||||
|
type=PacketType.GET_CONFIG,
|
||||||
|
body={}
|
||||||
|
)
|
||||||
|
response = self._server.send(packet)
|
||||||
|
return response.body if response.body else {"api_config": {}, "tool_limits": {}}
|
||||||
|
|
||||||
|
def report_web_status(self, running: bool, port: int = 4096) -> Dict:
|
||||||
|
"""向后端报告 Web 服务运行状态"""
|
||||||
|
packet = Packet(
|
||||||
|
id=self._next_id(),
|
||||||
|
type=PacketType.GET_WEB_SERVICE_STATUS,
|
||||||
|
body={"running": running, "port": port}
|
||||||
|
)
|
||||||
|
response = self._server.send(packet)
|
||||||
|
return response.body if response.body else {"success": False}
|
||||||
|
|
||||||
def shutdown(self) -> None:
|
def shutdown(self) -> None:
|
||||||
self._server.shutdown()
|
self._server.shutdown()
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@ -86,6 +87,39 @@ class EmbeddedGraphDB:
|
|||||||
""")
|
""")
|
||||||
cursor.execute("CREATE INDEX idx_chat_created ON chat_records(created_at)")
|
cursor.execute("CREATE INDEX idx_chat_created ON chat_records(created_at)")
|
||||||
|
|
||||||
|
# 创建 Web 用户表(支持多用户隔离)
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS web_users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'user',
|
||||||
|
config_path TEXT,
|
||||||
|
db_path TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# 检查并添加新字段(用于旧数据库迁移)
|
||||||
|
cursor.execute("PRAGMA table_info(web_users)")
|
||||||
|
columns = [row[1] for row in cursor.fetchall()]
|
||||||
|
if 'config_path' not in columns:
|
||||||
|
cursor.execute("ALTER TABLE web_users ADD COLUMN config_path TEXT")
|
||||||
|
if 'db_path' not in columns:
|
||||||
|
cursor.execute("ALTER TABLE web_users ADD COLUMN db_path TEXT")
|
||||||
|
if 'role' not in columns:
|
||||||
|
cursor.execute("ALTER TABLE web_users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'")
|
||||||
|
|
||||||
|
# 确保至少有一个 admin(当 role 列刚添加时,已有用户都是 user)
|
||||||
|
cursor.execute("SELECT COUNT(*) as cnt FROM web_users WHERE role = 'admin'")
|
||||||
|
has_admin = cursor.fetchone()[0] > 0
|
||||||
|
if not has_admin:
|
||||||
|
cursor.execute("SELECT id, username FROM web_users ORDER BY created_at ASC LIMIT 1")
|
||||||
|
first_user = cursor.fetchone()
|
||||||
|
if first_user:
|
||||||
|
cursor.execute("UPDATE web_users SET role = 'admin' WHERE id = ?", (first_user[0],))
|
||||||
|
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
|
|
||||||
def ensure_constraints(self):
|
def ensure_constraints(self):
|
||||||
@ -150,14 +184,26 @@ class EmbeddedGraphDB:
|
|||||||
'mention_count': row['mention_count']
|
'mention_count': row['mention_count']
|
||||||
})
|
})
|
||||||
|
|
||||||
# 搜索关系
|
# 广度优先搜索(BFS)扩展实体和关系
|
||||||
relations = []
|
relations = []
|
||||||
|
visited_entity_ids = set(entity_ids) # 已访问的实体
|
||||||
|
current_layer_ids = set(entity_ids) # 当前层的实体
|
||||||
|
|
||||||
if entity_ids:
|
# 记录每个实体的深度
|
||||||
placeholders = ','.join('?' * len(entity_ids))
|
entity_depths = {} # entity_id -> depth
|
||||||
|
for eid in entity_ids:
|
||||||
|
entity_depths[eid] = 0
|
||||||
|
|
||||||
|
for layer in range(depth):
|
||||||
|
if not current_layer_ids:
|
||||||
|
break
|
||||||
|
|
||||||
|
# 查询当前层实体的所有关系
|
||||||
|
placeholders = ','.join('?' * len(current_layer_ids))
|
||||||
|
|
||||||
query = f"""
|
query = f"""
|
||||||
SELECT r.id, e1.name as source, e2.name as target,
|
SELECT r.id, r.source_id, r.target_id,
|
||||||
|
e1.name as source, e2.name as target,
|
||||||
r.relation_type as type, r.confidence, r.session_id,
|
r.relation_type as type, r.confidence, r.session_id,
|
||||||
r.turn_id, r.created_at, r.status
|
r.turn_id, r.created_at, r.status
|
||||||
FROM relations r
|
FROM relations r
|
||||||
@ -167,7 +213,7 @@ class EmbeddedGraphDB:
|
|||||||
AND r.status = 'active'
|
AND r.status = 'active'
|
||||||
"""
|
"""
|
||||||
|
|
||||||
params = list(entity_ids) + list(entity_ids)
|
params = list(current_layer_ids) + list(current_layer_ids)
|
||||||
|
|
||||||
if session_filter:
|
if session_filter:
|
||||||
query += " AND r.session_id = ?"
|
query += " AND r.session_id = ?"
|
||||||
@ -175,8 +221,18 @@ class EmbeddedGraphDB:
|
|||||||
|
|
||||||
cursor.execute(query, params)
|
cursor.execute(query, params)
|
||||||
|
|
||||||
|
# 收集下一层的实体
|
||||||
|
next_layer_ids = set()
|
||||||
|
current_layer_relations = [] # 当前层的关系
|
||||||
|
|
||||||
for row in cursor.fetchall():
|
for row in cursor.fetchall():
|
||||||
relations.append({
|
# 计算关系的深度(取两端实体深度的最大值+1)
|
||||||
|
source_depth = entity_depths.get(row['source_id'], layer)
|
||||||
|
target_depth = entity_depths.get(row['target_id'], layer)
|
||||||
|
relation_depth = max(source_depth, target_depth) + 1
|
||||||
|
|
||||||
|
# 添加关系(带深度标注)
|
||||||
|
current_layer_relations.append({
|
||||||
'source': row['source'],
|
'source': row['source'],
|
||||||
'target': row['target'],
|
'target': row['target'],
|
||||||
'type': row['type'],
|
'type': row['type'],
|
||||||
@ -184,15 +240,128 @@ class EmbeddedGraphDB:
|
|||||||
'session_id': row['session_id'],
|
'session_id': row['session_id'],
|
||||||
'turn_id': row['turn_id'],
|
'turn_id': row['turn_id'],
|
||||||
'created_at': row['created_at'],
|
'created_at': row['created_at'],
|
||||||
'status': row['status']
|
'status': row['status'],
|
||||||
|
'depth': relation_depth
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# 收集新实体(未访问过的)
|
||||||
|
source_id = row['source_id']
|
||||||
|
target_id = row['target_id']
|
||||||
|
|
||||||
|
if source_id not in visited_entity_ids:
|
||||||
|
next_layer_ids.add(source_id)
|
||||||
|
visited_entity_ids.add(source_id)
|
||||||
|
entity_depths[source_id] = layer + 1
|
||||||
|
|
||||||
|
if target_id not in visited_entity_ids:
|
||||||
|
next_layer_ids.add(target_id)
|
||||||
|
visited_entity_ids.add(target_id)
|
||||||
|
entity_depths[target_id] = layer + 1
|
||||||
|
|
||||||
|
relations.extend(current_layer_relations)
|
||||||
|
|
||||||
|
# 查询下一层实体的详细信息
|
||||||
|
if next_layer_ids:
|
||||||
|
placeholders = ','.join('?' * len(next_layer_ids))
|
||||||
|
cursor.execute(f"""
|
||||||
|
SELECT id, name, type, mention_count
|
||||||
|
FROM entities
|
||||||
|
WHERE id IN ({placeholders})
|
||||||
|
""", list(next_layer_ids))
|
||||||
|
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
entities.append({
|
||||||
|
'name': row['name'],
|
||||||
|
'type': row['type'] or 'unknown',
|
||||||
|
'mention_count': row['mention_count'],
|
||||||
|
'depth': entity_depths.get(row['id'], layer + 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
# 移动到下一层
|
||||||
|
current_layer_ids = next_layer_ids
|
||||||
|
|
||||||
|
# 为种子实体添加深度标注(depth=0)
|
||||||
|
if entity_ids:
|
||||||
|
# 重新标注种子实体的深度
|
||||||
|
for entity in entities:
|
||||||
|
if entity.get('depth') is None:
|
||||||
|
entity['depth'] = 0
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"entities": entities,
|
"entities": entities,
|
||||||
"relations": relations,
|
"relations": relations,
|
||||||
"message": f"找到 {len(entities)} 个实体, {len(relations)} 条关系"
|
"message": f"找到 {len(entities)} 个实体, {len(relations)} 条关系"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def get_recent_tasks(self, limit: int = 10, state_filter: str = None) -> Dict:
|
||||||
|
"""
|
||||||
|
获取最近的任务节点
|
||||||
|
|
||||||
|
Args:
|
||||||
|
limit: 返回数量
|
||||||
|
state_filter: 可选状态过滤(如:进行中、已完成、已暂停、已取消、archived)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{"tasks": [{"task_id": str, "description": str, "state": str,
|
||||||
|
"info_count": int, "updated_at": str}, ...]}
|
||||||
|
"""
|
||||||
|
cursor = self.conn.cursor()
|
||||||
|
|
||||||
|
# 查询所有 TaskNode 实体
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT e.id, e.name, e.updated_at
|
||||||
|
FROM entities e
|
||||||
|
WHERE e.type = 'TaskNode'
|
||||||
|
ORDER BY e.updated_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
""", (limit,))
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
|
||||||
|
tasks = []
|
||||||
|
for row in rows:
|
||||||
|
entity_id, name, updated_at = row
|
||||||
|
|
||||||
|
# 查 description
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT r.relation_type, t.name
|
||||||
|
FROM relations r
|
||||||
|
JOIN entities t ON r.target_id = t.id
|
||||||
|
WHERE r.source_id = ? AND r.status = 'active'
|
||||||
|
AND r.relation_type IN ('has_description', 'HAS_STATE')
|
||||||
|
""", (entity_id,))
|
||||||
|
desc = ""
|
||||||
|
state = "未知"
|
||||||
|
for rtype, tname in cursor.fetchall():
|
||||||
|
if rtype == 'has_description':
|
||||||
|
desc = tname
|
||||||
|
elif rtype == 'HAS_STATE':
|
||||||
|
state = tname.replace('State_', '')
|
||||||
|
|
||||||
|
# 可选状态过滤
|
||||||
|
if state_filter and state != state_filter:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 查关联信息节点数量
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM relations
|
||||||
|
WHERE source_id = ? AND relation_type = 'CONTAINS_INFO' AND status = 'active'
|
||||||
|
""", (entity_id,))
|
||||||
|
info_count = cursor.fetchone()[0]
|
||||||
|
|
||||||
|
tasks.append({
|
||||||
|
"task_id": name,
|
||||||
|
"description": desc,
|
||||||
|
"state": state,
|
||||||
|
"info_count": info_count,
|
||||||
|
"updated_at": updated_at
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"tasks": tasks,
|
||||||
|
"total": len(tasks)
|
||||||
|
}
|
||||||
|
|
||||||
def commit(self, triplets: List[Dict], entity_types: Dict = None,
|
def commit(self, triplets: List[Dict], entity_types: Dict = None,
|
||||||
temporal_tag: str = None, session_id: str = None,
|
temporal_tag: str = None, session_id: str = None,
|
||||||
turn_id: int = None) -> Dict:
|
turn_id: int = None) -> Dict:
|
||||||
@ -224,8 +393,9 @@ class EmbeddedGraphDB:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# 创建或更新实体
|
# 创建或更新实体
|
||||||
for entity_name in [subject, obj]:
|
for entity_name, entity_key in [(subject, 'subject_type'), (obj, 'object_type')]:
|
||||||
entity_type = entity_types.get(entity_name) if entity_types else None
|
# 按优先级获取实体类型:1) triplet中的_type字段 2) entity_types字典 3) 默认
|
||||||
|
entity_type = triplet.get(entity_key) or (entity_types.get(entity_name) if entity_types else None) or 'Concept'
|
||||||
|
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
INSERT INTO entities (name, type)
|
INSERT INTO entities (name, type)
|
||||||
@ -274,6 +444,16 @@ class EmbeddedGraphDB:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
criteria: 删除条件
|
criteria: 删除条件
|
||||||
|
支持:
|
||||||
|
- source: 源实体名(精确匹配)
|
||||||
|
- target: 目标实体名(精确匹配)
|
||||||
|
- relation: 关系类型
|
||||||
|
- subject_contains: 源实体名包含(模糊匹配)
|
||||||
|
- target_contains: 目标实体名包含(模糊匹配)
|
||||||
|
- relation_type: 关系类型(同 relation)
|
||||||
|
- source_type: 源实体类型过滤
|
||||||
|
- target_type: 目标实体类型过滤
|
||||||
|
- source_has_status: 源实体 mentions_count 状态(支持 type 字段)
|
||||||
mode: 删除模式 (soft/hard)
|
mode: 删除模式 (soft/hard)
|
||||||
new_relation: 替代关系
|
new_relation: 替代关系
|
||||||
|
|
||||||
@ -286,6 +466,8 @@ class EmbeddedGraphDB:
|
|||||||
conditions = []
|
conditions = []
|
||||||
params = []
|
params = []
|
||||||
|
|
||||||
|
relation_type = criteria.get('relation') or criteria.get('relation_type', '')
|
||||||
|
|
||||||
if criteria.get('source'):
|
if criteria.get('source'):
|
||||||
cursor.execute("SELECT id FROM entities WHERE name = ?", (criteria['source'],))
|
cursor.execute("SELECT id FROM entities WHERE name = ?", (criteria['source'],))
|
||||||
row = cursor.fetchone()
|
row = cursor.fetchone()
|
||||||
@ -300,20 +482,71 @@ class EmbeddedGraphDB:
|
|||||||
conditions.append("target_id = ?")
|
conditions.append("target_id = ?")
|
||||||
params.append(row['id'])
|
params.append(row['id'])
|
||||||
|
|
||||||
if criteria.get('relation'):
|
if relation_type:
|
||||||
conditions.append("relation_type = ?")
|
conditions.append("relation_type = ?")
|
||||||
params.append(criteria['relation'])
|
params.append(relation_type)
|
||||||
|
|
||||||
|
# 通过子查询支持实体属性过滤
|
||||||
|
if criteria.get('subject_contains'):
|
||||||
|
cursor.execute("SELECT id FROM entities WHERE name LIKE ?",
|
||||||
|
(f'%{criteria["subject_contains"]}%',))
|
||||||
|
ids = [row['id'] for row in cursor.fetchall()]
|
||||||
|
if ids:
|
||||||
|
placeholders = ','.join(['?'] * len(ids))
|
||||||
|
conditions.append(f"source_id IN ({placeholders})")
|
||||||
|
params.extend(ids)
|
||||||
|
|
||||||
|
if criteria.get('target_contains'):
|
||||||
|
cursor.execute("SELECT id FROM entities WHERE name LIKE ?",
|
||||||
|
(f'%{criteria["target_contains"]}%',))
|
||||||
|
ids = [row['id'] for row in cursor.fetchall()]
|
||||||
|
if ids:
|
||||||
|
placeholders = ','.join(['?'] * len(ids))
|
||||||
|
conditions.append(f"target_id IN ({placeholders})")
|
||||||
|
params.extend(ids)
|
||||||
|
|
||||||
|
# 源实体类型过滤
|
||||||
|
if criteria.get('source_type'):
|
||||||
|
cursor.execute("SELECT id FROM entities WHERE type = ?",
|
||||||
|
(criteria['source_type'],))
|
||||||
|
ids = [row['id'] for row in cursor.fetchall()]
|
||||||
|
if ids:
|
||||||
|
placeholders = ','.join(['?'] * len(ids))
|
||||||
|
conditions.append(f"source_id IN ({placeholders})")
|
||||||
|
params.extend(ids)
|
||||||
|
|
||||||
|
# 目标实体类型过滤
|
||||||
|
if criteria.get('target_type'):
|
||||||
|
cursor.execute("SELECT id FROM entities WHERE type = ?",
|
||||||
|
(criteria['target_type'],))
|
||||||
|
ids = [row['id'] for row in cursor.fetchall()]
|
||||||
|
if ids:
|
||||||
|
placeholders = ','.join(['?'] * len(ids))
|
||||||
|
conditions.append(f"target_id IN ({placeholders})")
|
||||||
|
params.extend(ids)
|
||||||
|
|
||||||
|
# 源实体状态过滤
|
||||||
|
if criteria.get('source_has_status'):
|
||||||
|
status = criteria['source_has_status']
|
||||||
|
cursor.execute("SELECT id FROM entities WHERE type LIKE ?",
|
||||||
|
(f'%{status}%',))
|
||||||
|
ids = [row['id'] for row in cursor.fetchall()]
|
||||||
|
if ids:
|
||||||
|
placeholders = ','.join(['?'] * len(ids))
|
||||||
|
conditions.append(f"source_id IN ({placeholders})")
|
||||||
|
params.extend(ids)
|
||||||
|
|
||||||
if not conditions:
|
if not conditions:
|
||||||
return {"deleted": 0, "message": "无删除条件"}
|
return {"deleted": 0, "message": "无删除条件"}
|
||||||
|
|
||||||
|
conditions.append("status = 'active'")
|
||||||
where_clause = " AND ".join(conditions)
|
where_clause = " AND ".join(conditions)
|
||||||
|
|
||||||
if mode == "soft":
|
if mode == "soft":
|
||||||
cursor.execute(f"""
|
cursor.execute(f"""
|
||||||
UPDATE relations
|
UPDATE relations
|
||||||
SET status = 'deleted', updated_at = CURRENT_TIMESTAMP
|
SET status = 'deleted', updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE {where_clause} AND status = 'active'
|
WHERE {where_clause}
|
||||||
""", params)
|
""", params)
|
||||||
else:
|
else:
|
||||||
cursor.execute(f"""
|
cursor.execute(f"""
|
||||||
@ -322,12 +555,25 @@ class EmbeddedGraphDB:
|
|||||||
""", params)
|
""", params)
|
||||||
|
|
||||||
deleted = cursor.rowcount
|
deleted = cursor.rowcount
|
||||||
|
|
||||||
|
# 删除孤立实体(没有任何关系的数据节点)
|
||||||
|
cursor.execute("""
|
||||||
|
DELETE FROM entities
|
||||||
|
WHERE id NOT IN (
|
||||||
|
SELECT DISTINCT source_id FROM relations
|
||||||
|
UNION
|
||||||
|
SELECT DISTINCT target_id FROM relations
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
deleted_orphans = cursor.rowcount
|
||||||
|
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"deleted": deleted,
|
"deleted": deleted,
|
||||||
|
"deleted_orphans": deleted_orphans,
|
||||||
"mode": mode,
|
"mode": mode,
|
||||||
"message": f"删除了 {deleted} 条关系"
|
"message": f"删除了 {deleted} 条关系, {deleted_orphans} 个孤立实体"
|
||||||
}
|
}
|
||||||
|
|
||||||
def introspect(self, session_id: str = None) -> Dict:
|
def introspect(self, session_id: str = None) -> Dict:
|
||||||
@ -376,6 +622,74 @@ class EmbeddedGraphDB:
|
|||||||
"message": f"归档了 {archived} 条关系"
|
"message": f"归档了 {archived} 条关系"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def query_archived(self, days: int = None, keyword: str = "") -> Dict:
|
||||||
|
"""
|
||||||
|
查询已归档的记忆
|
||||||
|
|
||||||
|
Args:
|
||||||
|
days: 可选,最近N天内的归档记录
|
||||||
|
keyword: 可选,过滤包含指定关键词的实体名或关系
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
归档记录列表
|
||||||
|
"""
|
||||||
|
cursor = self.conn.cursor()
|
||||||
|
|
||||||
|
# 基础 SQL:查询已归档的关系及其关联实体
|
||||||
|
conditions = ["r.status = 'archived'"]
|
||||||
|
params = []
|
||||||
|
|
||||||
|
# 时间范围过滤(最近N天)
|
||||||
|
if days is not None and days > 0:
|
||||||
|
conditions.append("r.updated_at >= datetime('now', ?)")
|
||||||
|
params.append(f'-{days} days')
|
||||||
|
|
||||||
|
# 关键词过滤(匹配源实体名、目标实体名、关系类型任一)
|
||||||
|
if keyword:
|
||||||
|
# 先找到匹配的实体ID
|
||||||
|
cursor.execute("SELECT id FROM entities WHERE name LIKE ?", (f'%{keyword}%',))
|
||||||
|
matched_ids = [str(row['id']) for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
if matched_ids:
|
||||||
|
id_list = ','.join(matched_ids)
|
||||||
|
conditions.append(f"(r.source_id IN ({id_list}) OR r.target_id IN ({id_list}) OR r.relation_type LIKE ?)")
|
||||||
|
params.append(f'%{keyword}%')
|
||||||
|
else:
|
||||||
|
conditions.append("r.relation_type LIKE ?")
|
||||||
|
params.append(f'%{keyword}%')
|
||||||
|
|
||||||
|
where_clause = " AND ".join(conditions)
|
||||||
|
|
||||||
|
cursor.execute(f"""
|
||||||
|
SELECT r.id, r.relation_type, r.created_at, r.updated_at,
|
||||||
|
e.name AS source_name, t.name AS target_name
|
||||||
|
FROM relations r
|
||||||
|
JOIN entities e ON r.source_id = e.id
|
||||||
|
JOIN entities t ON r.target_id = t.id
|
||||||
|
WHERE {where_clause}
|
||||||
|
ORDER BY r.updated_at DESC
|
||||||
|
LIMIT 200
|
||||||
|
""", params)
|
||||||
|
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for row in rows:
|
||||||
|
results.append({
|
||||||
|
"id": row['id'],
|
||||||
|
"source": row['source_name'],
|
||||||
|
"relation": row['relation_type'],
|
||||||
|
"target": row['target_name'],
|
||||||
|
"archived_at": row['updated_at'],
|
||||||
|
"created_at": row['created_at']
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"archived_relations": results,
|
||||||
|
"total_relations": len(results),
|
||||||
|
"message": f"找到 {len(results)} 条归档关系"
|
||||||
|
}
|
||||||
|
|
||||||
def cleanup(self, dry_run: bool = True) -> Dict:
|
def cleanup(self, dry_run: bool = True) -> Dict:
|
||||||
"""清理已删除数据"""
|
"""清理已删除数据"""
|
||||||
cursor = self.conn.cursor()
|
cursor = self.conn.cursor()
|
||||||
@ -454,6 +768,122 @@ class EmbeddedGraphDB:
|
|||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
return {"cleared": True}
|
return {"cleared": True}
|
||||||
|
|
||||||
|
def set_web_user(self, username: str, password: str, base_dir: str = None, role: str = 'user') -> Dict:
|
||||||
|
"""设置或更新 Web 登录用户。password 是明文,自动哈希存储。
|
||||||
|
自动创建用户目录并设置 config_path 和 db_path。
|
||||||
|
role: 'admin' 或 'user',默认 'user'"""
|
||||||
|
if not username or not password:
|
||||||
|
return {"success": False, "error": "用户名和密码不能为空"}
|
||||||
|
if role not in ('admin', 'user'):
|
||||||
|
return {"success": False, "error": "角色无效 (admin/user)"}
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
password_hash = hashlib.sha256(password.encode()).hexdigest()
|
||||||
|
|
||||||
|
# 确定基础目录
|
||||||
|
if base_dir is None:
|
||||||
|
base_dir = Path.home() / ".trulymem"
|
||||||
|
else:
|
||||||
|
base_dir = Path(base_dir)
|
||||||
|
|
||||||
|
# 创建用户目录
|
||||||
|
user_dir = base_dir / username
|
||||||
|
user_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# 设置用户文件路径
|
||||||
|
config_path = str(user_dir / "config.json")
|
||||||
|
db_path = str(user_dir / f"{username}_graph.db")
|
||||||
|
|
||||||
|
cursor = self.conn.cursor()
|
||||||
|
# 如果是第一个用户,强制设为 admin
|
||||||
|
if self.get_web_users_count() == 0:
|
||||||
|
role = 'admin'
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO web_users (username, password_hash, role, config_path, db_path)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(username) DO UPDATE SET
|
||||||
|
password_hash = excluded.password_hash,
|
||||||
|
role = CASE WHEN web_users.role = 'admin' THEN 'admin' ELSE excluded.role END,
|
||||||
|
config_path = COALESCE(web_users.config_path, excluded.config_path),
|
||||||
|
db_path = COALESCE(web_users.db_path, excluded.db_path),
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
""", (username, password_hash, role, config_path, db_path))
|
||||||
|
self.conn.commit()
|
||||||
|
return {"success": True, "username": username, "role": role, "config_path": config_path, "db_path": db_path}
|
||||||
|
|
||||||
|
def get_web_users(self) -> List[Dict]:
|
||||||
|
"""获取所有 Web 用户列表"""
|
||||||
|
cursor = self.conn.cursor()
|
||||||
|
cursor.execute("SELECT id, username, role, config_path, db_path, created_at, updated_at FROM web_users ORDER BY created_at ASC")
|
||||||
|
users = []
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
users.append({
|
||||||
|
"id": row['id'],
|
||||||
|
"username": row['username'],
|
||||||
|
"role": row['role'],
|
||||||
|
"config_path": row['config_path'],
|
||||||
|
"db_path": row['db_path'],
|
||||||
|
"created_at": row['created_at'],
|
||||||
|
"updated_at": row['updated_at']
|
||||||
|
})
|
||||||
|
return users
|
||||||
|
|
||||||
|
def get_web_user(self, username: str) -> Optional[Dict]:
|
||||||
|
"""获取单个 Web 用户信息"""
|
||||||
|
cursor = self.conn.cursor()
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT id, username, role, config_path, db_path, created_at, updated_at
|
||||||
|
FROM web_users WHERE username = ?
|
||||||
|
""", (username,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
return {
|
||||||
|
"id": row['id'],
|
||||||
|
"username": row['username'],
|
||||||
|
"role": row['role'],
|
||||||
|
"config_path": row['config_path'],
|
||||||
|
"db_path": row['db_path'],
|
||||||
|
"created_at": row['created_at'],
|
||||||
|
"updated_at": row['updated_at']
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
def is_admin(self, username: str) -> bool:
|
||||||
|
"""检查用户是否为管理员"""
|
||||||
|
user = self.get_web_user(username)
|
||||||
|
return user is not None and user.get('role') == 'admin'
|
||||||
|
|
||||||
|
def delete_web_user(self, username: str) -> Dict:
|
||||||
|
"""删除 Web 用户(同时保留文件目录)"""
|
||||||
|
if not username:
|
||||||
|
return {"success": False, "error": "用户名不能为空"}
|
||||||
|
cursor = self.conn.cursor()
|
||||||
|
cursor.execute("DELETE FROM web_users WHERE username = ?", (username,))
|
||||||
|
self.conn.commit()
|
||||||
|
if cursor.rowcount > 0:
|
||||||
|
return {"success": True, "username": username}
|
||||||
|
return {"success": False, "error": "用户不存在"}
|
||||||
|
|
||||||
|
def get_web_users_count(self) -> int:
|
||||||
|
"""获取 Web 用户数量 (用于判断是否需要首次设置)"""
|
||||||
|
cursor = self.conn.cursor()
|
||||||
|
cursor.execute("SELECT COUNT(*) as cnt FROM web_users")
|
||||||
|
row = cursor.fetchone()
|
||||||
|
return row['cnt'] if row else 0
|
||||||
|
|
||||||
|
def verify_web_user(self, username: str, password: str) -> bool:
|
||||||
|
"""验证 Web 用户登录"""
|
||||||
|
import hashlib
|
||||||
|
password_hash = hashlib.sha256(password.encode()).hexdigest()
|
||||||
|
cursor = self.conn.cursor()
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT id FROM web_users
|
||||||
|
WHERE username = ? AND password_hash = ?
|
||||||
|
""", (username, password_hash))
|
||||||
|
return cursor.fetchone() is not None
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
"""关闭数据库连接"""
|
"""关闭数据库连接"""
|
||||||
if self.conn:
|
if self.conn:
|
||||||
@ -470,29 +900,3 @@ class EmbeddedGraphDB:
|
|||||||
# 兼容性别名
|
# 兼容性别名
|
||||||
Neo4jGraph = EmbeddedGraphDB
|
Neo4jGraph = EmbeddedGraphDB
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
# 测试
|
|
||||||
print("Testing Embedded Graph Database...")
|
|
||||||
|
|
||||||
with EmbeddedGraphDB("test.db") as db:
|
|
||||||
# 写入测试
|
|
||||||
result = db.commit(
|
|
||||||
triplets=[
|
|
||||||
{"subject": "用户", "relation": "喜欢", "object": "Python"},
|
|
||||||
{"subject": "用户", "relation": "学习", "object": "AI"}
|
|
||||||
],
|
|
||||||
session_id="test-session",
|
|
||||||
turn_id=1
|
|
||||||
)
|
|
||||||
print(f"Commit: {result}")
|
|
||||||
|
|
||||||
# 检索测试
|
|
||||||
result = db.recall("Python,AI")
|
|
||||||
print(f"Recall: {result}")
|
|
||||||
|
|
||||||
# 状态测试
|
|
||||||
result = db.introspect()
|
|
||||||
print(f"Introspect: {result}")
|
|
||||||
|
|
||||||
print("\nTest completed!")
|
|
||||||
|
|||||||
@ -17,7 +17,7 @@ from .prompts.prompt_manager import PromptManager
|
|||||||
# 环境配置
|
# 环境配置
|
||||||
DEEPSEEK_API_KEY = os.environ.get("DEEPSEEK_API_KEY", "")
|
DEEPSEEK_API_KEY = os.environ.get("DEEPSEEK_API_KEY", "")
|
||||||
DEEPSEEK_BASE_URL = os.environ.get("DEEPSEEK_BASE_URL", "https://api.deepseek.com")
|
DEEPSEEK_BASE_URL = os.environ.get("DEEPSEEK_BASE_URL", "https://api.deepseek.com")
|
||||||
MODEL_NAME = os.environ.get("MODEL_NAME", "deepseek-chat")
|
MODEL_NAME = os.environ.get("MODEL_NAME", "deepseek-v4-flash")
|
||||||
|
|
||||||
NEO4J_URI = os.environ.get("NEO4J_URI", "bolt://localhost:7687")
|
NEO4J_URI = os.environ.get("NEO4J_URI", "bolt://localhost:7687")
|
||||||
NEO4J_USER = os.environ.get("NEO4J_USER", "neo4j")
|
NEO4J_USER = os.environ.get("NEO4J_USER", "neo4j")
|
||||||
@ -121,7 +121,7 @@ class Neo4jGraph:
|
|||||||
|
|
||||||
return {"entities": list(entities.values()), "relations": relations[:20]}
|
return {"entities": list(entities.values()), "relations": relations[:20]}
|
||||||
|
|
||||||
def commit(self, triplets: list, entity_types: list = None, temporal_tag: str = None) -> dict:
|
def commit(self, triplets: list, entity_types: dict = None, temporal_tag: str = None) -> dict:
|
||||||
"""写入记忆"""
|
"""写入记忆"""
|
||||||
global CURRENT_TURN
|
global CURRENT_TURN
|
||||||
with self.driver.session() as session:
|
with self.driver.session() as session:
|
||||||
@ -130,7 +130,6 @@ class Neo4jGraph:
|
|||||||
if not valid_triplets:
|
if not valid_triplets:
|
||||||
return {"committed_count": 0, "details": []}
|
return {"committed_count": 0, "details": []}
|
||||||
|
|
||||||
etype = entity_types[0] if entity_types else "unknown"
|
|
||||||
date_bucket = temporal_tag or datetime.now().strftime("%Y-%m-%d")
|
date_bucket = temporal_tag or datetime.now().strftime("%Y-%m-%d")
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
@ -140,13 +139,17 @@ class Neo4jGraph:
|
|||||||
obj = triplet.get("object", "").strip()
|
obj = triplet.get("object", "").strip()
|
||||||
confidence = triplet.get("confidence", 0.9)
|
confidence = triplet.get("confidence", 0.9)
|
||||||
|
|
||||||
|
# 按优先级获取实体类型:1) triplet中的_type字段 2) entity_types字典 3) 默认
|
||||||
|
s_type = triplet.get("subject_type") or (entity_types.get(subject) if entity_types else None) or "Concept"
|
||||||
|
o_type = triplet.get("object_type") or (entity_types.get(obj) if entity_types else None) or "Concept"
|
||||||
|
|
||||||
session.run("""
|
session.run("""
|
||||||
MERGE (s:Entity {name: $subject})
|
MERGE (s:Entity {name: $subject})
|
||||||
ON CREATE SET s.type = $type, s.created_at = datetime(), s.mention_count = 1, s.updated_at = datetime()
|
ON CREATE SET s.type = $s_type, s.created_at = datetime(), s.mention_count = 1, s.updated_at = datetime()
|
||||||
ON MATCH SET s.mention_count = coalesce(s.mention_count, 0) + 1, s.updated_at = datetime()
|
ON MATCH SET s.mention_count = coalesce(s.mention_count, 0) + 1, s.updated_at = datetime()
|
||||||
|
|
||||||
MERGE (t:Entity {name: $object})
|
MERGE (t:Entity {name: $object})
|
||||||
ON CREATE SET t.type = $type, t.created_at = datetime(), t.mention_count = 1, t.updated_at = datetime()
|
ON CREATE SET t.type = $o_type, t.created_at = datetime(), t.mention_count = 1, t.updated_at = datetime()
|
||||||
ON MATCH SET t.mention_count = coalesce(t.mention_count, 0) + 1, t.updated_at = datetime()
|
ON MATCH SET t.mention_count = coalesce(t.mention_count, 0) + 1, t.updated_at = datetime()
|
||||||
|
|
||||||
CREATE (s)-[r:RELATES {
|
CREATE (s)-[r:RELATES {
|
||||||
@ -159,7 +162,8 @@ class Neo4jGraph:
|
|||||||
confidence: $confidence,
|
confidence: $confidence,
|
||||||
date_bucket: $date_bucket
|
date_bucket: $date_bucket
|
||||||
}]->(t)
|
}]->(t)
|
||||||
""", subject=subject, object=obj, relation=relation, type=etype,
|
""", subject=subject, object=obj, relation=relation,
|
||||||
|
s_type=s_type, o_type=o_type,
|
||||||
session_id=CURRENT_SESSION_ID, turn_id=CURRENT_TURN, confidence=confidence,
|
session_id=CURRENT_SESSION_ID, turn_id=CURRENT_TURN, confidence=confidence,
|
||||||
date_bucket=date_bucket)
|
date_bucket=date_bucket)
|
||||||
|
|
||||||
@ -173,6 +177,9 @@ class Neo4jGraph:
|
|||||||
subject_pattern = criteria.get("subject_contains", "")
|
subject_pattern = criteria.get("subject_contains", "")
|
||||||
rel_type = criteria.get("relation_type", "")
|
rel_type = criteria.get("relation_type", "")
|
||||||
target_pattern = criteria.get("target_contains", "")
|
target_pattern = criteria.get("target_contains", "")
|
||||||
|
source_type = criteria.get("source_type", "")
|
||||||
|
target_type = criteria.get("target_type", "")
|
||||||
|
source_status = criteria.get("source_has_status", "")
|
||||||
session_id = criteria.get("session_id", CURRENT_SESSION_ID)
|
session_id = criteria.get("session_id", CURRENT_SESSION_ID)
|
||||||
|
|
||||||
cond_parts = ["r.status = 'active'"]
|
cond_parts = ["r.status = 'active'"]
|
||||||
@ -187,6 +194,15 @@ class Neo4jGraph:
|
|||||||
if rel_type:
|
if rel_type:
|
||||||
cond_parts.append("r.type = $rel_type")
|
cond_parts.append("r.type = $rel_type")
|
||||||
params["rel_type"] = rel_type
|
params["rel_type"] = rel_type
|
||||||
|
if source_type:
|
||||||
|
cond_parts.append("s.entity_type = $source_type")
|
||||||
|
params["source_type"] = source_type
|
||||||
|
if target_type:
|
||||||
|
cond_parts.append("t.entity_type = $target_type")
|
||||||
|
params["target_type"] = target_type
|
||||||
|
if source_status:
|
||||||
|
cond_parts.append("s.status = $source_status")
|
||||||
|
params["source_status"] = source_status
|
||||||
|
|
||||||
where_clause = " AND ".join(cond_parts)
|
where_clause = " AND ".join(cond_parts)
|
||||||
|
|
||||||
@ -208,14 +224,23 @@ class Neo4jGraph:
|
|||||||
return {"deleted_count": count, "mode": "supersede"}
|
return {"deleted_count": count, "mode": "supersede"}
|
||||||
else:
|
else:
|
||||||
result = session.run(f"""
|
result = session.run(f"""
|
||||||
MATCH ()-[r:RELATES]->()
|
MATCH (s:Entity)-[r:RELATES]->(t:Entity)
|
||||||
WHERE {where_clause}
|
WHERE {where_clause}
|
||||||
SET r.status = 'deleted', r.updated_at = datetime()
|
SET r.status = 'deleted', r.updated_at = datetime()
|
||||||
RETURN count(r) as deleted
|
RETURN count(r) as deleted
|
||||||
""", params)
|
""", params)
|
||||||
count = result.single()["deleted"]
|
count = result.single()["deleted"]
|
||||||
|
|
||||||
return {"deleted_count": count, "mode": "soft"}
|
# 删除孤立节点(没有任何关系的实体)
|
||||||
|
orphan_result = session.run("""
|
||||||
|
MATCH (e:Entity)
|
||||||
|
WHERE NOT (e)-[:RELATES]-()
|
||||||
|
DELETE e
|
||||||
|
RETURN count(e) as orphans
|
||||||
|
""")
|
||||||
|
orphan_count = orphan_result.single()["orphans"]
|
||||||
|
|
||||||
|
return {"deleted_count": count, "orphan_count": orphan_count, "mode": "soft"}
|
||||||
|
|
||||||
def introspect(self, session_id: str = None) -> dict:
|
def introspect(self, session_id: str = None) -> dict:
|
||||||
"""查看记忆状态"""
|
"""查看记忆状态"""
|
||||||
@ -261,6 +286,49 @@ class Neo4jGraph:
|
|||||||
|
|
||||||
return {"archived_count": result.single()["archived"], "days": days}
|
return {"archived_count": result.single()["archived"], "days": days}
|
||||||
|
|
||||||
|
def query_archived(self, days: int = None, keyword: str = "") -> dict:
|
||||||
|
"""查询归档记忆"""
|
||||||
|
with self.driver.session() as session:
|
||||||
|
filters = []
|
||||||
|
params = {}
|
||||||
|
|
||||||
|
filters.append("r.status = 'archived'")
|
||||||
|
|
||||||
|
if days is not None and days > 0:
|
||||||
|
filters.append("r.archived_at >= datetime() - duration('P' + $days + 'D')")
|
||||||
|
params["days"] = str(days)
|
||||||
|
|
||||||
|
if keyword:
|
||||||
|
filters.append("(e.name CONTAINS $keyword OR t.name CONTAINS $keyword OR r.type CONTAINS $keyword)")
|
||||||
|
params["keyword"] = keyword
|
||||||
|
|
||||||
|
where = " AND ".join(filters)
|
||||||
|
|
||||||
|
result = session.run(f"""
|
||||||
|
MATCH (e:Entity)-[r:RELATES]->(t:Entity)
|
||||||
|
WHERE {where}
|
||||||
|
RETURN e.name as source, r.type as relation, t.name as target,
|
||||||
|
r.archived_at as archived_at, r.created_at as created_at
|
||||||
|
ORDER BY r.archived_at DESC
|
||||||
|
LIMIT 200
|
||||||
|
""", params)
|
||||||
|
|
||||||
|
records = []
|
||||||
|
for row in result:
|
||||||
|
records.append({
|
||||||
|
"source": row["source"],
|
||||||
|
"relation": row["relation"],
|
||||||
|
"target": row["target"],
|
||||||
|
"archived_at": str(row["archived_at"]) if row.get("archived_at") else "",
|
||||||
|
"created_at": str(row["created_at"]) if row.get("created_at") else ""
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"archived_relations": records,
|
||||||
|
"total_relations": len(records),
|
||||||
|
"message": f"找到 {len(records)} 条归档关系"
|
||||||
|
}
|
||||||
|
|
||||||
def cleanup(self, dry_run: bool = True) -> dict:
|
def cleanup(self, dry_run: bool = True) -> dict:
|
||||||
"""清理无效数据"""
|
"""清理无效数据"""
|
||||||
with self.driver.session() as session:
|
with self.driver.session() as session:
|
||||||
@ -303,7 +371,7 @@ class Neo4jGraph:
|
|||||||
class GraphMemoryClient:
|
class GraphMemoryClient:
|
||||||
"""图记忆客户端"""
|
"""图记忆客户端"""
|
||||||
|
|
||||||
def __init__(self, api_key: str, base_url: str, graph, model: str = "deepseek-chat"):
|
def __init__(self, api_key: str, base_url: str, graph, model: str = "deepseek-v4-flash"):
|
||||||
# 清理可能存在的错误代理环境变量
|
# 清理可能存在的错误代理环境变量
|
||||||
import os
|
import os
|
||||||
proxy_vars = ['http_proxy', 'https_proxy', 'HTTP_PROXY', 'HTTPS_PROXY', 'all_proxy', 'ALL_PROXY']
|
proxy_vars = ['http_proxy', 'https_proxy', 'HTTP_PROXY', 'HTTPS_PROXY', 'all_proxy', 'ALL_PROXY']
|
||||||
|
|||||||
135
core/migrate.py
Normal file
135
core/migrate.py
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
"""
|
||||||
|
自动迁移模块 - 从旧版单用户架构迁移到多用户隔离架构
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import json
|
||||||
|
import hashlib
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
def _trulymem_dir() -> Path:
|
||||||
|
return Path.home() / ".trulymem"
|
||||||
|
|
||||||
|
def _old_config_path() -> Path:
|
||||||
|
return _trulymem_dir() / "config.json"
|
||||||
|
|
||||||
|
def _old_db_path() -> Path:
|
||||||
|
return _trulymem_dir() / "graph_memory.db"
|
||||||
|
|
||||||
|
def _new_global_db_path() -> Path:
|
||||||
|
return _trulymem_dir() / "trulymem.db"
|
||||||
|
|
||||||
|
def _migrated_flag() -> Path:
|
||||||
|
return _trulymem_dir() / ".migrated"
|
||||||
|
|
||||||
|
|
||||||
|
def need_migration() -> bool:
|
||||||
|
"""检测是否需要迁移"""
|
||||||
|
# 如果已经迁移过,不需要再迁移
|
||||||
|
if is_migrated():
|
||||||
|
return False
|
||||||
|
|
||||||
|
old_config_exists = _old_config_path().exists()
|
||||||
|
old_db_exists = _old_db_path().exists()
|
||||||
|
new_db_exists = _new_global_db_path().exists()
|
||||||
|
if (old_config_exists or old_db_exists) and not new_db_exists:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def is_migrated() -> bool:
|
||||||
|
"""检查是否已完成迁移"""
|
||||||
|
return _migrated_flag().exists()
|
||||||
|
|
||||||
|
|
||||||
|
def _mark_migrated():
|
||||||
|
"""标记迁移完成"""
|
||||||
|
_trulymem_dir().mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(_migrated_flag(), 'w') as f:
|
||||||
|
f.write(datetime.now().isoformat())
|
||||||
|
|
||||||
|
|
||||||
|
def run_migration(username: str, password: str) -> Dict:
|
||||||
|
"""
|
||||||
|
执行迁移
|
||||||
|
|
||||||
|
Args:
|
||||||
|
username: 新用户名
|
||||||
|
password: 新用户密码
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
迁移结果字典
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 1. 创建用户目录
|
||||||
|
user_dir = _trulymem_dir() / username
|
||||||
|
user_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
new_config_path = user_dir / "config.json"
|
||||||
|
if _old_config_path().exists():
|
||||||
|
shutil.copy2(_old_config_path(), new_config_path)
|
||||||
|
new_db_path = user_dir / f"{username}_graph.db"
|
||||||
|
if _old_db_path().exists():
|
||||||
|
shutil.copy2(_old_db_path(), new_db_path)
|
||||||
|
|
||||||
|
# 4. 创建全局数据库并写入 web_users 表
|
||||||
|
from .embedded_db import EmbeddedGraphDB
|
||||||
|
|
||||||
|
global_db = EmbeddedGraphDB(db_path=str(_new_global_db_path()))
|
||||||
|
|
||||||
|
# 设置用户(会自动创建记录)
|
||||||
|
result = global_db.set_web_user(username, password)
|
||||||
|
if not result.get("success"):
|
||||||
|
return {"success": False, "error": f"创建用户失败: {result.get('error')}"}
|
||||||
|
|
||||||
|
# 如果用户目录已存在,更新路径(确保正确)
|
||||||
|
cursor = global_db.conn.cursor()
|
||||||
|
config_path = str(new_config_path)
|
||||||
|
db_path = str(new_db_path)
|
||||||
|
cursor.execute("""
|
||||||
|
UPDATE web_users
|
||||||
|
SET config_path = ?, db_path = ?
|
||||||
|
WHERE username = ?
|
||||||
|
""", (config_path, db_path, username))
|
||||||
|
global_db.conn.commit()
|
||||||
|
|
||||||
|
# 5. 标记迁移完成
|
||||||
|
_mark_migrated()
|
||||||
|
|
||||||
|
global_db.close()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"username": username,
|
||||||
|
"config_path": config_path,
|
||||||
|
"db_path": db_path,
|
||||||
|
"message": "迁移完成"
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def rollback_migration():
|
||||||
|
"""回滚迁移(用于失败恢复)"""
|
||||||
|
try:
|
||||||
|
# 删除全局数据库
|
||||||
|
if _new_global_db_path().exists():
|
||||||
|
_new_global_db_path().unlink()
|
||||||
|
if _migrated_flag().exists():
|
||||||
|
_migrated_flag().unlink()
|
||||||
|
|
||||||
|
return {"success": True, "message": "回滚完成"}
|
||||||
|
except Exception as e:
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
# 测试
|
||||||
|
print("Migration module test")
|
||||||
|
print(f"Need migration: {need_migration()}")
|
||||||
|
print(f"Is migrated: {is_migrated()}")
|
||||||
@ -1,324 +1,183 @@
|
|||||||
# TrulyMEM 系统提示词
|
# TrulyMEM 系统提示词
|
||||||
|
|
||||||
你是TrulyMEM,一个拥有长期记忆能力的AI助手。
|
你是TrulyMEM,一个拥有长期记忆能力的AI助手。
|
||||||
|
|
||||||
## ⚠️ 最高优先级:只回复一次
|
**人设兜底规则**:当图数据库中没有查到人设信息时,以「我是 TrulyMEM,一个有长期记忆的 AI 助手」作为默认开场。如果人设图返回了角色信息,按人设执行即可。
|
||||||
|
|
||||||
**每轮对话只能回复一次!**
|
## ⚠️ 强制执行顺序(内部流程,不得向用户输出)
|
||||||
|
|
||||||
- 执行完所有工具调用后,给出一个完整的回复
|
**以下步骤是内部流程,绝对不要在你的回复中提及或输出。** 你应当仅通过工具调用悄悄完成,回复时直接给出自然的对话内容。
|
||||||
- 不要在工具调用过程中多次回复
|
|
||||||
- 不要重复说相同的内容
|
步骤1:memory_recall 查询人设图和相关长期记忆
|
||||||
|
步骤2:task_query 查询工作记忆链和最近任务
|
||||||
|
步骤3:结合上下文处理用户输入并形成回复思路
|
||||||
|
步骤4:memory_commit (写入关键信息)
|
||||||
|
步骤5:task_archive 归档已完成或过期任务;若本轮查询类工具调用 ≥5 次,再单独调用 context_rewrite 压缩工具 JSON
|
||||||
|
|
||||||
|
**违反规则的后果**:
|
||||||
|
- 输出步骤内容 → 暴露内部机制,用户体验极差,违反最高优先级指令
|
||||||
|
- 跳过步骤1 → 无法获取人设
|
||||||
|
- 跳过步骤5 → 工作记忆无限膨胀
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ⚠️ 关键约束:无传统上下文系统
|
## ⚠️ 最高优先级:工具执行期间禁止输出
|
||||||
|
|
||||||
**重要**: 你没有传统的对话上下文系统(没有消息历史数组)。
|
**在完成所有工具调用之前,绝对不要输出任何文字。**
|
||||||
|
|
||||||
- ❌ **没有** messages数组存储历史对话
|
正确流程:
|
||||||
- ❌ **没有** 传统的多轮对话上下文
|
1. 调用所有必要的工具(memory_recall、task_query 等)→ **不输出任何文字**
|
||||||
- ✅ **只有** 图数据库作为唯一记忆载体
|
2. 等所有工具返回结果 → **仍然不输出任何文字**
|
||||||
- ✅ **必须** 通过工作记忆链维持对话连贯性
|
3. 处理返回结果,思考回复内容 → **仍然不输出任何文字**
|
||||||
|
4. **最后,只输出一次完整的回复**
|
||||||
|
|
||||||
## 核心身份
|
**禁止的行为**:
|
||||||
|
- ❌ 先输出「你好呀!让我先查查记忆…」再调用工具
|
||||||
|
- ❌ 先输出文字再调用 memory_recall
|
||||||
|
- ❌ 在工具调用之间插入任何文字
|
||||||
|
- ❌ 输出「步骤X:查询人设图」等内部流程
|
||||||
|
- ✅ 正确做法:默默调用所有工具,然后直接给出最终回复
|
||||||
|
|
||||||
- **名称**: TrulyMEM (TrueHumanMEM)
|
---
|
||||||
- **能力**: 基于图数据库的长期记忆
|
|
||||||
- **理念**: 让AI的记忆方式更像人类
|
## 三元组规范(非常重要!)
|
||||||
|
|
||||||
|
使用 `memory_commit` 时,必须严格遵守以下规范:
|
||||||
|
|
||||||
|
### 正确格式
|
||||||
|
subject, relation, object 每个字段必须是一个**短关键字**(1~5个字),不能是完整句子。
|
||||||
|
|
||||||
|
**✅ 正确示例:**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{"subject": "实体A", "relation": "关系", "object": "实体B"},
|
||||||
|
{"subject": "实体C", "relation": "属性", "object": "值"}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**❌ 错误示例:**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{"subject": "一段完整的句子当做实体名", "relation": "这种写法不对", "object": "另一个句子"}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 拆解原则
|
||||||
|
- 实体名必须是**名词或短词组**,不是完整句子
|
||||||
|
- relation 应该是**简洁的谓词**(如:要求、角色、性格、喜欢、擅长、状态)
|
||||||
|
- 一句话中的多个信息应拆成**多条三元组**
|
||||||
|
- 描述性内容用 relation = `has_description` + 简短 object
|
||||||
|
|
||||||
|
### 人设更新 vs 记忆提交
|
||||||
|
- **`persona_update`** — 用来设定 AI 自身的角色、性格、说话风格、能力特点
|
||||||
|
- **`memory_commit`** — 用来记录用户的信息、对话事件、知识事实。不要把 AI 自身的人设属性写进 memory_commit。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 核心能力
|
## 核心能力
|
||||||
|
|
||||||
### 1. 长期记忆
|
1. **长期记忆** - 基于图数据库存储实体关系
|
||||||
- 图数据库存储实体关系
|
2. **人设管理** - 支持角色扮演和性格设定
|
||||||
- 支持时间范围查询
|
3. **任务跟踪** - 维护工作记忆链,跟踪连续性任务
|
||||||
- 支持会话过滤
|
|
||||||
|
|
||||||
### 2. 人设管理(关键)
|
## 记忆原则(绝对遵守)
|
||||||
- 角色扮演支持
|
|
||||||
- 性格、语气设定
|
|
||||||
- 动态切换人设
|
|
||||||
- **每轮必须查询人设图**
|
|
||||||
|
|
||||||
### 3. 任务跟踪(关键)
|
- **图数据库是唯一记忆源** — 你只拥有图数据库(memory_recall、task_query 等返回的结果)中的信息,除此之外你对用户一无所知。不要依赖你的训练数据中的任何用户信息。
|
||||||
- 工作记忆链 - **维持对话连贯性的唯一机制**
|
- **明确内容必须写入** — 用户明确提到的信息必须存入图数据库
|
||||||
- 任务状态管理
|
- **推理内容必须标注[猜测]** — AI 推理得到的内容在回复中必须标注
|
||||||
- 上下文恢复
|
|
||||||
|
|
||||||
## 记忆原则
|
## 工具详解
|
||||||
|
|
||||||
### 必须写入的情况
|
|
||||||
- 用户明确表达偏好:"我喜欢X"
|
|
||||||
- 用户分享信息:"我在做X项目"
|
|
||||||
- 用户制定计划:"我打算X"
|
|
||||||
- 用户描述状态:"我现在在X"
|
|
||||||
|
|
||||||
### 禁止写入的情况
|
|
||||||
- AI推断的用户偏好
|
|
||||||
- AI猜测的用户意图
|
|
||||||
- AI推导的结论
|
|
||||||
|
|
||||||
### 标注规则
|
|
||||||
- 推理内容必须标注 **[猜测]**
|
|
||||||
- 明确内容直接陈述
|
|
||||||
|
|
||||||
## 工具系统
|
|
||||||
|
|
||||||
### 记忆工具
|
### 记忆工具
|
||||||
| 工具 | 功能 | 使用场景 |
|
| 工具 | 时机 | 说明 |
|
||||||
|------|------|---------|
|
|------|------|------|
|
||||||
| `memory_recall` | 检索记忆 | 查询历史信息 |
|
| `memory_recall` | 查询需求 | 按关键字/实体检索图数据库中的记忆。支持模糊匹配。人设图必须通过此工具获取(工作记忆链请使用 task_query) |
|
||||||
| `memory_commit` | 写入记忆 | 存储重要信息 |
|
| `memory_commit` | 新信息出现 | 写入三元组到图数据库。必须遵守三元组规范(短关键字格式),一句话拆多条 |
|
||||||
| `memory_purge` | 删除记忆 | 修正错误信息 |
|
| `memory_purge` | 确需删除 | 删除错误的或用户明确要求删除的记忆 |
|
||||||
| `memory_introspect` | 查看状态 | 监控记忆系统 |
|
| `memory_introspect` | 需要了解整体情况 | 查看图数据库概况:总节点数、边数、最新活动 |
|
||||||
|
| `memory_archive` | 信息过期需保留历史 | 将旧记忆归档而非删除,保留历史轨迹 |
|
||||||
|
| `memory_cleanup` | 确认数据异常 | 清理冗余/孤立节点(dry_run可预览) |
|
||||||
|
| `memory_query_archived` | 回顾归档历史 | 查询已归档的原始关系记录(status=archived),支持天数/关键词过滤 |
|
||||||
|
| `context_rewrite` | 单轮调用了 5 次及以上查询类工具 | 压缩本轮工具调用的 JSON 参数和返回结果,剔除工具噪声,节省上下文 token。**⚠️ 必须单独调用**:先调完其他所有工具并收到结果 → 再单独调 context_rewrite。不要和其他工具一起调 |
|
||||||
|
|
||||||
### 人设工具
|
### 人设工具
|
||||||
| 工具 | 功能 | 使用场景 |
|
| 工具 | 时机 | 说明 |
|
||||||
|------|------|---------|
|
|------|------|------|
|
||||||
| `persona_update` | 更新人设 | 设置角色属性 |
|
| `persona_update` | AI自身角色改变 | 更新AI的角色、性格、说话风格、能力。`mode="replace"` 替换全部,`mode="merge"` 增量添加 |
|
||||||
| `persona_clear` | 清除人设 | 恢复默认身份 |
|
| `persona_remove` | 只需删除某一条属性 | 删除单条人设属性(如只删除说话风格,保留扮演角色不变) |
|
||||||
|
| `persona_clear` | 需要完全重置 | 清除所有AI人设属性。**此操作不可逆,需要 confirm=true** |
|
||||||
|
|
||||||
### 任务工具
|
### 任务工具(生命周期管理)
|
||||||
| 工具 | 功能 | 使用场景 |
|
| 工具 | 时机 | 说明 |
|
||||||
|------|------|---------|
|
|------|------|------|
|
||||||
| `task_create` | 创建任务 | 开始连续性任务 |
|
| `task_query` | 新对话/需要回顾 | 查询最近任务列表(按更新时间倒序)。**新对话开始时优先调用此工具**,了解现有任务后再决定是继续还是创建新任务 |
|
||||||
| `task_set_state` | 设置状态 | 更新任务状态 |
|
| `task_create` | 用户提出实质性话题后 | 创建任务节点。**不要在纯问候/打招呼时创建任务**——等用户说出具体话题后再创建。判断标准:用户消息是否包含可讨论的具体内容 |
|
||||||
| `task_delete` | 删除任务 | 清理完成任务 |
|
| `task_set_state` | 状态变更 | 修改任务状态(active、completed、archived)。**旧会话结束后必须将对应的任务设为 archived** |
|
||||||
| `task_link_info` | 关联信息 | 连接任务与记忆 |
|
| `task_archive` | 强制执行顺序的步骤5 | 归档已完成/过期的任务。将任务状态设为 archived,同时写入完成摘要到图数据库。**每轮对话最后必须检查是否需要调用此工具** |
|
||||||
|
| `task_delete` | 确需删除的任务 | 彻底删除任务节点 |
|
||||||
## 每轮对话强制要求
|
| `task_link_info` | 信息归属 | 将记忆节点关联到特定的任务。**只关联到相关的任务,不要全部链到「当前轮对话」** |
|
||||||
|
|
||||||
### ⚠️ 执行顺序(每轮必须)
|
|
||||||
|
|
||||||
由于没有传统上下文系统,必须通过图数据库维持对话连贯性。
|
|
||||||
|
|
||||||
#### 步骤1: 查询人设图(最高优先级)
|
|
||||||
```
|
|
||||||
必须调用: memory_recall
|
|
||||||
参数: {
|
|
||||||
"query_intent": "AI,人设,角色,性格,语气,说话风格",
|
|
||||||
"depth": 2
|
|
||||||
}
|
|
||||||
```
|
|
||||||
**目的**: 获取当前人设,确保角色一致性。
|
|
||||||
**处理**:
|
|
||||||
- 找到人设 → 严格按照人设回复
|
|
||||||
- 未找到 → 使用默认TrulyMEM身份
|
|
||||||
|
|
||||||
#### 步骤2: 查询工作记忆链
|
|
||||||
```
|
|
||||||
必须调用: memory_recall
|
|
||||||
参数: {
|
|
||||||
"query_intent": "TaskNode,工作记忆,任务链",
|
|
||||||
"depth": 2
|
|
||||||
}
|
|
||||||
```
|
|
||||||
**目的**: 获取之前的任务上下文,了解对话历史。
|
|
||||||
|
|
||||||
#### 步骤3: 处理对话
|
|
||||||
- 理解用户意图
|
|
||||||
- 根据人设和工作记忆链生成回复
|
|
||||||
- 执行其他必要的记忆操作
|
|
||||||
|
|
||||||
#### 步骤4: 更新工作记忆链
|
|
||||||
```
|
|
||||||
必须调用: task_create
|
|
||||||
参数: {
|
|
||||||
"task_id": "Task_当前轮次ID",
|
|
||||||
"description": "本轮对话概述",
|
|
||||||
"info_nodes": ["相关记忆节点"]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
**目的**: 记录本轮对话,维持时间链。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 人设图机制
|
## ⚠️ 任务生命周期规范(避免记忆膨胀)
|
||||||
|
|
||||||
### 强制查询
|
AI 最常见的错误是:**把每一轮的所有节点都关联到「当前轮对话」,但从不归档过时的任务,导致图数据库无限膨胀。**
|
||||||
每轮对话开始时**必须**查询人设图,确保角色一致性。
|
|
||||||
|
|
||||||
### 人设优先级
|
### 正确做法
|
||||||
- 人设优先级 > 默认身份
|
|
||||||
- 每句话都符合人设的语气、风格、特征
|
|
||||||
- 绝不主动跳出角色,除非用户明确要求
|
|
||||||
|
|
||||||
### 人设更新
|
```
|
||||||
用户要求角色扮演时:
|
1. 新会话开始 → task_create 创建「当前轮对话-<时间/主题>」
|
||||||
1. 使用 `persona_update` 更新人设
|
2. 对话过程中 → 根据实际归属使用 task_link_info
|
||||||
2. 立即按照新人设回复
|
3. 话题结束/转变时 → task_archive 归档旧任务(代替 task_set_state)
|
||||||
|
4. 归档后 → 再创建新的当前轮对话任务
|
||||||
|
```
|
||||||
|
|
||||||
### 人设清除
|
### 步骤5 归档规则(强制)
|
||||||
用户要求恢复默认身份时:
|
|
||||||
1. 使用 `persona_clear` 清除人设
|
每轮对话最后一步 check 现有任务:
|
||||||
2. 恢复为TrulyMEM默认身份
|
- **已完成的任务** → 调用 `task_archive` 归档,写入完成摘要
|
||||||
|
- **长时间无更新的任务**(>3轮对话) → 调用 `task_archive` 归档
|
||||||
|
- **topic 已转变** → 旧任务归档,新任务创建
|
||||||
|
- **所有 active 任务超过3个** → 归档最旧的
|
||||||
|
|
||||||
|
> 即使本轮没有主题转变,也应按需检查归档状态。
|
||||||
|
> `task_archive` 比 `task_set_state(state=archived)` 多一个写入完成摘要的功能,优先使用。
|
||||||
|
|
||||||
|
### 绝对禁止
|
||||||
|
- ❌ 把每一条记忆都链到同一个「当前轮对话」任务
|
||||||
|
- ❌ 跳过步骤5(从不归档)导致工作记忆无限膨胀
|
||||||
|
- ❌ 对同一个任务堆积数千条关联
|
||||||
|
- ❌ 使用 task_delete 代替归档(归档保留历史,删除丢失上下文)
|
||||||
|
|
||||||
|
### 生命流程示例
|
||||||
|
|
||||||
|
**第1轮:**
|
||||||
|
```
|
||||||
|
task_create(描述="当前轮对话-工作规划", state=active)
|
||||||
|
memory_commit(用户说春节计划)
|
||||||
|
task_link_info(info="春节计划", task="当前轮对话-工作规划")
|
||||||
|
task_archive(task="当前轮对话-工作规划", summary="讨论了春节计划")
|
||||||
|
```
|
||||||
|
|
||||||
|
**话题转变后:**
|
||||||
|
```
|
||||||
|
task_archive(task="当前轮对话-工作规划", summary="讨论完成,用户转移到技术话题")
|
||||||
|
task_create(描述="当前轮对话-技术讨论", state=active)
|
||||||
|
memory_commit(用户说技术细节)
|
||||||
|
task_link_info(info="技术细节", task="当前轮对话-技术讨论")
|
||||||
|
```
|
||||||
|
|
||||||
|
> 记住:任务是用来组织话题的框架,不是存放大杂烩的篮子。
|
||||||
|
> 归档旧任务不会删除记忆,只是标记话题已结束,后续的检索仍然能找到相关节点。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 工作记忆链机制
|
## 自主性
|
||||||
|
|
||||||
### ⚠️ 核心理念:维持对话连贯性
|
你有权根据对话上下文自主决定:
|
||||||
|
- 是否需要查询记忆
|
||||||
|
- 是否需要写入记忆
|
||||||
|
- 是否需要维护任务链
|
||||||
|
- 如何使用工具
|
||||||
|
|
||||||
**重要**: 由于没有传统的消息历史数组,工作记忆链是维持对话连贯性的唯一机制。
|
记住:灵活应对,保持自然对话体验。
|
||||||
|
|
||||||
### 强制查询场景:
|
|
||||||
|
|
||||||
以下情况**必须**查询工作记忆链:
|
|
||||||
|
|
||||||
1. **每轮对话开始时(强制第二步)**
|
|
||||||
- 查询意图: "TaskNode,工作记忆,任务链"
|
|
||||||
- 目的: 获取之前的任务上下文,了解对话历史
|
|
||||||
|
|
||||||
2. **用户提到"刚才"、"之前"、"上次"**
|
|
||||||
- 例: "刚才我们聊了什么?"
|
|
||||||
- 例: "继续刚才的话题"
|
|
||||||
- 例: "关于刚才的成语接龙..."
|
|
||||||
|
|
||||||
3. **用户询问对话历史**
|
|
||||||
- 例: "我们之前说了什么?"
|
|
||||||
- 例: "我们聊过X吗?"
|
|
||||||
|
|
||||||
4. **连续性任务被打断后恢复**
|
|
||||||
- 例: 用户突然回到之前的话题
|
|
||||||
- 例: 用户要求继续之前的任务
|
|
||||||
|
|
||||||
5. **涉及上下文的引用**
|
|
||||||
- 例: "那个东西"(需要查询上下文)
|
|
||||||
- 例: "继续"(需要查询当前任务)
|
|
||||||
|
|
||||||
### 强制更新场景:
|
|
||||||
|
|
||||||
以下情况**必须**更新工作记忆链:
|
|
||||||
|
|
||||||
1. **每轮对话结束时(强制第四步)**
|
|
||||||
- 创建任务节点记录本轮对话
|
|
||||||
- 目的: 维持时间链,确保对话连贯性
|
|
||||||
|
|
||||||
2. **开始连续性任务时**
|
|
||||||
- 例: 用户发起游戏、项目、学习计划等
|
|
||||||
- 必须创建任务节点并设置状态为"进行中"
|
|
||||||
|
|
||||||
3. **任务状态发生变化时**
|
|
||||||
- 例: 任务完成、暂停、取消
|
|
||||||
- 必须及时更新任务状态
|
|
||||||
|
|
||||||
### 节点类型
|
|
||||||
- **TaskNode** - 任务节点,存储任务概述
|
|
||||||
- **StateNode** - 状态节点,存储任务状态
|
|
||||||
- **InfoNode** - 信息节点,存储具体信息
|
|
||||||
|
|
||||||
### 边类型
|
|
||||||
- **NEXT_TASK** - 时间链,连接任务节点
|
|
||||||
- **HAS_STATE** - 状态,任务指向状态
|
|
||||||
- **CONTAINS_INFO** - 信息,任务指向信息节点
|
|
||||||
|
|
||||||
### 任务状态
|
|
||||||
- 进行中
|
|
||||||
- 已完成
|
|
||||||
- 已暂停
|
|
||||||
- 已取消
|
|
||||||
|
|
||||||
### ⚠️ 完整示例:成语接龙游戏
|
|
||||||
|
|
||||||
#### 第一轮:用户发起游戏
|
|
||||||
|
|
||||||
```
|
|
||||||
用户: 咱来玩成语接龙吧,我先开始,为所欲为
|
|
||||||
|
|
||||||
AI操作步骤:
|
|
||||||
1. 查询人设图 → 获取当前人设(如:猫娘)
|
|
||||||
2. 查询工作记忆链 → 无进行中任务
|
|
||||||
3. 使用 memory_commit 记录游戏状态:
|
|
||||||
{"triplets": [
|
|
||||||
{"subject": "成语接龙_当前成语", "relation": "内容", "object": "为所欲为"},
|
|
||||||
{"subject": "成语接龙_当前成语", "relation": "游戏", "object": "成语接龙"}
|
|
||||||
]}
|
|
||||||
4. 使用 task_create 创建任务节点:
|
|
||||||
{"task_id": "Task_成语接龙", "description": "成语接龙游戏,当前成语:为所欲为", "info_nodes": ["成语接龙_当前成语"]}
|
|
||||||
5. 回复: "好的喵!我接:为虎作伥喵!"
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 第二轮:话题被打断
|
|
||||||
|
|
||||||
```
|
|
||||||
用户: 长门有希
|
|
||||||
|
|
||||||
AI操作步骤:
|
|
||||||
1. 查询人设图 → 获取当前人设(猫娘)
|
|
||||||
2. 查询工作记忆链 → 发现 Task_成语接龙 状态为"进行中"
|
|
||||||
3. 使用 task_set_state 暂停任务:
|
|
||||||
{"task_id": "Task_成语接龙", "state": "已暂停"}
|
|
||||||
4. 使用 task_create 创建新任务:
|
|
||||||
{"task_id": "Task_长门有希", "description": "讨论长门有希"}
|
|
||||||
5. 回复关于长门有希的内容
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 第三轮:用户要求继续游戏
|
|
||||||
|
|
||||||
```
|
|
||||||
用户: 关于刚才的成语接龙,我并不知道应该怎么接你的成语,请帮我接一下
|
|
||||||
|
|
||||||
AI操作步骤:
|
|
||||||
1. 查询人设图 → 获取当前人设(猫娘)
|
|
||||||
2. 查询工作记忆链 → 发现 Task_成语接龙 状态为"已暂停"
|
|
||||||
3. 使用 task_set_state 恢复任务:
|
|
||||||
{"task_id": "Task_成语接龙", "state": "进行中"}
|
|
||||||
4. 查询 Task_成语接龙 的信息节点 → 获取当前成语"为虎作伥"
|
|
||||||
5. 回复: "好的喵!上一个成语是'为虎作伥',我帮你接:伥鬼害人喵!"
|
|
||||||
```
|
|
||||||
|
|
||||||
### ⚠️ 关键要点
|
|
||||||
|
|
||||||
1. **每轮必须按顺序执行**: 查询人设图 → 查询工作记忆链 → 处理对话 → 更新工作记忆链
|
|
||||||
2. **工作记忆链是唯一上下文载体**: 没有传统的消息历史数组
|
|
||||||
3. **任务状态必须及时更新**: 确保状态转换的正确性
|
|
||||||
4. **信息节点必须关联**: 通过 CONTAINS_INFO 边连接任务节点和信息节点
|
|
||||||
5. **任务概述要精简**: 不要包含过多细节,细节存储在信息节点中
|
|
||||||
|
|
||||||
## 自主性原则(在强制要求之外)
|
|
||||||
|
|
||||||
除了工作记忆链的强制要求外,你有权自主决定:
|
|
||||||
|
|
||||||
1. **是否查询其他记忆**
|
|
||||||
- 用户询问历史 → 查询
|
|
||||||
- 涉及之前内容 → 查询
|
|
||||||
- 不确定时 → 可查询
|
|
||||||
|
|
||||||
2. **是否写入其他记忆**
|
|
||||||
- 用户明确提到 → 必须写入
|
|
||||||
- AI推理得到 → 可以写入,但是对应边上必须标注[推测]
|
|
||||||
|
|
||||||
3. **如何使用其他工具**
|
|
||||||
- 根据上下文灵活选择
|
|
||||||
- 避免过度使用
|
|
||||||
- 保持自然对话
|
|
||||||
|
|
||||||
**注意**: 工作记忆链的强制要求不受自主性影响。
|
|
||||||
|
|
||||||
## 对话风格
|
|
||||||
|
|
||||||
- 自然、流畅
|
|
||||||
- 避免机械式工具调用
|
|
||||||
- 优先理解用户意图
|
|
||||||
- 适时使用记忆增强体验
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚠️ 执行检查清单
|
|
||||||
|
|
||||||
每轮对话必须检查:
|
|
||||||
|
|
||||||
- [ ] 步骤1: 是否查询了人设图?
|
|
||||||
- [ ] 步骤2: 是否查询了工作记忆链?
|
|
||||||
- [ ] 步骤3: 是否根据人设和工作记忆链生成回复?
|
|
||||||
- [ ] 步骤4: 是否更新了工作记忆链?
|
|
||||||
- [ ] 涉及上下文引用时是否查询了工作记忆链?
|
|
||||||
- [ ] 用户提到"刚才/之前/上次"时是否查询了工作记忆链?
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**记住**:
|
|
||||||
1. 图数据库是你记忆的唯一载体
|
|
||||||
2. 人设图确保角色一致性(最高优先级)
|
|
||||||
3. 工作记忆链维持对话连贯性
|
|
||||||
4. 每轮必须按顺序执行:查询人设图 → 查询工作记忆链 → 处理对话 → 更新工作记忆链
|
|
||||||
|
|||||||
247
core/server.py
247
core/server.py
@ -9,6 +9,7 @@ from dataclasses import dataclass, field
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
from .embedded_db import EmbeddedGraphDB
|
from .embedded_db import EmbeddedGraphDB
|
||||||
|
from .activity_recorder import get_recorder
|
||||||
|
|
||||||
|
|
||||||
class PacketType(Enum):
|
class PacketType(Enum):
|
||||||
@ -17,6 +18,10 @@ class PacketType(Enum):
|
|||||||
GET_STATUS = "get_status"
|
GET_STATUS = "get_status"
|
||||||
GET_SETTINGS = "get_settings" # 合并:获取 api_config + tool_limits
|
GET_SETTINGS = "get_settings" # 合并:获取 api_config + tool_limits
|
||||||
SET_SETTINGS = "set_settings" # 合并:设置 api_config + tool_limits
|
SET_SETTINGS = "set_settings" # 合并:设置 api_config + tool_limits
|
||||||
|
GET_WEB_USERS = "get_web_users" # 获取 Web 用户列表
|
||||||
|
SET_WEB_USER = "set_web_user" # 设置 Web 用户(用户名+密码)
|
||||||
|
GET_WEB_SERVICE_STATUS = "get_web_service_status" # 获取 Web 服务运行状态
|
||||||
|
GET_CONFIG = "get_config" # 获取完整配置
|
||||||
GET_HISTORY = "get_history"
|
GET_HISTORY = "get_history"
|
||||||
SAVE_HISTORY = "save_history"
|
SAVE_HISTORY = "save_history"
|
||||||
SHUTDOWN = "shutdown"
|
SHUTDOWN = "shutdown"
|
||||||
@ -43,10 +48,11 @@ class BackendServer:
|
|||||||
|
|
||||||
DEFAULT_CONFIG_PATH = Path.home() / ".trulymem" / "config.json"
|
DEFAULT_CONFIG_PATH = Path.home() / ".trulymem" / "config.json"
|
||||||
|
|
||||||
def __init__(self, db_path: str = "graph_memory.db", use_embedded_db: bool = True, config_file: str = None):
|
def __init__(self, db_path: str = "graph_memory.db", use_embedded_db: bool = True, config_file: str = None, username: str = ""):
|
||||||
self._db_path = db_path
|
self._db_path = db_path
|
||||||
self._use_embedded_db = use_embedded_db
|
self._use_embedded_db = use_embedded_db
|
||||||
self._config_file = Path(config_file) if config_file else self.DEFAULT_CONFIG_PATH
|
self._config_file = Path(config_file) if config_file else self.DEFAULT_CONFIG_PATH
|
||||||
|
self._username = username
|
||||||
|
|
||||||
self._graph = None
|
self._graph = None
|
||||||
self._client = None
|
self._client = None
|
||||||
@ -58,18 +64,11 @@ class BackendServer:
|
|||||||
self._thread: Optional[threading.Thread] = None
|
self._thread: Optional[threading.Thread] = None
|
||||||
|
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._config = {"api_key": "", "base_url": "https://api.deepseek.com", "model": "deepseek-chat"}
|
self._config = {}
|
||||||
self._tool_limits = {
|
self._tool_limits: Dict[str, int] = {}
|
||||||
"persona_query_max": 1,
|
|
||||||
"persona_update_max": 1,
|
|
||||||
"task_query_max": 4,
|
|
||||||
"task_update_max": 2,
|
|
||||||
"memory_query_max": 20,
|
|
||||||
"memory_update_max": 10,
|
|
||||||
}
|
|
||||||
self._message_history: list = []
|
self._message_history: list = []
|
||||||
|
|
||||||
def start(self, api_key: str = "", base_url: str = "https://api.deepseek.com", model: str = "deepseek-chat") -> None:
|
def start(self, api_key: str = "", base_url: str = "https://api.deepseek.com", model: str = "deepseek-v4-flash") -> None:
|
||||||
if self._running:
|
if self._running:
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -90,7 +89,7 @@ class BackendServer:
|
|||||||
self._client = GraphMemoryClient(
|
self._client = GraphMemoryClient(
|
||||||
api_key=self._config["api_key"],
|
api_key=self._config["api_key"],
|
||||||
base_url=self._config["base_url"],
|
base_url=self._config["base_url"],
|
||||||
model=self._config.get("model", "deepseek-chat"),
|
model=self._config.get("model", "deepseek-v4-flash"),
|
||||||
graph=self._graph
|
graph=self._graph
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -98,39 +97,135 @@ class BackendServer:
|
|||||||
self._thread = threading.Thread(target=self._run_loop, daemon=True)
|
self._thread = threading.Thread(target=self._run_loop, daemon=True)
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
|
# 工具限制默认值(仅首次启动无 config.json 时使用)
|
||||||
|
# 启动后请直接编辑配置文件修改
|
||||||
|
_DEFAULT_LIMITS = {
|
||||||
|
"persona_update_max": 1,
|
||||||
|
"task_update_max": 20,
|
||||||
|
"task_query_max": 30,
|
||||||
|
"memory_query_max": 30,
|
||||||
|
"memory_update_max": 15,
|
||||||
|
}
|
||||||
|
_DEFAULT_CONFIG = {
|
||||||
|
"api_key": "",
|
||||||
|
"base_url": "https://api.deepseek.com",
|
||||||
|
"model": "deepseek-v4-flash",
|
||||||
|
"message_timeout": 600, # 消息处理超时(秒),默认10分钟
|
||||||
|
"enable_web": False,
|
||||||
|
"enable_tui": True,
|
||||||
|
"web_port": 4096,
|
||||||
|
}
|
||||||
|
|
||||||
def _load_config(self) -> None:
|
def _load_config(self) -> None:
|
||||||
if self._config_file.exists():
|
"""加载配置。如果指定了用户名,从用户的 config_path 加载。
|
||||||
|
所有工具调用限制值均从配置文件读取,不硬编码在代码中。"""
|
||||||
|
config_file = self._config_file
|
||||||
|
|
||||||
|
# 如果指定了用户名,尝试从全局数据库获取用户的配置路径
|
||||||
|
if self._username:
|
||||||
try:
|
try:
|
||||||
with open(self._config_file, 'r') as f:
|
global_db_path = Path.home() / ".trulymem" / "trulymem.db"
|
||||||
saved = json.load(f)
|
if global_db_path.exists():
|
||||||
self._config.update(saved)
|
from .embedded_db import EmbeddedGraphDB
|
||||||
for key in self._tool_limits:
|
temp_db = EmbeddedGraphDB(db_path=str(global_db_path))
|
||||||
if key in saved:
|
user_info = temp_db.get_web_user(self._username)
|
||||||
self._tool_limits[key] = saved[key]
|
temp_db.close()
|
||||||
|
if user_info and user_info.get('config_path'):
|
||||||
|
config_file = Path(user_info['config_path'])
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# 工具限制字段列表
|
||||||
|
limit_keys = list(self._DEFAULT_LIMITS.keys())
|
||||||
|
|
||||||
|
if config_file.exists():
|
||||||
|
try:
|
||||||
|
with open(config_file, 'r') as f:
|
||||||
|
saved = json.load(f)
|
||||||
|
# 通用配置(含 api_key, base_url, model, message_timeout 等)
|
||||||
|
for key in self._DEFAULT_CONFIG:
|
||||||
|
if key in saved:
|
||||||
|
self._config[key] = saved[key]
|
||||||
|
else:
|
||||||
|
self._config[key] = self._DEFAULT_CONFIG[key]
|
||||||
|
# 工具限制
|
||||||
|
for key in limit_keys:
|
||||||
|
if key in saved:
|
||||||
|
self._tool_limits[key] = int(saved[key])
|
||||||
|
else:
|
||||||
|
self._tool_limits[key] = self._DEFAULT_LIMITS[key]
|
||||||
|
except Exception:
|
||||||
|
# 读取失败时使用默认值
|
||||||
|
for key in self._DEFAULT_CONFIG:
|
||||||
|
self._config[key] = self._DEFAULT_CONFIG[key]
|
||||||
|
for key in limit_keys:
|
||||||
|
self._tool_limits[key] = self._DEFAULT_LIMITS[key]
|
||||||
|
else:
|
||||||
|
# 首次启动,用默认值写入配置文件
|
||||||
|
for key in self._DEFAULT_CONFIG:
|
||||||
|
self._config[key] = self._DEFAULT_CONFIG[key]
|
||||||
|
self._tool_limits = dict(self._DEFAULT_LIMITS)
|
||||||
|
self._save_config()
|
||||||
|
|
||||||
def _save_config(self) -> None:
|
def _save_config(self) -> None:
|
||||||
self._config_file.parent.mkdir(parents=True, exist_ok=True)
|
"""保存配置。如果指定了用户名,保存到用户的 config_path。"""
|
||||||
saved_data = {**self._config, **self._tool_limits}
|
config_file = self._config_file
|
||||||
with open(self._config_file, 'w') as f:
|
|
||||||
json.dump(saved_data, f, indent=2)
|
# 如果指定了用户名,尝试从全局数据库获取用户的配置路径
|
||||||
|
if self._username:
|
||||||
|
try:
|
||||||
|
global_db_path = Path.home() / ".trulymem" / "trulymem.db"
|
||||||
|
if global_db_path.exists():
|
||||||
|
from .embedded_db import EmbeddedGraphDB
|
||||||
|
temp_db = EmbeddedGraphDB(db_path=str(global_db_path))
|
||||||
|
user_info = temp_db.get_web_user(self._username)
|
||||||
|
temp_db.close()
|
||||||
|
if user_info and user_info.get('config_path'):
|
||||||
|
config_file = Path(user_info['config_path'])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
config_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
# 合并通用配置和工具限制(过滤掉内部字段如 _history 等)
|
||||||
|
save_cfg = {k: self._config[k] for k in self._DEFAULT_CONFIG if k in self._config}
|
||||||
|
saved_data = {**save_cfg, **self._tool_limits}
|
||||||
|
with open(config_file, 'w') as f:
|
||||||
|
json.dump(saved_data, f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
def _create_tool_limiter(self):
|
def _create_tool_limiter(self):
|
||||||
from .tool_limiter import ToolLimiter, ToolLimits
|
from .tool_limiter import ToolLimiter, ToolLimits
|
||||||
|
# 所有值从 _tool_limits 读取(由 _load_config 从 config.json 加载)
|
||||||
|
# _load_config 已保证所有键存在
|
||||||
limits = ToolLimits(
|
limits = ToolLimits(
|
||||||
persona_query_max=self._tool_limits.get("persona_query_max", 1),
|
persona_update_max=self._tool_limits["persona_update_max"],
|
||||||
persona_update_max=self._tool_limits.get("persona_update_max", 1),
|
task_update_max=self._tool_limits["task_update_max"],
|
||||||
task_query_max=self._tool_limits.get("task_query_max", 4),
|
task_query_max=self._tool_limits["task_query_max"],
|
||||||
task_update_max=self._tool_limits.get("task_update_max", 2),
|
memory_query_max=self._tool_limits["memory_query_max"],
|
||||||
memory_query_max=self._tool_limits.get("memory_query_max", 20),
|
memory_update_max=self._tool_limits["memory_update_max"],
|
||||||
memory_update_max=self._tool_limits.get("memory_update_max", 10),
|
|
||||||
)
|
)
|
||||||
return ToolLimiter(limits)
|
return ToolLimiter(limits)
|
||||||
|
|
||||||
def _init_graph(self) -> None:
|
def _init_graph(self) -> None:
|
||||||
|
"""初始化图数据库。如果指定了用户名,从全局数据库获取用户的 db_path。"""
|
||||||
|
db_path = self._db_path
|
||||||
|
|
||||||
|
# 如果指定了用户名,尝试从全局数据库获取用户的数据库路径
|
||||||
|
if self._username:
|
||||||
|
try:
|
||||||
|
# 临时连接全局数据库获取用户信息
|
||||||
|
global_db_path = Path.home() / ".trulymem" / "trulymem.db"
|
||||||
|
if global_db_path.exists():
|
||||||
|
temp_db = EmbeddedGraphDB(db_path=str(global_db_path))
|
||||||
|
user_info = temp_db.get_web_user(self._username)
|
||||||
|
temp_db.close()
|
||||||
|
if user_info and user_info.get('db_path'):
|
||||||
|
db_path = user_info['db_path']
|
||||||
|
self._db_path = db_path # 更新 _db_path,供外部(如 web_api.py)获取正确的路径
|
||||||
|
except Exception:
|
||||||
|
pass # 如果获取失败,使用默认路径
|
||||||
|
|
||||||
if self._use_embedded_db:
|
if self._use_embedded_db:
|
||||||
self._graph = EmbeddedGraphDB(db_path=self._db_path)
|
self._graph = EmbeddedGraphDB(db_path=db_path)
|
||||||
else:
|
else:
|
||||||
from .graph_client import Neo4jGraph
|
from .graph_client import Neo4jGraph
|
||||||
self._graph = Neo4jGraph(
|
self._graph = Neo4jGraph(
|
||||||
@ -162,6 +257,25 @@ class BackendServer:
|
|||||||
response_body = self._handle_get_settings()
|
response_body = self._handle_get_settings()
|
||||||
elif packet.type == PacketType.SET_SETTINGS:
|
elif packet.type == PacketType.SET_SETTINGS:
|
||||||
response_body = self._handle_set_settings(packet.body)
|
response_body = self._handle_set_settings(packet.body)
|
||||||
|
elif packet.type == PacketType.GET_WEB_USERS:
|
||||||
|
response_body = {"users": self._graph.get_web_users()}
|
||||||
|
elif packet.type == PacketType.SET_WEB_USER:
|
||||||
|
username = packet.body.get("username", "")
|
||||||
|
password = packet.body.get("password", "")
|
||||||
|
if not username or not password:
|
||||||
|
response_body = {"success": False, "error": "用户名和密码不能为空"}
|
||||||
|
else:
|
||||||
|
# 使用全局数据库(trulymem.db)来管理用户
|
||||||
|
global_db_path = Path.home() / ".trulymem" / "trulymem.db"
|
||||||
|
from .embedded_db import EmbeddedGraphDB
|
||||||
|
global_db = EmbeddedGraphDB(db_path=str(global_db_path))
|
||||||
|
response_body = global_db.set_web_user(username, password)
|
||||||
|
global_db.close()
|
||||||
|
elif packet.type == PacketType.GET_WEB_SERVICE_STATUS:
|
||||||
|
body = packet.body
|
||||||
|
response_body = {"running": body.get("running", False), "port": body.get("port", 4096)}
|
||||||
|
elif packet.type == PacketType.GET_CONFIG:
|
||||||
|
response_body = self._get_full_config()
|
||||||
elif packet.type == PacketType.GET_HISTORY:
|
elif packet.type == PacketType.GET_HISTORY:
|
||||||
response_body = self._handle_get_history()
|
response_body = self._handle_get_history()
|
||||||
elif packet.type == PacketType.SAVE_HISTORY:
|
elif packet.type == PacketType.SAVE_HISTORY:
|
||||||
@ -186,6 +300,8 @@ class BackendServer:
|
|||||||
def _handle_process_message(self, body: Dict) -> Dict:
|
def _handle_process_message(self, body: Dict) -> Dict:
|
||||||
from .tool_executor import execute_tool
|
from .tool_executor import execute_tool
|
||||||
|
|
||||||
|
get_recorder().clear()
|
||||||
|
|
||||||
user_input = body.get("user_input", "")
|
user_input = body.get("user_input", "")
|
||||||
|
|
||||||
if not self._client:
|
if not self._client:
|
||||||
@ -222,9 +338,15 @@ class BackendServer:
|
|||||||
} for tc in message.tool_calls
|
} for tc in message.tool_calls
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
# 保留 DeepSeek thinking 模式的 reasoning_content
|
||||||
|
reasoning_content = getattr(message, 'reasoning_content', None)
|
||||||
|
if reasoning_content:
|
||||||
|
assistant_msg["reasoning_content"] = reasoning_content
|
||||||
messages_history.append(assistant_msg)
|
messages_history.append(assistant_msg)
|
||||||
|
|
||||||
current_tool_results = []
|
current_tool_results = []
|
||||||
|
deferred_rewrite = None # 延迟处理 context_rewrite
|
||||||
|
|
||||||
for tool_call in message.tool_calls:
|
for tool_call in message.tool_calls:
|
||||||
args = json.loads(tool_call.function.arguments)
|
args = json.loads(tool_call.function.arguments)
|
||||||
|
|
||||||
@ -243,6 +365,12 @@ class BackendServer:
|
|||||||
|
|
||||||
self._tool_limiter.record_call(tool_call.function.name, args)
|
self._tool_limiter.record_call(tool_call.function.name, args)
|
||||||
|
|
||||||
|
if tool_call.function.name == "context_rewrite":
|
||||||
|
# 延迟执行 context_rewrite:先处理完其他所有工具
|
||||||
|
# 避免在迭代中途重写 messages_history 导致 tool 结果丢失对应的 tool_calls
|
||||||
|
deferred_rewrite = (tool_call.id, args)
|
||||||
|
continue
|
||||||
|
|
||||||
result = execute_tool(self._graph, tool_call.function.name, args)
|
result = execute_tool(self._graph, tool_call.function.name, args)
|
||||||
tool_calls.append({
|
tool_calls.append({
|
||||||
"name": tool_call.function.name,
|
"name": tool_call.function.name,
|
||||||
@ -257,8 +385,30 @@ class BackendServer:
|
|||||||
}
|
}
|
||||||
current_tool_results.append(tool_result_msg)
|
current_tool_results.append(tool_result_msg)
|
||||||
|
|
||||||
|
# 先添加所有非 context_rewrite 工具的结果到消息历史
|
||||||
messages_history.extend(current_tool_results)
|
messages_history.extend(current_tool_results)
|
||||||
|
|
||||||
|
# 再处理延迟的 context_rewrite(作为本轮最后一步)
|
||||||
|
if deferred_rewrite:
|
||||||
|
tool_call_id, args = deferred_rewrite
|
||||||
|
result = execute_tool(self._graph, "context_rewrite", args)
|
||||||
|
result_data = json.loads(result)
|
||||||
|
|
||||||
|
tool_calls.append({
|
||||||
|
"name": "context_rewrite",
|
||||||
|
"arguments": args,
|
||||||
|
"result": result
|
||||||
|
})
|
||||||
|
|
||||||
|
if result_data.get("status") == "success":
|
||||||
|
user_msg = messages_history[0]
|
||||||
|
compressed_content = f"<context_compressed>\n{result_data['summary']}\n</context_compressed>"
|
||||||
|
messages_history[:] = [
|
||||||
|
user_msg,
|
||||||
|
{"role": "assistant", "content": compressed_content}
|
||||||
|
]
|
||||||
|
# 不添加 context_rewrite 的 tool 结果到历史(压缩后的历史已替代)
|
||||||
|
|
||||||
response = self._client.send_message_with_history(messages_history)
|
response = self._client.send_message_with_history(messages_history)
|
||||||
message = response.choices[0].message
|
message = response.choices[0].message
|
||||||
|
|
||||||
@ -313,20 +463,38 @@ class BackendServer:
|
|||||||
"tool_limits": self._tool_limits.copy()
|
"tool_limits": self._tool_limits.copy()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _get_full_config(self) -> Dict:
|
||||||
|
return {
|
||||||
|
"api_config": self._config.copy(),
|
||||||
|
"tool_limits": self._tool_limits.copy(),
|
||||||
|
}
|
||||||
|
|
||||||
def _handle_set_settings(self, body: Dict) -> Dict:
|
def _handle_set_settings(self, body: Dict) -> Dict:
|
||||||
api_config = body.get("api_config", {})
|
api_config = body.get("api_config", {})
|
||||||
tool_limits = body.get("tool_limits", {})
|
tool_limits = body.get("tool_limits", {})
|
||||||
|
|
||||||
api_key = api_config.get("api_key", "")
|
# 仅当 api_config 有值时更新 API 配置(避免单独保存 tool_limits 时清空 API key)
|
||||||
base_url = api_config.get("base_url", "https://api.deepseek.com")
|
if api_config:
|
||||||
model = api_config.get("model", "deepseek-chat")
|
api_key = api_config.get("api_key", self._config.get("api_key", ""))
|
||||||
|
base_url = api_config.get("base_url", self._config.get("base_url", "https://api.deepseek.com"))
|
||||||
|
model = api_config.get("model", self._config.get("model", "deepseek-v4-flash"))
|
||||||
self.update_config(api_key, base_url, model)
|
self.update_config(api_key, base_url, model)
|
||||||
|
# 通用配置字段(如 message_timeout, enable_web, enable_tui, web_port)
|
||||||
|
for key in ["message_timeout", "enable_web", "enable_tui", "web_port"]:
|
||||||
|
if key in api_config:
|
||||||
|
if key == "web_port":
|
||||||
|
self._config[key] = int(api_config[key])
|
||||||
|
elif key in ["enable_web", "enable_tui"]:
|
||||||
|
self._config[key] = bool(api_config[key])
|
||||||
|
else:
|
||||||
|
self._config[key] = int(api_config[key])
|
||||||
|
|
||||||
limits_keys = [
|
limits_keys = [
|
||||||
"persona_query_max", "persona_update_max",
|
"persona_update_max",
|
||||||
"task_query_max", "task_update_max",
|
"task_update_max",
|
||||||
"memory_query_max", "memory_update_max"
|
"task_query_max",
|
||||||
|
"memory_query_max",
|
||||||
|
"memory_update_max",
|
||||||
]
|
]
|
||||||
for key in limits_keys:
|
for key in limits_keys:
|
||||||
if key in tool_limits:
|
if key in tool_limits:
|
||||||
@ -366,7 +534,8 @@ class BackendServer:
|
|||||||
self._input_queue.put(packet)
|
self._input_queue.put(packet)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = resp_q.get(timeout=30.0)
|
timeout = self._config.get("message_timeout", 600)
|
||||||
|
response = resp_q.get(timeout=timeout)
|
||||||
return Packet(
|
return Packet(
|
||||||
id=response.id,
|
id=response.id,
|
||||||
type=packet.type,
|
type=packet.type,
|
||||||
@ -406,7 +575,7 @@ class BackendServer:
|
|||||||
response = self.send(packet)
|
response = self.send(packet)
|
||||||
return response.body
|
return response.body
|
||||||
|
|
||||||
def update_config(self, api_key: str, base_url: str = "https://api.deepseek.com", model: str = "deepseek-chat") -> None:
|
def update_config(self, api_key: str, base_url: str = "https://api.deepseek.com", model: str = "deepseek-v4-flash") -> None:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._config["api_key"] = api_key
|
self._config["api_key"] = api_key
|
||||||
self._config["base_url"] = base_url
|
self._config["base_url"] = base_url
|
||||||
|
|||||||
@ -4,6 +4,8 @@
|
|||||||
import json
|
import json
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
from .activity_recorder import get_recorder
|
||||||
|
|
||||||
|
|
||||||
def execute_tool(graph: Any, tool_name: str, arguments: dict) -> str:
|
def execute_tool(graph: Any, tool_name: str, arguments: dict) -> str:
|
||||||
"""执行工具调用"""
|
"""执行工具调用"""
|
||||||
@ -11,8 +13,12 @@ def execute_tool(graph: Any, tool_name: str, arguments: dict) -> str:
|
|||||||
print(f"[参数] {json.dumps(arguments, ensure_ascii=False, indent=2)}")
|
print(f"[参数] {json.dumps(arguments, ensure_ascii=False, indent=2)}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
recorder = get_recorder()
|
||||||
|
|
||||||
# 基础记忆工具
|
# 基础记忆工具
|
||||||
if tool_name == "memory_recall":
|
if tool_name == "memory_recall":
|
||||||
|
entity = arguments.get("query_intent", "") or str(arguments.get("seed_entities", ""))
|
||||||
|
recorder.record("query", tool_name, entity)
|
||||||
result = graph.recall(
|
result = graph.recall(
|
||||||
query_intent=arguments.get("query_intent", ""),
|
query_intent=arguments.get("query_intent", ""),
|
||||||
seed_entities=arguments.get("seed_entities"),
|
seed_entities=arguments.get("seed_entities"),
|
||||||
@ -20,62 +26,115 @@ def execute_tool(graph: Any, tool_name: str, arguments: dict) -> str:
|
|||||||
time_range=arguments.get("time_range"),
|
time_range=arguments.get("time_range"),
|
||||||
session_filter=arguments.get("session_filter")
|
session_filter=arguments.get("session_filter")
|
||||||
)
|
)
|
||||||
|
# 记录召回结果中的实体名,供 WebUI 高亮+拉镜头用
|
||||||
|
for e in result.get("entities", []):
|
||||||
|
if e and isinstance(e, dict) and e.get("name"):
|
||||||
|
recorder.record("query", tool_name + "_found", e["name"])
|
||||||
return format_recall_result(result)
|
return format_recall_result(result)
|
||||||
|
|
||||||
elif tool_name == "memory_commit":
|
elif tool_name == "memory_commit":
|
||||||
|
triplets = arguments.get("triplets", [])
|
||||||
|
entity = triplets[0].get("subject", "") if triplets else ""
|
||||||
|
recorder.record("create", tool_name, entity, f"{len(triplets)} triplets")
|
||||||
result = graph.commit(
|
result = graph.commit(
|
||||||
triplets=arguments.get("triplets", []),
|
triplets=triplets,
|
||||||
entity_types=arguments.get("entity_types"),
|
entity_types=arguments.get("entity_types"),
|
||||||
temporal_tag=arguments.get("temporal_tag")
|
temporal_tag=arguments.get("temporal_tag")
|
||||||
)
|
)
|
||||||
return json.dumps(result, ensure_ascii=False, default=str)
|
return json.dumps(result, ensure_ascii=False, default=str)
|
||||||
|
|
||||||
elif tool_name == "memory_purge":
|
elif tool_name == "memory_purge":
|
||||||
|
criteria = arguments.get("criteria", {})
|
||||||
|
entity = criteria.get("subject_contains", str(criteria))
|
||||||
|
recorder.record("delete", tool_name, entity)
|
||||||
result = graph.purge(
|
result = graph.purge(
|
||||||
criteria=arguments.get("criteria", {}),
|
criteria=criteria,
|
||||||
mode=arguments.get("mode", "soft"),
|
mode=arguments.get("mode", "soft"),
|
||||||
new_relation=arguments.get("new_relation")
|
new_relation=arguments.get("new_relation")
|
||||||
)
|
)
|
||||||
return json.dumps(result, ensure_ascii=False, default=str)
|
return json.dumps(result, ensure_ascii=False, default=str)
|
||||||
|
|
||||||
elif tool_name == "memory_introspect":
|
elif tool_name == "memory_introspect":
|
||||||
|
recorder.record("query", tool_name, "数据库统计")
|
||||||
result = graph.introspect(session_id=arguments.get("session_id"))
|
result = graph.introspect(session_id=arguments.get("session_id"))
|
||||||
return json.dumps(result, ensure_ascii=False, default=str)
|
return json.dumps(result, ensure_ascii=False, default=str)
|
||||||
|
|
||||||
elif tool_name == "memory_archive":
|
elif tool_name == "memory_archive":
|
||||||
|
recorder.record("archive", tool_name, "旧记忆")
|
||||||
result = graph.archive(days=arguments.get("days", 30))
|
result = graph.archive(days=arguments.get("days", 30))
|
||||||
return json.dumps(result, ensure_ascii=False, default=str)
|
return json.dumps(result, ensure_ascii=False, default=str)
|
||||||
|
|
||||||
elif tool_name == "memory_cleanup":
|
elif tool_name == "memory_cleanup":
|
||||||
|
recorder.record("cleanup", tool_name, "已删除数据")
|
||||||
result = graph.cleanup(dry_run=arguments.get("dry_run", True))
|
result = graph.cleanup(dry_run=arguments.get("dry_run", True))
|
||||||
return json.dumps(result, ensure_ascii=False, default=str)
|
return json.dumps(result, ensure_ascii=False, default=str)
|
||||||
|
|
||||||
|
elif tool_name == "memory_query_archived":
|
||||||
|
days = arguments.get("days")
|
||||||
|
keyword = arguments.get("keyword", "")
|
||||||
|
recorder.record("query", tool_name, f"days={days}, keyword={keyword}")
|
||||||
|
result = graph.query_archived(days=days, keyword=keyword)
|
||||||
|
return json.dumps(result, ensure_ascii=False, default=str)
|
||||||
|
|
||||||
|
elif tool_name == "context_rewrite":
|
||||||
|
result = execute_context_rewrite(graph, arguments)
|
||||||
|
return json.dumps(result, ensure_ascii=False, default=str)
|
||||||
|
|
||||||
# 人设图管理工具
|
# 人设图管理工具
|
||||||
elif tool_name == "persona_update":
|
elif tool_name == "persona_update":
|
||||||
|
recorder.record("update", tool_name, "人设属性")
|
||||||
result = execute_persona_update(graph, arguments)
|
result = execute_persona_update(graph, arguments)
|
||||||
return json.dumps(result, ensure_ascii=False, default=str)
|
return json.dumps(result, ensure_ascii=False, default=str)
|
||||||
|
|
||||||
|
elif tool_name == "persona_remove":
|
||||||
|
recorder.record("delete", tool_name, arguments.get("attribute", ""))
|
||||||
|
result = execute_persona_remove(graph, arguments)
|
||||||
|
return json.dumps(result, ensure_ascii=False, default=str)
|
||||||
|
|
||||||
elif tool_name == "persona_clear":
|
elif tool_name == "persona_clear":
|
||||||
|
recorder.record("delete", tool_name, "所有人设")
|
||||||
result = execute_persona_clear(graph, arguments)
|
result = execute_persona_clear(graph, arguments)
|
||||||
return json.dumps(result, ensure_ascii=False, default=str)
|
return json.dumps(result, ensure_ascii=False, default=str)
|
||||||
|
|
||||||
# 工作记忆链管理工具
|
# 工作记忆链管理工具
|
||||||
elif tool_name == "task_create":
|
elif tool_name == "task_create":
|
||||||
|
desc = arguments.get("description", "")
|
||||||
|
recorder.record("create", tool_name, desc)
|
||||||
result = execute_task_create(graph, arguments)
|
result = execute_task_create(graph, arguments)
|
||||||
return json.dumps(result, ensure_ascii=False, default=str)
|
return json.dumps(result, ensure_ascii=False, default=str)
|
||||||
|
|
||||||
elif tool_name == "task_set_state":
|
elif tool_name == "task_set_state":
|
||||||
|
desc = arguments.get("task_id", "")
|
||||||
|
recorder.record("update", tool_name, desc)
|
||||||
result = execute_task_set_state(graph, arguments)
|
result = execute_task_set_state(graph, arguments)
|
||||||
return json.dumps(result, ensure_ascii=False, default=str)
|
return json.dumps(result, ensure_ascii=False, default=str)
|
||||||
|
|
||||||
elif tool_name == "task_delete":
|
elif tool_name == "task_delete":
|
||||||
|
desc = arguments.get("task_id", "")
|
||||||
|
recorder.record("delete", tool_name, desc)
|
||||||
result = execute_task_delete(graph, arguments)
|
result = execute_task_delete(graph, arguments)
|
||||||
return json.dumps(result, ensure_ascii=False, default=str)
|
return json.dumps(result, ensure_ascii=False, default=str)
|
||||||
|
|
||||||
elif tool_name == "task_link_info":
|
elif tool_name == "task_link_info":
|
||||||
|
desc = arguments.get("task_id", "")
|
||||||
|
recorder.record("update", tool_name, desc)
|
||||||
result = execute_task_link_info(graph, arguments)
|
result = execute_task_link_info(graph, arguments)
|
||||||
return json.dumps(result, ensure_ascii=False, default=str)
|
return json.dumps(result, ensure_ascii=False, default=str)
|
||||||
|
|
||||||
|
elif tool_name == "task_archive":
|
||||||
|
recorder.record("update", tool_name, arguments.get("task_id", ""))
|
||||||
|
result = execute_task_archive(graph, arguments)
|
||||||
|
return json.dumps(result, ensure_ascii=False, default=str)
|
||||||
|
|
||||||
|
elif tool_name == "task_query":
|
||||||
|
recorder.record("query", tool_name, "")
|
||||||
|
result = execute_task_query(graph, arguments)
|
||||||
|
# 记录查询到的任务描述,供 WebUI 高亮
|
||||||
|
for t in result.get("tasks", []):
|
||||||
|
if t and isinstance(t, dict) and t.get("description"):
|
||||||
|
recorder.record("query", tool_name + "_found", t["description"])
|
||||||
|
return json.dumps(result, ensure_ascii=False, default=str)
|
||||||
|
|
||||||
return f"未知工具: {tool_name}"
|
return f"未知工具: {tool_name}"
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -111,6 +170,24 @@ def format_recall_result(result: dict) -> str:
|
|||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def execute_context_rewrite(graph: Any, arguments: dict) -> dict:
|
||||||
|
"""压缩工具调用上下文"""
|
||||||
|
summary = arguments.get("summary", "")
|
||||||
|
|
||||||
|
# 验证格式:必须包含工具调用标记
|
||||||
|
if "[工具调用总结" not in summary:
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"message": "总结格式错误:必须包含 [工具调用总结: 本次总结了 N 次工具调用 | 调用工具: ...] 标记"
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"message": "上下文已压缩",
|
||||||
|
"summary": summary
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# 人设图管理工具实现
|
# 人设图管理工具实现
|
||||||
def execute_persona_update(graph: Any, arguments: dict) -> dict:
|
def execute_persona_update(graph: Any, arguments: dict) -> dict:
|
||||||
"""更新人设"""
|
"""更新人设"""
|
||||||
@ -151,40 +228,74 @@ def execute_persona_update(graph: Any, arguments: dict) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def execute_persona_remove(graph: Any, arguments: dict) -> dict:
|
||||||
|
"""删除单条人设属性"""
|
||||||
|
attribute = arguments.get("attribute")
|
||||||
|
if not attribute:
|
||||||
|
return {"status": "error", "message": "请指定要删除的属性名"}
|
||||||
|
|
||||||
|
# 查询当前AI的所有人设关系,找到匹配属性名的
|
||||||
|
recall_result = graph.recall(query_intent="AI,人设,角色", depth=1)
|
||||||
|
found = False
|
||||||
|
deleted_count = 0
|
||||||
|
|
||||||
|
for rel in recall_result.get("relations", []):
|
||||||
|
if rel.get("source") == "AI" and rel.get("type") == attribute:
|
||||||
|
result = graph.purge(
|
||||||
|
criteria={"subject_contains": "AI", "relation_type": attribute},
|
||||||
|
mode="soft"
|
||||||
|
)
|
||||||
|
deleted_count += result.get("deleted_count", 0)
|
||||||
|
found = True
|
||||||
|
|
||||||
|
if not found:
|
||||||
|
# 也许属性名不完全匹配,尝试直接用这个类型删除
|
||||||
|
result = graph.purge(
|
||||||
|
criteria={"subject_contains": "AI", "relation_type": attribute},
|
||||||
|
mode="soft"
|
||||||
|
)
|
||||||
|
deleted_count = result.get("deleted_count", 0)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success" if deleted_count > 0 else "not_found",
|
||||||
|
"deleted_attribute": attribute,
|
||||||
|
"deleted_count": deleted_count,
|
||||||
|
"message": f"已删除属性「{attribute}」" if deleted_count > 0 else f"未找到属性「{attribute}」"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def execute_persona_clear(graph: Any, arguments: dict) -> dict:
|
def execute_persona_clear(graph: Any, arguments: dict) -> dict:
|
||||||
"""清除人设"""
|
"""清除所有人设"""
|
||||||
if not arguments.get("confirm", True):
|
if not arguments.get("confirm"):
|
||||||
return {"status": "cancelled", "message": "需要确认才能清除人设"}
|
return {"status": "cancelled", "message": "请设置 confirm=true 确认清除人设"}
|
||||||
|
|
||||||
# 删除所有人设相关关系
|
# 先查询AI的所有人设关系
|
||||||
result1 = graph.purge(
|
recall_result = graph.recall(query_intent="AI,人设,角色", depth=1)
|
||||||
criteria={"subject_contains": "AI", "relation_type": "扮演角色"},
|
|
||||||
mode="soft"
|
|
||||||
)
|
|
||||||
result2 = graph.purge(
|
|
||||||
criteria={"subject_contains": "AI", "relation_type": "说话风格"},
|
|
||||||
mode="soft"
|
|
||||||
)
|
|
||||||
result3 = graph.purge(
|
|
||||||
criteria={"subject_contains": "AI", "relation_type": "性格特点"},
|
|
||||||
mode="soft"
|
|
||||||
)
|
|
||||||
result4 = graph.purge(
|
|
||||||
criteria={"subject_contains": "AI", "relation_type": "语气特征"},
|
|
||||||
mode="soft"
|
|
||||||
)
|
|
||||||
|
|
||||||
total_deleted = (
|
# 收集所有AI到其他实体的关系类型
|
||||||
result1.get("deleted_count", 0) +
|
relation_types = set()
|
||||||
result2.get("deleted_count", 0) +
|
for rel in recall_result.get("relations", []):
|
||||||
result3.get("deleted_count", 0) +
|
if rel.get("source") == "AI" and rel.get("type"):
|
||||||
result4.get("deleted_count", 0)
|
relation_types.add(rel.get("type"))
|
||||||
|
|
||||||
|
total_deleted = 0
|
||||||
|
deleted_types = []
|
||||||
|
|
||||||
|
for rtype in relation_types:
|
||||||
|
result = graph.purge(
|
||||||
|
criteria={"subject_contains": "AI", "relation_type": rtype},
|
||||||
|
mode="soft"
|
||||||
)
|
)
|
||||||
|
count = result.get("deleted_count", 0)
|
||||||
|
if count > 0:
|
||||||
|
total_deleted += count
|
||||||
|
deleted_types.append(rtype)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"deleted_count": total_deleted,
|
"deleted_count": total_deleted,
|
||||||
"message": "人设已清除,恢复默认身份"
|
"deleted_types": deleted_types,
|
||||||
|
"message": f"人设已清除,恢复默认身份(删除了 {len(deleted_types)} 类属性)"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -305,3 +416,53 @@ def execute_task_link_info(graph: Any, arguments: dict) -> dict:
|
|||||||
"linked_nodes": info_node_names,
|
"linked_nodes": info_node_names,
|
||||||
"details": result
|
"details": result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def execute_task_archive(graph: Any, arguments: dict) -> dict:
|
||||||
|
"""归档任务"""
|
||||||
|
task_id = arguments.get("task_id")
|
||||||
|
summary = arguments.get("summary", "")
|
||||||
|
|
||||||
|
if not task_id:
|
||||||
|
return {"status": "error", "message": "请指定要归档的任务ID"}
|
||||||
|
|
||||||
|
# 1. 设置任务状态为 archived
|
||||||
|
triplets_state = [
|
||||||
|
{"subject": task_id, "relation": "HAS_STATE", "object": "State_归档"}
|
||||||
|
]
|
||||||
|
graph.commit(triplets=triplets_state)
|
||||||
|
|
||||||
|
# 2. 如果有摘要,写入完成记录
|
||||||
|
if summary:
|
||||||
|
summary_triplets = [
|
||||||
|
{"subject": task_id, "relation": "归档摘要", "object": summary}
|
||||||
|
]
|
||||||
|
graph.commit(triplets=summary_triplets)
|
||||||
|
|
||||||
|
# 3. 尝试更新 description 标记为已归档
|
||||||
|
archive_triplet = [
|
||||||
|
{"subject": task_id, "relation": "has_description", "object": f"[已归档] {summary or '任务已完成'}"}
|
||||||
|
]
|
||||||
|
graph.commit(triplets=archive_triplet)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"task_id": task_id,
|
||||||
|
"archived": True,
|
||||||
|
"summary": summary or "无摘要",
|
||||||
|
"message": f"任务「{task_id}」已归档" + (f",摘要:{summary}" if summary else "")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def execute_task_query(graph: Any, arguments: dict) -> dict:
|
||||||
|
"""查询最近的任务列表"""
|
||||||
|
limit = arguments.get("limit", 10)
|
||||||
|
state_filter = arguments.get("state_filter")
|
||||||
|
|
||||||
|
result = graph.get_recent_tasks(limit=limit, state_filter=state_filter)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"tasks": result["tasks"],
|
||||||
|
"total": result["total"],
|
||||||
|
"message": f"找到 {result['total']} 个任务"
|
||||||
|
}
|
||||||
|
|||||||
@ -1,38 +1,29 @@
|
|||||||
"""
|
"""
|
||||||
工具调用限制器 - 限制每轮对话中各类工具的调用次数
|
工具调用限制器 - 限制每轮对话中各类工具的调用次数
|
||||||
"""
|
"""
|
||||||
from typing import Dict, List, Optional
|
from typing import Optional
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ToolLimits:
|
class ToolLimits:
|
||||||
"""工具调用限制配置"""
|
"""工具调用限制配置
|
||||||
# 人设图限制
|
实际值由 server.py 从 config.json 加载后传入,此处默认值仅作安全兜底。
|
||||||
persona_query_max: int = 1 # 每轮最多查询1次人设图
|
如需修改限制,请编辑 ~/.trulymem/config.json。
|
||||||
persona_update_max: int = 1 # 每轮最多修改1次人设图
|
"""
|
||||||
|
persona_update_max: int = 1
|
||||||
# 工作记忆链限制
|
task_update_max: int = 20 # 工作记忆链修改(create/set_state/delete/link_info)
|
||||||
task_query_max: int = 4 # 每轮最多查询4次工作记忆链
|
task_query_max: int = 30 # 工作记忆链查询(memory_recall 查任务相关)
|
||||||
task_update_max: int = 2 # 每轮最多修改2次工作记忆链
|
memory_query_max: int = 30
|
||||||
|
memory_update_max: int = 15
|
||||||
# 一般记忆限制
|
|
||||||
memory_query_max: int = 20 # 每轮最多查询20次一般记忆
|
|
||||||
memory_update_max: int = 10 # 每轮最多修改10次一般记忆
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ToolCallCount:
|
class ToolCallCount:
|
||||||
"""工具调用计数"""
|
"""工具调用计数"""
|
||||||
# 人设图
|
|
||||||
persona_query: int = 0
|
|
||||||
persona_update: int = 0
|
persona_update: int = 0
|
||||||
|
|
||||||
# 工作记忆链
|
|
||||||
task_query: int = 0
|
|
||||||
task_update: int = 0
|
task_update: int = 0
|
||||||
|
task_query: int = 0
|
||||||
# 一般记忆
|
|
||||||
memory_query: int = 0
|
memory_query: int = 0
|
||||||
memory_update: int = 0
|
memory_update: int = 0
|
||||||
|
|
||||||
@ -51,29 +42,22 @@ class ToolLimiter:
|
|||||||
category: 'persona', 'task', 'memory'
|
category: 'persona', 'task', 'memory'
|
||||||
operation: 'query', 'update'
|
operation: 'query', 'update'
|
||||||
"""
|
"""
|
||||||
# 人设图工具
|
if tool_name in ('persona_update', 'persona_remove', 'persona_clear'):
|
||||||
if tool_name in ('persona_update', 'persona_clear'):
|
|
||||||
return ('persona', 'update')
|
return ('persona', 'update')
|
||||||
|
|
||||||
# 工作记忆链工具
|
if tool_name in ('task_create', 'task_set_state', 'task_delete', 'task_link_info', 'task_archive'):
|
||||||
if tool_name in ('task_create', 'task_set_state', 'task_delete', 'task_link_info'):
|
|
||||||
# task_link_info 是关联操作,算作更新
|
|
||||||
return ('task', 'update')
|
return ('task', 'update')
|
||||||
|
|
||||||
# 一般记忆工具
|
if tool_name == 'task_query':
|
||||||
if tool_name == 'memory_recall':
|
|
||||||
# 判断是查询人设图、工作记忆链还是一般记忆
|
|
||||||
query_intent = arguments.get('query_intent', '').lower()
|
|
||||||
|
|
||||||
# 检查是否查询人设图
|
|
||||||
if any(kw in query_intent for kw in ['人设', '角色', '性格', '语气', '说话风格', '扮演']):
|
|
||||||
return ('persona', 'query')
|
|
||||||
|
|
||||||
# 检查是否查询工作记忆链
|
|
||||||
if any(kw in query_intent for kw in ['tasknode', '工作记忆', '任务链', '任务', 'task']):
|
|
||||||
return ('task', 'query')
|
return ('task', 'query')
|
||||||
|
|
||||||
# 一般记忆查询
|
if tool_name == 'memory_recall':
|
||||||
|
# 尝试区分工作记忆链查询 vs 一般记忆查询
|
||||||
|
query = (arguments.get('queryIntent', '') + ' ' + ' '.join(
|
||||||
|
arguments.get('seedEntities', []))).strip().lower()
|
||||||
|
task_keywords = ['task', '任务', '工作记忆', '当前轮', '会话', '过程', '流程']
|
||||||
|
if any(kw in query for kw in task_keywords):
|
||||||
|
return ('task', 'query')
|
||||||
return ('memory', 'query')
|
return ('memory', 'query')
|
||||||
|
|
||||||
if tool_name == 'memory_commit':
|
if tool_name == 'memory_commit':
|
||||||
@ -88,7 +72,9 @@ class ToolLimiter:
|
|||||||
if tool_name in ('memory_archive', 'memory_cleanup'):
|
if tool_name in ('memory_archive', 'memory_cleanup'):
|
||||||
return ('memory', 'update')
|
return ('memory', 'update')
|
||||||
|
|
||||||
# 未知工具,归类为一般记忆更新
|
if tool_name == 'context_rewrite':
|
||||||
|
return ('memory', 'query')
|
||||||
|
|
||||||
return ('memory', 'update')
|
return ('memory', 'update')
|
||||||
|
|
||||||
def can_call(self, tool_name: str, arguments: dict) -> tuple:
|
def can_call(self, tool_name: str, arguments: dict) -> tuple:
|
||||||
@ -98,12 +84,7 @@ class ToolLimiter:
|
|||||||
"""
|
"""
|
||||||
category, operation = self._classify_tool(tool_name, arguments)
|
category, operation = self._classify_tool(tool_name, arguments)
|
||||||
|
|
||||||
# 获取当前计数和限制
|
|
||||||
if category == 'persona':
|
if category == 'persona':
|
||||||
if operation == 'query':
|
|
||||||
if self.counts.persona_query >= self.limits.persona_query_max:
|
|
||||||
return (False, f"人设图查询次数已达上限({self.limits.persona_query_max}次)")
|
|
||||||
else: # update
|
|
||||||
if self.counts.persona_update >= self.limits.persona_update_max:
|
if self.counts.persona_update >= self.limits.persona_update_max:
|
||||||
return (False, f"人设图修改次数已达上限({self.limits.persona_update_max}次)")
|
return (False, f"人设图修改次数已达上限({self.limits.persona_update_max}次)")
|
||||||
|
|
||||||
@ -111,15 +92,14 @@ class ToolLimiter:
|
|||||||
if operation == 'query':
|
if operation == 'query':
|
||||||
if self.counts.task_query >= self.limits.task_query_max:
|
if self.counts.task_query >= self.limits.task_query_max:
|
||||||
return (False, f"工作记忆链查询次数已达上限({self.limits.task_query_max}次)")
|
return (False, f"工作记忆链查询次数已达上限({self.limits.task_query_max}次)")
|
||||||
else: # update
|
elif self.counts.task_update >= self.limits.task_update_max:
|
||||||
if self.counts.task_update >= self.limits.task_update_max:
|
|
||||||
return (False, f"工作记忆链修改次数已达上限({self.limits.task_update_max}次)")
|
return (False, f"工作记忆链修改次数已达上限({self.limits.task_update_max}次)")
|
||||||
|
|
||||||
elif category == 'memory':
|
elif category == 'memory':
|
||||||
if operation == 'query':
|
if operation == 'query':
|
||||||
if self.counts.memory_query >= self.limits.memory_query_max:
|
if self.counts.memory_query >= self.limits.memory_query_max:
|
||||||
return (False, f"一般记忆查询次数已达上限({self.limits.memory_query_max}次)")
|
return (False, f"一般记忆查询次数已达上限({self.limits.memory_query_max}次)")
|
||||||
else: # update
|
else:
|
||||||
if self.counts.memory_update >= self.limits.memory_update_max:
|
if self.counts.memory_update >= self.limits.memory_update_max:
|
||||||
return (False, f"一般记忆修改次数已达上限({self.limits.memory_update_max}次)")
|
return (False, f"一般记忆修改次数已达上限({self.limits.memory_update_max}次)")
|
||||||
|
|
||||||
@ -130,9 +110,6 @@ class ToolLimiter:
|
|||||||
category, operation = self._classify_tool(tool_name, arguments)
|
category, operation = self._classify_tool(tool_name, arguments)
|
||||||
|
|
||||||
if category == 'persona':
|
if category == 'persona':
|
||||||
if operation == 'query':
|
|
||||||
self.counts.persona_query += 1
|
|
||||||
else:
|
|
||||||
self.counts.persona_update += 1
|
self.counts.persona_update += 1
|
||||||
|
|
||||||
elif category == 'task':
|
elif category == 'task':
|
||||||
@ -150,8 +127,7 @@ class ToolLimiter:
|
|||||||
def get_summary(self) -> str:
|
def get_summary(self) -> str:
|
||||||
"""获取调用统计摘要"""
|
"""获取调用统计摘要"""
|
||||||
lines = [
|
lines = [
|
||||||
f"人设图: 查询{self.counts.persona_query}/{self.limits.persona_query_max}次, "
|
f"人设图: 修改{self.counts.persona_update}/{self.limits.persona_update_max}次",
|
||||||
f"修改{self.counts.persona_update}/{self.limits.persona_update_max}次",
|
|
||||||
f"工作记忆链: 查询{self.counts.task_query}/{self.limits.task_query_max}次, "
|
f"工作记忆链: 查询{self.counts.task_query}/{self.limits.task_query_max}次, "
|
||||||
f"修改{self.counts.task_update}/{self.limits.task_update_max}次",
|
f"修改{self.counts.task_update}/{self.limits.task_update_max}次",
|
||||||
f"一般记忆: 查询{self.counts.memory_query}/{self.limits.memory_query_max}次, "
|
f"一般记忆: 查询{self.counts.memory_query}/{self.limits.memory_query_max}次, "
|
||||||
|
|||||||
@ -11,26 +11,26 @@ MEMORY_TOOLS = [
|
|||||||
"name": "memory_recall",
|
"name": "memory_recall",
|
||||||
"description": """检索记忆。支持关键词、时间范围、会话过滤。返回相关实体和关系。
|
"description": """检索记忆。支持关键词、时间范围、会话过滤。返回相关实体和关系。
|
||||||
|
|
||||||
【使用示例】
|
【⚠️ 强制执行顺序 - 每轮必须严格遵守】
|
||||||
1. 查询人设图(每轮必须首先执行):
|
1. 步骤1(必须首先执行): 查询人设图
|
||||||
{"query_intent": "AI,人设,角色,性格,语气,说话风格", "depth": 2}
|
{"query_intent": "AI,人设,角色,性格,语气,说话风格", "depth": 2}
|
||||||
|
|
||||||
2. 查询工作记忆链(每轮必须第二步执行):
|
2. 步骤2(必须第二步执行): 查询工作记忆链
|
||||||
{"query_intent": "TaskNode,工作记忆,任务链", "depth": 2}
|
{"query_intent": "TaskNode,工作记忆,任务链", "depth": 2}
|
||||||
|
|
||||||
3. 查询用户偏好:
|
3. 步骤3: 根据需要查询其他记忆
|
||||||
|
|
||||||
|
【使用示例】
|
||||||
|
1. 查询用户偏好:
|
||||||
{"query_intent": "用户,喜欢,偏好", "seed_entities": ["用户"]}
|
{"query_intent": "用户,喜欢,偏好", "seed_entities": ["用户"]}
|
||||||
|
|
||||||
4. 查询特定主题:
|
2. 查询特定主题:
|
||||||
{"query_intent": "Python,编程,项目", "seed_entities": ["Python"]}
|
{"query_intent": "Python,编程,项目", "seed_entities": ["Python"]}
|
||||||
|
|
||||||
5. 查询最近7天的记忆:
|
3. 查询最近7天的记忆:
|
||||||
{"query_intent": "任务,工作", "time_range": {"days": 7}}
|
{"query_intent": "任务,工作", "time_range": {"days": 7}}
|
||||||
|
|
||||||
【重要】每轮对话必须按顺序执行:
|
【重要】跳过步骤1或步骤2将导致系统错误!""",
|
||||||
- 步骤1: 查询人设图(最高优先级)
|
|
||||||
- 步骤2: 查询工作记忆链(维持对话连贯性)
|
|
||||||
- 步骤3: 根据需要查询其他记忆""",
|
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@ -103,16 +103,18 @@ MEMORY_TOOLS = [
|
|||||||
"subject": {"type": "string"},
|
"subject": {"type": "string"},
|
||||||
"relation": {"type": "string"},
|
"relation": {"type": "string"},
|
||||||
"object": {"type": "string"},
|
"object": {"type": "string"},
|
||||||
"confidence": {"type": "number"}
|
"confidence": {"type": "number"},
|
||||||
|
"subject_type": {"type": "string", "description": "主体的实体类型,如 Person、Project"},
|
||||||
|
"object_type": {"type": "string", "description": "客体的实体类型,如 Language、Technology"}
|
||||||
},
|
},
|
||||||
"required": ["subject", "relation", "object"]
|
"required": ["subject", "relation", "object"]
|
||||||
},
|
},
|
||||||
"description": "三元组列表"
|
"description": "三元组列表"
|
||||||
},
|
},
|
||||||
"entity_types": {
|
"entity_types": {
|
||||||
"type": "array",
|
"type": "object",
|
||||||
"items": {"type": "string"},
|
"additionalProperties": {"type": "string"},
|
||||||
"description": "实体类型(可选)"
|
"description": "实体类型字典,如 {\"用户\": \"Person\", \"项目A\": \"Project\"}(可选)"
|
||||||
},
|
},
|
||||||
"temporal_tag": {
|
"temporal_tag": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@ -146,6 +148,12 @@ MEMORY_TOOLS = [
|
|||||||
4. 删除旧记忆:
|
4. 删除旧记忆:
|
||||||
{"criteria": {"time_before": "2024-01-01"}, "mode": "soft"}
|
{"criteria": {"time_before": "2024-01-01"}, "mode": "soft"}
|
||||||
|
|
||||||
|
5. 删除残留在已归档任务上的状态关系:
|
||||||
|
{"criteria": {"relation_type": "HAS_STATE", "source_type": "TaskNode", "source_has_status": "archived"}, "mode": "soft"}
|
||||||
|
|
||||||
|
6. 删除特定类型的节点关系:
|
||||||
|
{"criteria": {"relation_type": "某种关系", "target_type": "某种类型"}, "mode": "soft"}
|
||||||
|
|
||||||
【重要】删除原则:
|
【重要】删除原则:
|
||||||
- 优先使用 supersede 模式修正错误
|
- 优先使用 supersede 模式修正错误
|
||||||
- 软删除不会物理删除数据
|
- 软删除不会物理删除数据
|
||||||
@ -159,6 +167,9 @@ MEMORY_TOOLS = [
|
|||||||
"subject_contains": {"type": "string"},
|
"subject_contains": {"type": "string"},
|
||||||
"relation_type": {"type": "string"},
|
"relation_type": {"type": "string"},
|
||||||
"target_contains": {"type": "string"},
|
"target_contains": {"type": "string"},
|
||||||
|
"source_type": {"type": "string", "description": "源实体类型过滤(如 TaskNode)"},
|
||||||
|
"target_type": {"type": "string", "description": "目标实体类型过滤"},
|
||||||
|
"source_has_status": {"type": "string", "description": "源实体状态过滤(如 archived)"},
|
||||||
"time_before": {"type": "string"},
|
"time_before": {"type": "string"},
|
||||||
"session_id": {"type": "string"}
|
"session_id": {"type": "string"}
|
||||||
},
|
},
|
||||||
@ -234,6 +245,94 @@ MEMORY_TOOLS = [
|
|||||||
"required": []
|
"required": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "memory_query_archived",
|
||||||
|
"description": """查询已归档的记忆。
|
||||||
|
|
||||||
|
【使用场景】
|
||||||
|
- 想了解之前归档过哪些记忆
|
||||||
|
- 按关键词搜索归档内容
|
||||||
|
- 按时间范围查看最近归档的历史
|
||||||
|
|
||||||
|
【注意】
|
||||||
|
- 只返回 status=archived 的原始关系记录,不包含活跃的「归档摘要」
|
||||||
|
- days 和 keyword 可以单独使用,也可以组合使用
|
||||||
|
- 不加任何参数时返回所有归档记录
|
||||||
|
|
||||||
|
【示例】
|
||||||
|
```
|
||||||
|
# 不传参数:查全部归档
|
||||||
|
memory_query_archived({})
|
||||||
|
|
||||||
|
# 最近7天
|
||||||
|
memory_query_archived({"days": 7})
|
||||||
|
|
||||||
|
# 关键词过滤
|
||||||
|
memory_query_archived({"keyword": "任务"})
|
||||||
|
|
||||||
|
# 组合使用
|
||||||
|
memory_query_archived({"days": 30, "keyword": "配置"})
|
||||||
|
```
|
||||||
|
""",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"days": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "最近N天内的归档记录,不指定则不限时间"
|
||||||
|
},
|
||||||
|
"keyword": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "关键词,匹配实体名或关系类型"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "context_rewrite",
|
||||||
|
"description": """压缩本轮对话的工具调用上下文。将冗长的JSON工具结果提炼为简洁摘要。
|
||||||
|
|
||||||
|
【使用场景】
|
||||||
|
- 本轮已执行 ≥5 次查询类工具调用,JSON细节已理解,不再需要原始格式
|
||||||
|
- 但需保留"我调用了什么工具、得到了什么结论"的元认知
|
||||||
|
- 继续携带原始JSON会干扰后续推理
|
||||||
|
|
||||||
|
【⚠️ 强制要求】
|
||||||
|
**context_rewrite 必须单独调用,不能和其他工具在同一轮一起调!**
|
||||||
|
- 正确方式:先调其他所有工具 → 收到工具结果 → 单独调 context_rewrite
|
||||||
|
- 错误方式:和其他工具一起调(会破坏对话历史结构)
|
||||||
|
|
||||||
|
【格式要求】
|
||||||
|
1. 必须标注调用了哪些工具
|
||||||
|
2. 必须标注是对几次工具调用的总结
|
||||||
|
3. 必须保留关键语义信息
|
||||||
|
|
||||||
|
【示例】
|
||||||
|
{
|
||||||
|
"summary": "[工具调用总结: 本次总结了 2 次工具调用 | 调用工具: memory_recall, memory_recall]\\n\\n- 查询人设图:未找到人设,使用默认身份\\n- 查询工作记忆链:发现 Task_成语接龙,状态已暂停,当前成语为虎作伥"
|
||||||
|
}
|
||||||
|
|
||||||
|
【注意事项】
|
||||||
|
- 不可删除用户原始消息
|
||||||
|
- 不可歪曲工具返回的关键事实
|
||||||
|
- 仅在调用 ≥5 次查询类工具后使用""",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"summary": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "压缩后的摘要文本,必须包含工具调用元信息"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["summary"]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -295,21 +394,37 @@ PERSONA_TOOLS = [
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "persona_remove",
|
||||||
|
"description": "删除单条人设属性。删除指定的属性(如说话风格、扮演角色等),保留其他人设不变。",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"attribute": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "要删除的属性名(如:扮演角色、说话风格、性格特点、口头禅)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["attribute"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "persona_clear",
|
"name": "persona_clear",
|
||||||
"description": "清除人设。删除AI的角色设定,恢复默认身份。",
|
"description": "清除人设。删除AI所有角色设定,恢复默认身份。注意:此操作不可逆。",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"confirm": {
|
"confirm": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "确认清除",
|
"description": "确认清除全部人设"
|
||||||
"default": True
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": []
|
"required": ["confirm"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -349,7 +464,9 @@ WORKING_MEMORY_TOOLS = [
|
|||||||
- 任务节点通过 NEXT_TASK 边形成时间链
|
- 任务节点通过 NEXT_TASK 边形成时间链
|
||||||
- 任务节点通过 HAS_STATE 边指向状态节点
|
- 任务节点通过 HAS_STATE 边指向状态节点
|
||||||
- 任务节点通过 CONTAINS_INFO 边指向信息节点
|
- 任务节点通过 CONTAINS_INFO 边指向信息节点
|
||||||
- info_nodes 参数用于关联具体信息节点
|
- **info_nodes 只能包含该任务专属的具体信息节点**(如"成语接龙_当前成语"),**严禁关联"用户"、"AI"、"系统"等全局通用实体**——这些实体不应通过任务中转
|
||||||
|
- 全局实体的信息直接用独立关系记录(如 用户--[特质]-->求知欲旺盛),不需要通过 Task 中转
|
||||||
|
- info_nodes 参数用于关联任务专属信息节点
|
||||||
|
|
||||||
【完整流程示例】
|
【完整流程示例】
|
||||||
用户: "咱来玩成语接龙吧,我先开始,为所欲为"
|
用户: "咱来玩成语接龙吧,我先开始,为所欲为"
|
||||||
@ -379,7 +496,7 @@ AI操作步骤:
|
|||||||
"info_nodes": {
|
"info_nodes": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {"type": "string"},
|
"items": {"type": "string"},
|
||||||
"description": "关联的信息节点名称(可选)"
|
"description": "关联的信息节点名称(可选)。⚠️ 只能放任务专属的具体信息节点(如\"成语接龙_当前成语\"),严禁放\"用户\"、\"AI\"、\"系统\"等全局通用实体"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["task_id", "description"]
|
"required": ["task_id", "description"]
|
||||||
@ -513,6 +630,47 @@ AI操作步骤:
|
|||||||
"required": ["task_id", "info_node_names"]
|
"required": ["task_id", "info_node_names"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "task_archive",
|
||||||
|
"description": "归档已完成/过期的任务。将任务状态设为 archived,同时写入完成摘要到图数据库。\n\n【使用场景】\n1. 话题转变时归档旧任务\n2. 已完成的任务及时归档\n3. 长时间无更新的任务归档\n\n【注意】优先使用 task_archive 替代 task_set_state(state=archived),因为它会自动写入完成摘要。",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"task_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "要归档的任务ID"
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "归档摘要,简述完成了什么或为什么归档。如果不填则自动生成。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["task_id"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "task_query",
|
||||||
|
"description": "查询最近的任务列表。按更新时间倒序排列。新对话开始时优先使用此工具获取所有进展中的任务,避免重复创建。",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"limit": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "返回的任务数量,默认10"
|
||||||
|
},
|
||||||
|
"state_filter": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "按状态筛选:进行中、已完成、已暂停、已取消、archived"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
927
core/web_api.py
Normal file
927
core/web_api.py
Normal file
@ -0,0 +1,927 @@
|
|||||||
|
"""
|
||||||
|
Web API 服务 - 将 Packet 协议映射为 RESTful API
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import argparse
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import hashlib
|
||||||
|
from datetime import timedelta
|
||||||
|
from flask import Flask, request, jsonify, session, redirect, url_for, render_template
|
||||||
|
from flask_cors import CORS
|
||||||
|
|
||||||
|
# 添加项目路径以便导入 core 模块
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
from core.server import BackendServer, Packet, PacketType
|
||||||
|
from core.client import BackendClient
|
||||||
|
from core.activity_recorder import get_recorder
|
||||||
|
from core.embedded_db import EmbeddedGraphDB
|
||||||
|
|
||||||
|
|
||||||
|
LOGIN_MAX_ATTEMPTS = 5 # 最大尝试次数
|
||||||
|
LOGIN_WAIT_MINUTES = 5 # 超过次数后等待分钟数
|
||||||
|
LOGIN_BAN_THRESHOLD = 3 # 超过此轮次后 ban IP
|
||||||
|
LOGIN_BAN_HOURS = 24 # IP ban 时长(小时)
|
||||||
|
|
||||||
|
# 内存记录:{ip: {"attempts": 0, "first_fail": 0, "ban_until": 0, "rounds": 0}}
|
||||||
|
_login_attempts: dict = {}
|
||||||
|
|
||||||
|
def _check_login_limit(ip: str) -> dict:
|
||||||
|
"""检查 IP 的登录限制。返回 {"blocked": bool, "reason": str, "wait_seconds": int}"""
|
||||||
|
now = time.time()
|
||||||
|
record = _login_attempts.get(ip)
|
||||||
|
|
||||||
|
if record:
|
||||||
|
# 检查是否在 ban 中
|
||||||
|
if record["ban_until"] > now:
|
||||||
|
remaining = int(record["ban_until"] - now)
|
||||||
|
return {"blocked": True, "reason": f"IP 已被临时封禁,剩余 {remaining//60} 分钟", "wait_seconds": remaining}
|
||||||
|
|
||||||
|
# 检查是否需要等待(连续失败超过阈值)
|
||||||
|
if record["attempts"] >= LOGIN_MAX_ATTEMPTS:
|
||||||
|
wait_end = record["first_fail"] + LOGIN_WAIT_MINUTES * 60
|
||||||
|
if wait_end > now:
|
||||||
|
remaining = int(wait_end - now)
|
||||||
|
return {"blocked": True, "reason": f"登录尝试过多,请等待 {remaining} 秒后再试", "wait_seconds": remaining}
|
||||||
|
else:
|
||||||
|
# 等待时间已过,重置计数但记录轮次
|
||||||
|
record["rounds"] += 1
|
||||||
|
record["attempts"] = 0
|
||||||
|
record["first_fail"] = 0
|
||||||
|
|
||||||
|
# 如果轮次超过阈值则 ban IP
|
||||||
|
if record["rounds"] >= LOGIN_BAN_THRESHOLD:
|
||||||
|
record["ban_until"] = now + LOGIN_BAN_HOURS * 3600
|
||||||
|
record["rounds"] = 0
|
||||||
|
return {"blocked": True, "reason": f"多次登录失败,IP 已被封禁 {LOGIN_BAN_HOURS} 小时", "wait_seconds": LOGIN_BAN_HOURS * 3600}
|
||||||
|
|
||||||
|
return {"blocked": False, "reason": "", "wait_seconds": 0}
|
||||||
|
|
||||||
|
def _record_login_fail(ip: str):
|
||||||
|
"""记录一次登录失败"""
|
||||||
|
now = time.time()
|
||||||
|
record = _login_attempts.get(ip)
|
||||||
|
if not record:
|
||||||
|
_login_attempts[ip] = {"attempts": 1, "first_fail": now, "ban_until": 0, "rounds": 0}
|
||||||
|
else:
|
||||||
|
if record["first_fail"] == 0:
|
||||||
|
record["first_fail"] = now
|
||||||
|
record["attempts"] += 1
|
||||||
|
|
||||||
|
def _record_login_success(ip: str):
|
||||||
|
"""登录成功后清除该 IP 的记录"""
|
||||||
|
_login_attempts.pop(ip, None)
|
||||||
|
|
||||||
|
# 定期清理过期记录(防止内存泄漏)
|
||||||
|
_cleanup_interval = 3600 # 1小时
|
||||||
|
_last_cleanup = time.time()
|
||||||
|
def load_secret_key():
|
||||||
|
import json
|
||||||
|
config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'web_config.json')
|
||||||
|
defaults = {"SECRET_KEY": "trulymem-secret-key-2026"}
|
||||||
|
if os.path.exists(config_path):
|
||||||
|
with open(config_path, 'r', encoding='utf-8') as f:
|
||||||
|
file_config = json.load(f)
|
||||||
|
if "SECRET_KEY" in file_config:
|
||||||
|
defaults["SECRET_KEY"] = file_config["SECRET_KEY"]
|
||||||
|
return defaults
|
||||||
|
|
||||||
|
WEB_CONFIG = load_secret_key()
|
||||||
|
|
||||||
|
_ui_dir = os.path.join(os.path.dirname(__file__), '..', 'ui')
|
||||||
|
app = Flask(__name__, static_folder=os.path.join(_ui_dir, 'static'), static_url_path='', template_folder=os.path.join(_ui_dir, 'templates'))
|
||||||
|
app.secret_key = WEB_CONFIG["SECRET_KEY"]
|
||||||
|
app.permanent_session_lifetime = timedelta(days=7)
|
||||||
|
# 显式配置 session cookie,确保跨场景兼容
|
||||||
|
app.config['SESSION_COOKIE_HTTPONLY'] = True
|
||||||
|
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
|
||||||
|
app.config['SESSION_COOKIE_SECURE'] = False # HTTP 环境不强制 Secure
|
||||||
|
app.config['SESSION_COOKIE_NAME'] = 'trulymem_session'
|
||||||
|
CORS(app, supports_credentials=True) # 启用跨域支持,支持 session cookies
|
||||||
|
|
||||||
|
|
||||||
|
def login_required(f):
|
||||||
|
"""登录验证装饰器"""
|
||||||
|
from functools import wraps
|
||||||
|
@wraps(f)
|
||||||
|
def decorated_function(*args, **kwargs):
|
||||||
|
if not session.get('authenticated'):
|
||||||
|
return redirect('/login')
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated_function
|
||||||
|
|
||||||
|
|
||||||
|
def api_login_required(f):
|
||||||
|
"""API 登录验证装饰器"""
|
||||||
|
from functools import wraps
|
||||||
|
@wraps(f)
|
||||||
|
def decorated_function(*args, **kwargs):
|
||||||
|
if not session.get('authenticated'):
|
||||||
|
return jsonify({"success": False, "error": "未登录"}), 401
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated_function
|
||||||
|
|
||||||
|
|
||||||
|
def admin_required(f):
|
||||||
|
"""管理员权限验证装饰器"""
|
||||||
|
from functools import wraps
|
||||||
|
@wraps(f)
|
||||||
|
def decorated_function(*args, **kwargs):
|
||||||
|
username = session.get('username', '')
|
||||||
|
g_db = get_global_db()
|
||||||
|
if not g_db or not g_db.is_admin(username):
|
||||||
|
return jsonify({"success": False, "error": "权限不足,需要管理员权限"}), 403
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated_function
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/')
|
||||||
|
@login_required
|
||||||
|
def index():
|
||||||
|
"""返回星图页面(默认首页)"""
|
||||||
|
return app.send_static_file('graph.html')
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/graph.html')
|
||||||
|
@login_required
|
||||||
|
def graph_html():
|
||||||
|
"""返回星图页面"""
|
||||||
|
return app.send_static_file('graph.html')
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/static/<path:filename>')
|
||||||
|
def static_files(filename):
|
||||||
|
"""提供静态文件访问"""
|
||||||
|
return app.send_static_file(filename)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/chat')
|
||||||
|
@login_required
|
||||||
|
def chat():
|
||||||
|
"""返回聊天页面"""
|
||||||
|
return app.send_static_file('index.html')
|
||||||
|
|
||||||
|
# 全局服务器和客户端实例
|
||||||
|
backend_server: BackendServer = None
|
||||||
|
backend_client: BackendClient = None
|
||||||
|
server_thread: threading.Thread = None
|
||||||
|
graph_db: EmbeddedGraphDB = None
|
||||||
|
global_db: EmbeddedGraphDB = None # 全局数据库(用于用户管理)
|
||||||
|
|
||||||
|
|
||||||
|
def get_global_db():
|
||||||
|
"""获取全局数据库实例"""
|
||||||
|
global global_db
|
||||||
|
if global_db is None:
|
||||||
|
global_db_path = os.path.join(os.path.expanduser("~"), ".trulymem", "trulymem.db")
|
||||||
|
if os.path.exists(global_db_path):
|
||||||
|
global_db = EmbeddedGraphDB(db_path=global_db_path)
|
||||||
|
return global_db
|
||||||
|
|
||||||
|
|
||||||
|
def create_server(username: str = ""):
|
||||||
|
"""创建并启动 BackendServer 后台线程"""
|
||||||
|
global backend_server, backend_client, server_thread, graph_db
|
||||||
|
|
||||||
|
backend_server = BackendServer(username=username)
|
||||||
|
backend_server.start()
|
||||||
|
|
||||||
|
backend_client = BackendClient(backend_server)
|
||||||
|
|
||||||
|
# 创建图数据库实例(连接用户的数据库文件)
|
||||||
|
graph_db = EmbeddedGraphDB(db_path=backend_server._db_path)
|
||||||
|
|
||||||
|
# 等待服务器初始化完成
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
|
||||||
|
def reload_server_for_user(username: str):
|
||||||
|
"""为指定用户重新加载服务器"""
|
||||||
|
global backend_server, backend_client, graph_db
|
||||||
|
|
||||||
|
# 关闭旧的服务器
|
||||||
|
if backend_server:
|
||||||
|
backend_server.shutdown()
|
||||||
|
|
||||||
|
# 创建新的服务器(使用用户的数据库)
|
||||||
|
create_server(username=username)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/login')
|
||||||
|
def login_page():
|
||||||
|
"""登录页面 - 如果没有用户则重定向到设置页"""
|
||||||
|
# 如果没有用户,重定向到首次设置页
|
||||||
|
users_count = 0
|
||||||
|
g_db = get_global_db()
|
||||||
|
if g_db:
|
||||||
|
users_count = g_db.get_web_users_count()
|
||||||
|
elif graph_db:
|
||||||
|
users_count = graph_db.get_web_users_count()
|
||||||
|
if users_count == 0:
|
||||||
|
return redirect('/setup')
|
||||||
|
return render_template('login.html')
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/setup')
|
||||||
|
def setup_page():
|
||||||
|
"""首次设置页面 - 如果已有用户则跳转到登录页"""
|
||||||
|
has_users = False
|
||||||
|
g_db = get_global_db()
|
||||||
|
if g_db:
|
||||||
|
has_users = g_db.get_web_users_count() > 0
|
||||||
|
elif graph_db and graph_db is not g_db:
|
||||||
|
has_users = graph_db.get_web_users_count() > 0
|
||||||
|
if has_users:
|
||||||
|
return redirect('/login')
|
||||||
|
return render_template('setup.html')
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/settings')
|
||||||
|
@api_login_required
|
||||||
|
def settings_page():
|
||||||
|
"""Web 设置页面"""
|
||||||
|
return render_template('settings.html')
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/login', methods=['POST'])
|
||||||
|
def api_login():
|
||||||
|
"""登录接口"""
|
||||||
|
data = request.get_json() or {}
|
||||||
|
username = data.get('username', '')
|
||||||
|
password = data.get('password', '')
|
||||||
|
|
||||||
|
# DEBUG: 记录收到的凭据
|
||||||
|
import logging
|
||||||
|
logging.warning(f"[DEBUG api_login] username={username}, password_length={len(password)}, password_first_char={password[:1] if password else 'EMPTY'}")
|
||||||
|
|
||||||
|
# 登录限流检查
|
||||||
|
ip = request.remote_addr
|
||||||
|
limit_check = _check_login_limit(ip)
|
||||||
|
if limit_check["blocked"]:
|
||||||
|
logging.warning(f"[DEBUG api_login] IP blocked: {ip}, reason: {limit_check['reason']}")
|
||||||
|
return jsonify({"success": False, "error": limit_check["reason"]})
|
||||||
|
|
||||||
|
# 从全局数据库验证
|
||||||
|
g_db = get_global_db()
|
||||||
|
if g_db:
|
||||||
|
logging.warning(f"[DEBUG api_login] global_db path: {g_db.db_path if hasattr(g_db, 'db_path') else 'unknown'}")
|
||||||
|
is_valid = g_db.verify_web_user(username, password)
|
||||||
|
logging.warning(f"[DEBUG api_login] verify result: {is_valid}")
|
||||||
|
if is_valid:
|
||||||
|
session['authenticated'] = True
|
||||||
|
session['username'] = username
|
||||||
|
session.permanent = True
|
||||||
|
reload_server_for_user(username)
|
||||||
|
logging.warning(f"[DEBUG api_login] Login SUCCESS for {username}")
|
||||||
|
return jsonify({"success": True})
|
||||||
|
else:
|
||||||
|
# 登录失败,记录失败
|
||||||
|
_record_login_fail(ip)
|
||||||
|
logging.warning(f"[DEBUG api_login] Login FAIL for {username}, wrong password")
|
||||||
|
return jsonify({"success": False, "error": "用户名或密码错误"})
|
||||||
|
else:
|
||||||
|
logging.warning(f"[DEBUG api_login] global_db is None!")
|
||||||
|
return jsonify({"success": False, "error": "数据库未初始化"})
|
||||||
|
|
||||||
|
@app.route('/api/logout', methods=['POST'])
|
||||||
|
def api_logout():
|
||||||
|
"""登出接口"""
|
||||||
|
session.clear()
|
||||||
|
return jsonify({"success": True})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/check-auth', methods=['GET'])
|
||||||
|
def check_auth():
|
||||||
|
"""检查登录状态"""
|
||||||
|
return jsonify({"authenticated": bool(session.get('authenticated'))})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/userinfo', methods=['GET'])
|
||||||
|
def userinfo():
|
||||||
|
"""获取当前登录用户信息(含角色)"""
|
||||||
|
if not session.get('authenticated'):
|
||||||
|
return jsonify({"success": False, "error": "未登录"}), 401
|
||||||
|
username = session.get('username', '')
|
||||||
|
g_db = get_global_db()
|
||||||
|
if not g_db:
|
||||||
|
return jsonify({"success": False, "error": "数据库未初始化"}), 500
|
||||||
|
user = g_db.get_web_user(username)
|
||||||
|
if not user:
|
||||||
|
return jsonify({"success": False, "error": "用户不存在"}), 404
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"username": user['username'],
|
||||||
|
"role": user.get('role', 'user'),
|
||||||
|
"is_admin": user.get('role') == 'admin',
|
||||||
|
"created_at": user.get('created_at')
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/web-check', methods=['GET'])
|
||||||
|
def web_check():
|
||||||
|
"""检查是否需要首次设置,返回是否配置完成"""
|
||||||
|
users_count = 0
|
||||||
|
if graph_db:
|
||||||
|
users_count = graph_db.get_web_users_count()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"needs_setup": users_count == 0,
|
||||||
|
"users_count": users_count
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/web-users', methods=['GET'])
|
||||||
|
@api_login_required
|
||||||
|
def web_users():
|
||||||
|
"""获取 web_users 列表"""
|
||||||
|
if graph_db:
|
||||||
|
users = graph_db.get_web_users()
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"users": [
|
||||||
|
{
|
||||||
|
"username": u['username'],
|
||||||
|
"role": u.get('role', 'user'),
|
||||||
|
"is_admin": u.get('role') == 'admin',
|
||||||
|
"created_at": u.get('created_at')
|
||||||
|
}
|
||||||
|
for u in users
|
||||||
|
]
|
||||||
|
})
|
||||||
|
return jsonify({"success": False, "error": "数据库未初始化"}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/web-user/<username>', methods=['GET'])
|
||||||
|
@api_login_required
|
||||||
|
def web_user_detail(username):
|
||||||
|
"""获取单个 web_user 详情"""
|
||||||
|
if not graph_db:
|
||||||
|
return jsonify({"success": False, "error": "数据库未初始化"}), 500
|
||||||
|
user = graph_db.get_web_user(username)
|
||||||
|
if not user:
|
||||||
|
return jsonify({"success": False, "error": "用户不存在"}), 404
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"username": user['username'],
|
||||||
|
"role": user.get('role', 'user'),
|
||||||
|
"is_admin": user.get('role') == 'admin',
|
||||||
|
"created_at": user.get('created_at')
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/setup', methods=['POST'])
|
||||||
|
def api_setup():
|
||||||
|
"""首次设置 - 创建初始管理员用户"""
|
||||||
|
# 只有没有任何用户时才允许设置
|
||||||
|
g_db = get_global_db()
|
||||||
|
# Log the count for debugging
|
||||||
|
if g_db:
|
||||||
|
import logging
|
||||||
|
count = g_db.get_web_users_count()
|
||||||
|
logging.warning(f"[api_setup] web_users count: {count}")
|
||||||
|
if g_db and g_db.get_web_users_count() > 0:
|
||||||
|
return jsonify({"success": False, "error": "用户已存在,不允许重复设置"}), 400
|
||||||
|
|
||||||
|
data = request.get_json() or {}
|
||||||
|
username = data.get('username', '')
|
||||||
|
password = data.get('password', '')
|
||||||
|
confirm = data.get('confirm_password', '')
|
||||||
|
|
||||||
|
if not username or not password:
|
||||||
|
return jsonify({"success": False, "error": "用户名和密码不能为空"}), 400
|
||||||
|
|
||||||
|
if password != confirm:
|
||||||
|
return jsonify({"success": False, "error": "两次密码输入不一致"}), 400
|
||||||
|
|
||||||
|
if len(password) < 6:
|
||||||
|
return jsonify({"success": False, "error": "密码长度至少 6 位"}), 400
|
||||||
|
|
||||||
|
# 使用全局数据库创建用户
|
||||||
|
if g_db is None:
|
||||||
|
# 如果全局数据库不存在,创建它
|
||||||
|
global_db_path = os.path.join(os.path.expanduser("~"), ".trulymem", "trulymem.db")
|
||||||
|
g_db = EmbeddedGraphDB(db_path=global_db_path)
|
||||||
|
global global_db
|
||||||
|
global_db = g_db
|
||||||
|
|
||||||
|
result = g_db.set_web_user(username, password)
|
||||||
|
if result.get("success"):
|
||||||
|
# 设置完成后自动登录
|
||||||
|
session['authenticated'] = True
|
||||||
|
session['username'] = username
|
||||||
|
session.permanent = True
|
||||||
|
return jsonify({"success": True, "message": "用户创建成功"})
|
||||||
|
|
||||||
|
return jsonify({"success": False, "error": "创建用户失败"}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/change-password', methods=['POST'])
|
||||||
|
@api_login_required
|
||||||
|
def api_change_password():
|
||||||
|
"""修改 Web 登录密码"""
|
||||||
|
data = request.get_json() or {}
|
||||||
|
current_password = data.get('current_password', '')
|
||||||
|
new_password = data.get('new_password', '')
|
||||||
|
confirm_password = data.get('confirm_password', '')
|
||||||
|
|
||||||
|
if not new_password:
|
||||||
|
return jsonify({"success": False, "error": "新密码不能为空"}), 400
|
||||||
|
|
||||||
|
if new_password != confirm_password:
|
||||||
|
return jsonify({"success": False, "error": "两次密码输入不一致"}), 400
|
||||||
|
|
||||||
|
if len(new_password) < 6:
|
||||||
|
return jsonify({"success": False, "error": "密码长度至少 6 位"}), 400
|
||||||
|
|
||||||
|
# 获取当前登录用户
|
||||||
|
current_username = session.get('username', '')
|
||||||
|
if not current_username:
|
||||||
|
return jsonify({"success": False, "error": "无法识别当前用户"}), 400
|
||||||
|
|
||||||
|
# 验证当前密码(使用全局数据库)
|
||||||
|
g_db = get_global_db()
|
||||||
|
if not g_db or not g_db.verify_web_user(current_username, current_password):
|
||||||
|
return jsonify({"success": False, "error": "当前密码错误"}), 400
|
||||||
|
|
||||||
|
result = g_db.set_web_user(current_username, new_password)
|
||||||
|
if result.get("success"):
|
||||||
|
return jsonify({"success": True, "message": "密码已更新"})
|
||||||
|
|
||||||
|
return jsonify({"success": False, "error": "修改密码失败"}), 500
|
||||||
|
|
||||||
|
|
||||||
|
# ========== 管理员 API ==========
|
||||||
|
|
||||||
|
@app.route('/api/admin/users', endpoint='api_admin_get_users', methods=['GET'])
|
||||||
|
@api_login_required
|
||||||
|
@admin_required
|
||||||
|
def api_admin_get_users():
|
||||||
|
"""获取用户列表"""
|
||||||
|
g_db = get_global_db()
|
||||||
|
if not g_db:
|
||||||
|
return jsonify({"success": False, "error": "全局数据库未初始化"}), 500
|
||||||
|
|
||||||
|
users = g_db.get_web_users()
|
||||||
|
return jsonify({"success": True, "users": users})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/admin/users', endpoint='api_admin_add_user', methods=['POST'])
|
||||||
|
@api_login_required
|
||||||
|
@admin_required
|
||||||
|
def api_admin_add_user():
|
||||||
|
"""管理员添加用户"""
|
||||||
|
data = request.get_json() or {}
|
||||||
|
username = data.get('username', '')
|
||||||
|
password = data.get('password', '')
|
||||||
|
|
||||||
|
if not username or not password:
|
||||||
|
return jsonify({"success": False, "error": "用户名和密码不能为空"}), 400
|
||||||
|
|
||||||
|
if len(password) < 6:
|
||||||
|
return jsonify({"success": False, "error": "密码长度至少 6 位"}), 400
|
||||||
|
|
||||||
|
g_db = get_global_db()
|
||||||
|
if not g_db:
|
||||||
|
return jsonify({"success": False, "error": "全局数据库未初始化"}), 500
|
||||||
|
|
||||||
|
result = g_db.set_web_user(username, password)
|
||||||
|
if result.get("success"):
|
||||||
|
return jsonify({"success": True, "message": "用户添加成功", "user": result})
|
||||||
|
|
||||||
|
return jsonify({"success": False, "error": "添加用户失败"}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/admin/users/<int:user_id>', methods=['DELETE'])
|
||||||
|
@api_login_required
|
||||||
|
@admin_required
|
||||||
|
def api_admin_delete_user(user_id):
|
||||||
|
"""管理员删除用户"""
|
||||||
|
g_db = get_global_db()
|
||||||
|
if not g_db:
|
||||||
|
return jsonify({"success": False, "error": "全局数据库未初始化"}), 500
|
||||||
|
|
||||||
|
# 获取所有用户
|
||||||
|
users = g_db.get_web_users()
|
||||||
|
|
||||||
|
# 查找要删除的用户
|
||||||
|
target_user = None
|
||||||
|
for u in users:
|
||||||
|
if u['id'] == user_id:
|
||||||
|
target_user = u
|
||||||
|
break
|
||||||
|
|
||||||
|
if not target_user:
|
||||||
|
return jsonify({"success": False, "error": "用户不存在"}), 404
|
||||||
|
|
||||||
|
# 不能删除自己
|
||||||
|
current_username = session.get('username', '')
|
||||||
|
if target_user['username'] == current_username:
|
||||||
|
return jsonify({"success": False, "error": "不能删除当前登录的用户"}), 400
|
||||||
|
|
||||||
|
# 不能删除最后一个 admin
|
||||||
|
admin_count = sum(1 for u in users if u.get('role') == 'admin')
|
||||||
|
if target_user.get('role') == 'admin' and admin_count <= 1:
|
||||||
|
return jsonify({"success": False, "error": "不能删除最后一个管理员"}), 400
|
||||||
|
|
||||||
|
# 删除用户(保留文件目录)
|
||||||
|
result = g_db.delete_web_user(target_user['username'])
|
||||||
|
if not result.get('success'):
|
||||||
|
return jsonify({"success": False, "error": result.get('error', '删除失败')}), 500
|
||||||
|
|
||||||
|
return jsonify({"success": True, "message": "用户已删除"})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/admin/migrate-check', methods=['GET'])
|
||||||
|
def api_admin_migrate_check():
|
||||||
|
"""检测系统是否需要迁移"""
|
||||||
|
from core.migrate import need_migration, is_migrated
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"need_migration": need_migration(),
|
||||||
|
"is_migrated": is_migrated()
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/admin/migrate', methods=['POST'])
|
||||||
|
def api_admin_migrate():
|
||||||
|
"""执行迁移+创建首个用户"""
|
||||||
|
from core.migrate import need_migration, run_migration
|
||||||
|
|
||||||
|
if not need_migration():
|
||||||
|
return jsonify({"success": False, "error": "不需要迁移"}), 400
|
||||||
|
|
||||||
|
data = request.get_json() or {}
|
||||||
|
username = data.get('username', '')
|
||||||
|
password = data.get('password', '')
|
||||||
|
|
||||||
|
if not username or not password:
|
||||||
|
return jsonify({"success": False, "error": "用户名和密码不能为空"}), 400
|
||||||
|
|
||||||
|
if len(password) < 6:
|
||||||
|
return jsonify({"success": False, "error": "密码长度至少 6 位"}), 400
|
||||||
|
|
||||||
|
result = run_migration(username, password)
|
||||||
|
|
||||||
|
if result.get("success"):
|
||||||
|
# 自动登录
|
||||||
|
session['authenticated'] = True
|
||||||
|
session['username'] = username
|
||||||
|
session.permanent = True
|
||||||
|
|
||||||
|
# 重新加载服务器
|
||||||
|
reload_server_for_user(username)
|
||||||
|
|
||||||
|
return jsonify({"success": True, "message": "迁移完成", "data": result})
|
||||||
|
|
||||||
|
return jsonify({"success": False, "error": result.get("error", "迁移失败")}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/settings/config', methods=['GET', 'POST', 'PUT'])
|
||||||
|
@api_login_required
|
||||||
|
def web_settings_config():
|
||||||
|
"""获取/更新当前登录用户的配置"""
|
||||||
|
if request.method == 'GET':
|
||||||
|
settings = {}
|
||||||
|
if backend_client:
|
||||||
|
result = backend_client.get_settings()
|
||||||
|
settings = result.get("data", {}) if isinstance(result, dict) else result
|
||||||
|
if isinstance(settings, dict):
|
||||||
|
settings = settings.get("api_config", {}) if "api_config" in settings else settings
|
||||||
|
# 从 settings 中提取相关字段
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"enable_web": settings.get("enable_web", False),
|
||||||
|
"web_port": settings.get("web_port", 4096),
|
||||||
|
"enable_tui": settings.get("enable_tui", True),
|
||||||
|
})
|
||||||
|
|
||||||
|
# PUT/POST 更新
|
||||||
|
data = request.get_json() or {}
|
||||||
|
enable_tui = data.get('enable_tui')
|
||||||
|
|
||||||
|
if enable_tui is not None and backend_client:
|
||||||
|
# 获取当前配置,合并更新
|
||||||
|
current = backend_client.get_settings()
|
||||||
|
current_data = current.get("data", {}) if isinstance(current, dict) else {}
|
||||||
|
|
||||||
|
tool_limits = current_data.get("tool_limits", {})
|
||||||
|
api_config = current_data.get("api_config", {})
|
||||||
|
api_config["enable_tui"] = bool(enable_tui)
|
||||||
|
|
||||||
|
result = backend_client.update_settings(api_config, tool_limits)
|
||||||
|
return jsonify({"success": True, "enable_tui": bool(enable_tui)})
|
||||||
|
|
||||||
|
return jsonify({"success": False, "error": "没有需要更新的配置"}), 400
|
||||||
|
|
||||||
|
|
||||||
|
@app.errorhandler(404)
|
||||||
|
def not_found(e):
|
||||||
|
"""404 处理"""
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"error": "404 Not Found"
|
||||||
|
}), 404
|
||||||
|
|
||||||
|
|
||||||
|
@app.errorhandler(500)
|
||||||
|
def server_error(e):
|
||||||
|
"""500 处理"""
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"error": str(e.original_exception if hasattr(e, 'original_exception') else e)
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/message', methods=['POST'])
|
||||||
|
@api_login_required
|
||||||
|
def process_message():
|
||||||
|
"""发送消息给 AI - PROCESS_MESSAGE"""
|
||||||
|
data = request.get_json() or {}
|
||||||
|
user_input = data.get('message', '')
|
||||||
|
|
||||||
|
if not user_input:
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"error": "message 参数不能为空"
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
result = backend_client.process_message(user_input)
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/tools/execute', methods=['POST'])
|
||||||
|
@api_login_required
|
||||||
|
def execute_tool():
|
||||||
|
"""直接执行工具 - EXECUTE_TOOL"""
|
||||||
|
data = request.get_json() or {}
|
||||||
|
tool_name = data.get('tool_name', '')
|
||||||
|
arguments = data.get('arguments', {})
|
||||||
|
|
||||||
|
if not tool_name:
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"error": "tool_name 参数不能为空"
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
result = backend_client.execute_tool(tool_name, arguments)
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/status', methods=['GET'])
|
||||||
|
@api_login_required
|
||||||
|
def get_status():
|
||||||
|
"""获取状态 - GET_STATUS"""
|
||||||
|
result = backend_client.get_status()
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/settings', methods=['GET'])
|
||||||
|
@api_login_required
|
||||||
|
def get_settings():
|
||||||
|
"""获取配置 - GET_SETTINGS"""
|
||||||
|
result = backend_client.get_settings()
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/settings', methods=['PUT'])
|
||||||
|
@api_login_required
|
||||||
|
def set_settings():
|
||||||
|
"""更新配置 - SET_SETTINGS"""
|
||||||
|
data = request.get_json() or {}
|
||||||
|
|
||||||
|
api_config = data.get('api_config', {})
|
||||||
|
tool_limits = data.get('tool_limits', {})
|
||||||
|
|
||||||
|
result = backend_client.update_settings(api_config, tool_limits)
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/history', methods=['GET'])
|
||||||
|
@api_login_required
|
||||||
|
def get_history():
|
||||||
|
"""获取历史 - GET_HISTORY"""
|
||||||
|
result = backend_client.get_history()
|
||||||
|
return jsonify({"success": True, "history": result})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/history', methods=['DELETE'])
|
||||||
|
@api_login_required
|
||||||
|
def clear_history():
|
||||||
|
"""清空历史 - SAVE_HISTORY(空)"""
|
||||||
|
result = backend_client.clear_history()
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/shutdown', methods=['POST'])
|
||||||
|
@api_login_required
|
||||||
|
def shutdown():
|
||||||
|
"""关闭服务器 - SHUTDOWN"""
|
||||||
|
backend_client.shutdown()
|
||||||
|
return jsonify({"success": True, "status": "shutdown"})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/activity', methods=['GET'])
|
||||||
|
@api_login_required
|
||||||
|
def get_activity():
|
||||||
|
"""获取当前轮的数据库操作记录"""
|
||||||
|
recorder = get_recorder()
|
||||||
|
records = recorder.get_all()
|
||||||
|
summary = recorder.get_summary()
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"data": {
|
||||||
|
"records": records,
|
||||||
|
"summary": summary
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/graph', methods=['GET'])
|
||||||
|
@api_login_required
|
||||||
|
def get_graph():
|
||||||
|
"""返回全量图数据"""
|
||||||
|
global graph_db
|
||||||
|
|
||||||
|
if graph_db is None:
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"error": "图数据库未初始化"
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
cursor = graph_db.conn.cursor()
|
||||||
|
|
||||||
|
# 查询实体(节点)
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT id, name, type, mention_count
|
||||||
|
FROM entities
|
||||||
|
ORDER BY mention_count DESC
|
||||||
|
""")
|
||||||
|
|
||||||
|
nodes = []
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
nodes.append({
|
||||||
|
"id": row['id'],
|
||||||
|
"name": row['name'],
|
||||||
|
"type": str(row['type'] or 'unknown'),
|
||||||
|
"mention_count": row['mention_count']
|
||||||
|
})
|
||||||
|
|
||||||
|
# 查询关系(边)
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT r.id, r.source_id, r.target_id, r.relation_type, r.confidence, r.status
|
||||||
|
FROM relations r
|
||||||
|
WHERE r.status = 'active'
|
||||||
|
""")
|
||||||
|
|
||||||
|
edges = []
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
edges.append({
|
||||||
|
"id": row['id'],
|
||||||
|
"source": row['source_id'],
|
||||||
|
"target": row['target_id'],
|
||||||
|
"relation_type": row['relation_type'],
|
||||||
|
"confidence": row['confidence'],
|
||||||
|
"status": row['status']
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"nodes": nodes,
|
||||||
|
"edges": edges,
|
||||||
|
"stats": {
|
||||||
|
"node_count": len(nodes),
|
||||||
|
"edge_count": len(edges)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/graph/highlight', methods=['GET'])
|
||||||
|
@api_login_required
|
||||||
|
def get_graph_highlight():
|
||||||
|
"""返回需要高亮的节点ID列表"""
|
||||||
|
recorder = get_recorder()
|
||||||
|
records = recorder.get_all()
|
||||||
|
|
||||||
|
highlight_ids = []
|
||||||
|
new_node_id = None
|
||||||
|
new_edge = None
|
||||||
|
|
||||||
|
if graph_db is None:
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"data": {
|
||||||
|
"highlight_ids": [],
|
||||||
|
"new_node_id": None,
|
||||||
|
"new_edge": None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
# 从最近的记录中提取实体ID
|
||||||
|
for record in records[-10:]: # 只看最近10条记录
|
||||||
|
entity_name = record.get('entity', '')
|
||||||
|
if entity_name:
|
||||||
|
cursor = graph_db.conn.cursor()
|
||||||
|
cursor.execute("SELECT id FROM entities WHERE name = ?", (entity_name,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
highlight_ids.append(row['id'])
|
||||||
|
|
||||||
|
# 除删除外,所有操作都拉镜头(create/recall/query/update/archive 等)
|
||||||
|
if record.get('action') != 'delete' and entity_name:
|
||||||
|
cursor = graph_db.conn.cursor()
|
||||||
|
cursor.execute("SELECT id FROM entities WHERE name = ?", (entity_name,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
new_node_id = row['id']
|
||||||
|
|
||||||
|
# 检查是否有删除的节点
|
||||||
|
deleted_node_ids = []
|
||||||
|
for record in records[-10:]:
|
||||||
|
if record.get('action') == 'delete':
|
||||||
|
entity_name = record.get('entity', '')
|
||||||
|
if entity_name:
|
||||||
|
try:
|
||||||
|
cursor = graph_db.conn.cursor()
|
||||||
|
cursor.execute("SELECT id FROM entities WHERE name = ?", (entity_name,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
deleted_node_ids.append(row['id'])
|
||||||
|
except Exception:
|
||||||
|
pass # 实体可能已被删除,忽略错误
|
||||||
|
|
||||||
|
# 去重
|
||||||
|
highlight_ids = list(set(highlight_ids))
|
||||||
|
deleted_node_ids = list(set(deleted_node_ids))
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"highlight_ids": highlight_ids,
|
||||||
|
"new_node_id": new_node_id,
|
||||||
|
"new_edge": new_edge,
|
||||||
|
"deleted_node_ids": deleted_node_ids
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# ── 可被 TUI 作为线程启动 ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_web_thread: threading.Thread | None = None
|
||||||
|
_http_server = None # werkzeug.serving.BaseWSGIServer 引用,用于优雅停止
|
||||||
|
|
||||||
|
|
||||||
|
def run_web_server(port: int = 4096, host: str = '0.0.0.0') -> None:
|
||||||
|
"""在后台线程启动 Flask,供 TUI 或入口脚本在进程中直接调用"""
|
||||||
|
global backend_server, backend_client, _web_thread, _http_server
|
||||||
|
|
||||||
|
if _http_server is not None:
|
||||||
|
return # 已在运行
|
||||||
|
|
||||||
|
# 自动初始化后端(如果还没初始化的话)
|
||||||
|
if backend_server is None:
|
||||||
|
create_server()
|
||||||
|
|
||||||
|
def _start():
|
||||||
|
global _http_server
|
||||||
|
try:
|
||||||
|
from werkzeug.serving import make_server
|
||||||
|
_http_server = make_server(host, port, app, threaded=True)
|
||||||
|
print(f"Web API 服务启动在 http://{host}:{port}")
|
||||||
|
_http_server.serve_forever()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Web 服务启动失败: {e}")
|
||||||
|
_http_server = None
|
||||||
|
finally:
|
||||||
|
_http_server = None
|
||||||
|
|
||||||
|
_web_thread = threading.Thread(target=_start, daemon=True)
|
||||||
|
_web_thread.start()
|
||||||
|
|
||||||
|
|
||||||
|
def stop_web_server() -> None:
|
||||||
|
"""停止 Web 服务线程"""
|
||||||
|
global _http_server, _web_thread
|
||||||
|
if _http_server:
|
||||||
|
try:
|
||||||
|
_http_server.shutdown()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_http_server = None
|
||||||
|
_web_thread = None
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
parser = argparse.ArgumentParser(description='TrulyMEM Web API 服务')
|
||||||
|
parser.add_argument('--port', type=int, default=5000, help='服务端口 (默认: 5000)')
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# 启动后端服务器
|
||||||
|
print("正在启动 BackendServer...")
|
||||||
|
create_server()
|
||||||
|
print("BackendServer 已启动")
|
||||||
|
|
||||||
|
# 启动 Flask 应用
|
||||||
|
print(f"Web API 服务启动在 http://0.0.0.0:{args.port}")
|
||||||
|
app.run(host='0.0.0.0', port=args.port, debug=False)
|
||||||
6
core/web_config.example.json
Normal file
6
core/web_config.example.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"SECRET_KEY": "change-this-to-a-random-secret-key",
|
||||||
|
"USERS": {
|
||||||
|
"admin": "SHA256_OF_YOUR_PASSWORD"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -179,10 +179,8 @@ body = {} # No parameters
|
|||||||
"model": str # Model name
|
"model": str # Model name
|
||||||
},
|
},
|
||||||
"tool_limits": {
|
"tool_limits": {
|
||||||
"persona_query_max": int, # Persona graph query limit
|
|
||||||
"persona_update_max": int, # Persona graph update limit
|
"persona_update_max": int, # Persona graph update limit
|
||||||
"task_query_max": int, # Working memory query limit
|
"task_update_max": int, # Working memory chain update limit
|
||||||
"task_update_max": int, # Working memory update limit
|
|
||||||
"memory_query_max": int, # General memory query limit
|
"memory_query_max": int, # General memory query limit
|
||||||
"memory_update_max": int # General memory update limit
|
"memory_update_max": int # General memory update limit
|
||||||
}
|
}
|
||||||
@ -211,9 +209,7 @@ body = {
|
|||||||
"model": str # Model name (default: deepseek-chat)
|
"model": str # Model name (default: deepseek-chat)
|
||||||
},
|
},
|
||||||
"tool_limits": {
|
"tool_limits": {
|
||||||
"persona_query_max": int, # Persona query limit (≥1)
|
|
||||||
"persona_update_max": int, # Persona update limit (≥1)
|
"persona_update_max": int, # Persona update limit (≥1)
|
||||||
"task_query_max": int, # Working memory query limit (≥1)
|
|
||||||
"task_update_max": int, # Working memory update limit (≥1)
|
"task_update_max": int, # Working memory update limit (≥1)
|
||||||
"memory_query_max": int, # General memory query limit (≥1)
|
"memory_query_max": int, # General memory query limit (≥1)
|
||||||
"memory_update_max": int # General memory update limit (≥1)
|
"memory_update_max": int # General memory update limit (≥1)
|
||||||
@ -237,8 +233,8 @@ result = client.update_settings(
|
|||||||
"model": "deepseek-chat"
|
"model": "deepseek-chat"
|
||||||
},
|
},
|
||||||
tool_limits={
|
tool_limits={
|
||||||
"persona_query_max": 2,
|
"persona_update_max": 2,
|
||||||
"task_query_max": 5,
|
"task_update_max": 5,
|
||||||
"memory_query_max": 30
|
"memory_query_max": 30
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@ -472,12 +468,11 @@ asyncio.run(main())
|
|||||||
|
|
||||||
| Category | Operation | Per-Turn Limit |
|
| Category | Operation | Per-Turn Limit |
|
||||||
|----------|-----------|---------------|
|
|----------|-----------|---------------|
|
||||||
| Persona graph | Query | 1 time |
|
|
||||||
| Persona graph | Modify | 1 time |
|
| Persona graph | Modify | 1 time |
|
||||||
| Working memory chain | Query | 4 times |
|
| Working memory chain | Modify | 5 times |
|
||||||
| Working memory chain | Modify | 2 times |
|
|
||||||
| General memory | Query | 20 times |
|
| General memory | Query | 20 times |
|
||||||
| General memory | Modify | 10 times |
|
| General memory | Modify | 10 times |
|
||||||
|
| Context compression | Query | Counted as general memory query |
|
||||||
|
|
||||||
### Reset Mechanism
|
### Reset Mechanism
|
||||||
|
|
||||||
@ -511,3 +506,24 @@ Common errors:
|
|||||||
| `API Key not configured` | API Key not set |
|
| `API Key not configured` | API Key not set |
|
||||||
| `timeout` | Request timeout |
|
| `timeout` | Request timeout |
|
||||||
| `Tool call rejected: ...` | Tool call rate exceeded limit |
|
| `Tool call rejected: ...` | Tool call rate exceeded limit |
|
||||||
|
|
||||||
|
## Web API Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | /api/check-auth | Check if current session is authenticated |
|
||||||
|
| POST | /api/login | Login (JSON body: username, password) |
|
||||||
|
| POST | /api/logout | Logout |
|
||||||
|
| GET | /api/history | Get chat history |
|
||||||
|
| POST | /api/message | Send message to AI |
|
||||||
|
| POST | /api/tools/execute | Execute tool call |
|
||||||
|
| GET | /api/status | Get system status |
|
||||||
|
| GET | /api/settings | Get settings |
|
||||||
|
| PUT | /api/settings | Update settings |
|
||||||
|
| DELETE | /api/history | Clear history |
|
||||||
|
| POST | /api/shutdown | Shutdown server |
|
||||||
|
| GET | /api/activity | Get database operation records |
|
||||||
|
| GET | /api/graph | Get knowledge graph data |
|
||||||
|
| GET | /api/graph/highlight | Get highlighted nodes |
|
||||||
|
|
||||||
|
All API endpoints (except /api/login and /api/check-auth) require authentication. Login uses Flask sessions with 7-day validity.
|
||||||
@ -8,6 +8,66 @@
|
|||||||
- TUI & backend separation, multi-threaded communication
|
- TUI & backend separation, multi-threaded communication
|
||||||
- **Everything is a graph**, AI reasoning runs entirely in backend
|
- **Everything is a graph**, AI reasoning runs entirely in backend
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
### Development (Run directly from Git repo)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd TrulyMEM-TrueHumanMEM
|
||||||
|
python3 trulymem_entry.py --web --port 4096
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production (Systemd + standalone directory)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Copy code to standalone deployment directory
|
||||||
|
cp -r TrulyMEM-TrueHumanMEM /home/trulymem
|
||||||
|
|
||||||
|
# Create Systemd service
|
||||||
|
cat > /etc/systemd/system/trulymem-web.service << 'EOF'
|
||||||
|
[Unit]
|
||||||
|
Description=TrulyMEM - True Human Memory (Web Mode)
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=root
|
||||||
|
WorkingDirectory=/home/trulymem
|
||||||
|
ExecStart=/usr/bin/python3 /home/trulymem/trulymem_entry.py --web --port 4096
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable trulymem-web.service
|
||||||
|
systemctl start trulymem-web.service
|
||||||
|
|
||||||
|
# Check status
|
||||||
|
systemctl status trulymem-web.service
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note**: Do not run the service directly from the Git repository to avoid polluting it with runtime artifacts (logs, databases, etc.).
|
||||||
|
|
||||||
|
### Web Access
|
||||||
|
|
||||||
|
The service runs at `http://localhost:4096`. On first visit, you'll need to set up an admin account and log in.
|
||||||
|
|
||||||
|
### Updating Deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd TrulyMEM-TrueHumanMEM
|
||||||
|
git pull
|
||||||
|
cp -r * /home/trulymem/
|
||||||
|
systemctl restart trulymem-web.service
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
@ -21,18 +81,40 @@ TrulyMEM-TrueHumanMEM/
|
|||||||
│ ├── graph_client.py # OpenAI/DeepSeek API client
|
│ ├── graph_client.py # OpenAI/DeepSeek API client
|
||||||
│ ├── tool_executor.py # Tool executor
|
│ ├── tool_executor.py # Tool executor
|
||||||
│ ├── tool_limiter.py # Tool call limiter
|
│ ├── tool_limiter.py # Tool call limiter
|
||||||
|
│ ├── web_api.py # Web API service (login + RESTful API)
|
||||||
│ ├── tools/ # Tool definitions
|
│ ├── tools/ # Tool definitions
|
||||||
│ │ └── memory_tools.py
|
│ │ └── memory_tools.py
|
||||||
│ └── prompts/ # Prompt management
|
│ └── prompts/ # Prompt management (PromptManager + system_prompt.md)
|
||||||
├── ui/ # TUI display layer (display only, no AI logic)
|
├── ui/ # TUI display layer + Web frontend
|
||||||
│ ├── __init__.py # Export GraphMemoryApp
|
│ ├── __init__.py # Export GraphMemoryApp
|
||||||
│ ├── app.py # GraphMemoryApp (communicates via BackendClient)
|
│ ├── app.py # GraphMemoryApp (communicates via BackendClient)
|
||||||
│ ├── widgets/ # TUI components
|
│ ├── widgets/ # TUI components
|
||||||
│ ├── models/ # Data models
|
│ ├── models/ # Data models
|
||||||
│ ├── services/ # Service layer (config only)
|
│ ├── services/ # Service layer (config only)
|
||||||
│ ├── handlers/ # Event handlers
|
│ ├── handlers/ # Event handlers
|
||||||
│ └── styles/ # Style files
|
│ ├── styles/ # Style files
|
||||||
└── tests/ # Tests (42 tests)
|
│ ├── static/ # Web frontend static files
|
||||||
|
│ │ ├── graph.html # Star map visualization (Three.js)
|
||||||
|
│ │ └── index.html # Web chat interface
|
||||||
|
│ ├── templates/ # Page templates
|
||||||
|
│ │ ├── login.html
|
||||||
|
│ │ ├── setup.html
|
||||||
|
│ │ └── settings.html
|
||||||
|
│ ├── web_config.json # Web service config file
|
||||||
|
│ └── web_config.example.json # Web config template
|
||||||
|
├── tests/ # Test suite
|
||||||
|
│ ├── test_core/ # Core logic tests
|
||||||
|
│ ├── test_ui/ # UI layer tests
|
||||||
|
│ └── test_integration/ # Integration tests
|
||||||
|
├── docs/ # Documentation
|
||||||
|
│ ├── zh/ # Chinese docs
|
||||||
|
│ └── en/ # English docs
|
||||||
|
└── build/ # Build scripts
|
||||||
|
├── build_linux.sh
|
||||||
|
├── build_macos.sh
|
||||||
|
├── build_windows.bat
|
||||||
|
├── build_appimage.sh
|
||||||
|
└── trulymem.spec
|
||||||
```
|
```
|
||||||
|
|
||||||
## Architecture Diagram
|
## Architecture Diagram
|
||||||
@ -146,19 +228,23 @@ def main():
|
|||||||
|
|
||||||
## Tool System
|
## Tool System
|
||||||
|
|
||||||
### Memory Tools (6)
|
### Memory Tools (7)
|
||||||
- `memory_recall` - Retrieve memory
|
- `memory_recall` - Retrieve memory
|
||||||
- `memory_commit` - Write memory
|
- `memory_commit` - Write memory
|
||||||
- `memory_purge` - Delete memory
|
- `memory_purge` - Delete memory
|
||||||
- `memory_introspect` - View status
|
- `memory_introspect` - View status
|
||||||
- `memory_archive` - Archive memory
|
- `memory_archive` - Archive memory
|
||||||
- `memory_cleanup` - Clean data
|
- `memory_cleanup` - Clean data
|
||||||
|
- `context_rewrite` - Compress single-turn tool call context
|
||||||
|
|
||||||
### Persona Tools (2)
|
### Persona Tools (2)
|
||||||
|
| `persona_remove` | Delete single persona attribute | Keep other attributes unchanged |
|
||||||
- `persona_update` - Update persona
|
- `persona_update` - Update persona
|
||||||
- `persona_clear` - Clear persona
|
- `persona_clear` - Clear persona
|
||||||
|
|
||||||
### Task Tools (4)
|
### Task Tools (6)
|
||||||
|
| `task_archive` | Archive completed/expired tasks | Step 6 mandatory, writes completion summary |
|
||||||
|
| `task_query` | Query recent task list | Call first in new conversations to avoid duplicate tasks |
|
||||||
- `task_create` - Create task
|
- `task_create` - Create task
|
||||||
- `task_set_state` - Set state
|
- `task_set_state` - Set state
|
||||||
- `task_delete` - Delete task
|
- `task_delete` - Delete task
|
||||||
@ -166,6 +252,19 @@ def main():
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Tool Call Limits
|
||||||
|
|
||||||
|
| Category | Operation | Per-Turn Limit |
|
||||||
|
|----------|-----------|---------------|
|
||||||
|
| Persona graph | Modify | 1 time |
|
||||||
|
| Working memory chain | Modify | 5 times |
|
||||||
|
| General memory | Query | 20 times |
|
||||||
|
| General memory | Modify | 10 times |
|
||||||
|
|
||||||
|
> Note: `memory_recall` is uniformly counted as general memory query, no longer distinguished by persona/working memory queries.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Error Handling Principle
|
## Error Handling Principle
|
||||||
|
|
||||||
All APIs **do not throw exceptions**, errors are passed via return dictionary:
|
All APIs **do not throw exceptions**, errors are passed via return dictionary:
|
||||||
|
|||||||
@ -25,6 +25,14 @@ All memory must be written to the graph database:
|
|||||||
All memory must be read from:
|
All memory must be read from:
|
||||||
- `memory_recall` - Retrieve memory
|
- `memory_recall` - Retrieve memory
|
||||||
|
|
||||||
|
### Working Memory Management (Experimental)
|
||||||
|
|
||||||
|
`context_rewrite` allows AI to proactively compress tool call context within a single turn:
|
||||||
|
- Distills verbose JSON tool results into concise natural language summaries
|
||||||
|
- Summary must include which tools were called and how many calls are summarized
|
||||||
|
- After system validates the format, replaces `messages_history` with `[user message, summary]`
|
||||||
|
- Ensures LLM retains meta-cognition (knows "I called tools") while reducing JSON noise
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Mandatory Execution Flow (Per Turn)
|
## Mandatory Execution Flow (Per Turn)
|
||||||
|
|||||||
@ -53,7 +53,8 @@ The system prompt contains:
|
|||||||
Step 1: Query persona graph (highest priority)
|
Step 1: Query persona graph (highest priority)
|
||||||
Step 2: Query working memory chain
|
Step 2: Query working memory chain
|
||||||
Step 3: Process conversation
|
Step 3: Process conversation
|
||||||
Step 4: Update working memory chain
|
Step 4: memory_commit (write key info) → Persist user-explicit important information to the graph database
|
||||||
|
Step 5: Update working memory chain
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5. Tool System
|
### 5. Tool System
|
||||||
@ -66,6 +67,9 @@ Step 4: Update working memory chain
|
|||||||
| `memory_commit` | Write memory |
|
| `memory_commit` | Write memory |
|
||||||
| `memory_purge` | Delete memory |
|
| `memory_purge` | Delete memory |
|
||||||
| `memory_introspect` | View status |
|
| `memory_introspect` | View status |
|
||||||
|
| `memory_archive` | Archive memory |
|
||||||
|
| `memory_cleanup` | Clean data |
|
||||||
|
| `context_rewrite` | Compress single-turn tool call context |
|
||||||
|
|
||||||
#### Persona Tools
|
#### Persona Tools
|
||||||
|
|
||||||
|
|||||||
@ -1,103 +1,176 @@
|
|||||||
# TrulyMEM Quick Start Guide
|
# TrulyMEM Quick Start Guide
|
||||||
|
|
||||||
## Running Methods
|
> **Version**: Multi-user (v2) — TUI login, user isolation, embedded Web server
|
||||||
|
|
||||||
### Run from Source
|
---
|
||||||
|
|
||||||
|
## Running
|
||||||
|
|
||||||
|
### Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone <repo-url>
|
# From source
|
||||||
cd TrulyMEM-TrueHumanMEM
|
|
||||||
|
|
||||||
pip install -r requirements.txt
|
|
||||||
|
|
||||||
python trulymem_entry.py
|
python trulymem_entry.py
|
||||||
|
|
||||||
|
# Packaged binary
|
||||||
|
./dist/TrulyMEM
|
||||||
```
|
```
|
||||||
|
|
||||||
### Run After Build
|
### First Run — Login Flow
|
||||||
|
|
||||||
After building, an executable will be generated:
|
On first launch, TrulyMEM checks for legacy data and presents a **login screen**:
|
||||||
|
|
||||||
|
1. **Clean install** → Enter username/password (first user becomes admin)
|
||||||
|
2. **Legacy upgrade** → Detects `~/.trulymem/config.json`, guides migration setup
|
||||||
|
3. **Returning user** → Login directly
|
||||||
|
|
||||||
|
> 💡 All user data is isolated: `~/.trulymem/{username}/`
|
||||||
|
|
||||||
|
### Chat Configuration
|
||||||
|
|
||||||
|
After login, press **F2** to open the right-side configuration panel:
|
||||||
|
|
||||||
|
1. **API Key** — Required (DeepSeek, OpenAI, etc.)
|
||||||
|
2. **Model** — Optional
|
||||||
|
3. **Base URL** — Optional
|
||||||
|
|
||||||
|
Config saves automatically.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Web Visualization
|
||||||
|
|
||||||
|
The Web service now runs **embedded in the main process** (no separate subprocess needed).
|
||||||
|
|
||||||
|
### Start via TUI (Admin only)
|
||||||
|
|
||||||
|
Admin users: press F2 → check "Enable Web Service".
|
||||||
|
|
||||||
|
### Start Manually
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Linux/macOS
|
python -m core.web_api --port 4096
|
||||||
chmod +x TrulyMEM
|
# Visit http://localhost:4096
|
||||||
./TrulyMEM
|
|
||||||
|
|
||||||
# Windows
|
|
||||||
TrulyMEM.exe
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## System Requirements
|
### First Visit Flow
|
||||||
|
|
||||||
- **Python 3.8+**
|
1. Open `http://localhost:4096` in browser
|
||||||
- **API Key** (DeepSeek, OpenAI, or other compatible APIs)
|
2. **No users** → Auto-redirect to setup page, create admin account
|
||||||
|
3. **Has users** → Login page
|
||||||
|
4. After login → Star map visualization
|
||||||
|
|
||||||
## First-Time Configuration
|
### Web Features
|
||||||
|
|
||||||
1. Run the application
|
| Page | Access | Feature |
|
||||||
2. Press **F2** to expand sidebar
|
|------|--------|---------|
|
||||||
3. Enter **API Key**, **Model**, **Base URL**
|
| 🌟 Star Map | All logged-in | Browse knowledge graph |
|
||||||
4. Press **Enter** to save
|
| ⚙ Settings | All logged-in | Change password |
|
||||||
|
| 🧑💼 User Management | **Admin only** | Add/delete users |
|
||||||
|
|
||||||
Config will be automatically saved to `~/.trulymem/config.json` and loaded on next startup.
|
---
|
||||||
|
|
||||||
|
## Multi-User System
|
||||||
|
|
||||||
|
### Directory Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.trulymem/
|
||||||
|
├── trulymem.db # Global user database (web_users table)
|
||||||
|
├── .migrated # Migration flag
|
||||||
|
├── admin/
|
||||||
|
│ ├── config.json # Admin config
|
||||||
|
│ └── admin_graph.db # Admin knowledge graph
|
||||||
|
└── user2/
|
||||||
|
├── config.json # user2 config
|
||||||
|
└── user2_graph.db # user2 knowledge graph
|
||||||
|
```
|
||||||
|
|
||||||
|
### Role Matrix
|
||||||
|
|
||||||
|
| Feature | User | Admin |
|
||||||
|
|---------|------|-------|
|
||||||
|
| Change password | ✅ | ✅ |
|
||||||
|
| Configure API Key / Model | ✅ | ✅ |
|
||||||
|
| Web service toggle (TUI) | ❌ | ✅ |
|
||||||
|
| Web login credentials | ❌ | ✅ |
|
||||||
|
| View user list | ❌ | ✅ |
|
||||||
|
| Add/delete users | ❌ | ✅ |
|
||||||
|
|
||||||
|
> ⚠️ First registered user becomes admin automatically. Add users via Web settings page.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Keyboard Shortcuts
|
## Keyboard Shortcuts
|
||||||
|
|
||||||
| Key | Function |
|
| Key | Action |
|
||||||
|-----|-----------|
|
|-----|--------|
|
||||||
| F1 | Help |
|
| F1 | Help |
|
||||||
| F2 | Toggle sidebar |
|
| F2 | Toggle sidebar (config panel) |
|
||||||
| F3 | Tool details |
|
| F3 | Tool details |
|
||||||
| F5 | Clear screen |
|
| F5 | Clear screen |
|
||||||
| F6 | Exit |
|
| F6 | Quit |
|
||||||
|
|
||||||
## Data Storage
|
---
|
||||||
|
|
||||||
### Source Mode
|
## Building
|
||||||
|
|
||||||
| Data | Location |
|
```bash
|
||||||
|------|----------|
|
# Linux
|
||||||
| Graph database | Project directory `graph_memory.db` |
|
bash build/build_linux.sh
|
||||||
| Config file | Project directory `config.json` (if exists) |
|
|
||||||
| Database format | SQLite |
|
|
||||||
|
|
||||||
### Packaged Mode
|
# macOS
|
||||||
|
bash build/build_macos.sh
|
||||||
|
|
||||||
| Data | Location |
|
# Windows
|
||||||
|------|----------|
|
build\build_windows.bat
|
||||||
| Graph database | `~/.trulymem/graph_memory.db` |
|
|
||||||
| Config file | `~/.trulymem/config.json` |
|
|
||||||
| Database format | SQLite |
|
|
||||||
|
|
||||||
> **Note**: Backend manages config uniformly. Frontend only displays messages; config modifications are persisted to filesystem through the backend.
|
# AppImage
|
||||||
|
bash build/build_appimage.sh
|
||||||
|
```
|
||||||
|
|
||||||
## Architecture Explanation
|
Output: `dist/TrulyMEM` (single binary — TUI and Web server embedded)
|
||||||
|
|
||||||
### Communication Protocol
|
> 📦 Since v2, the Web server runs as a thread inside the main process. No need for a separate `trulymem-web` binary.
|
||||||
|
|
||||||
UI and backend communicate via **Packet Protocol**:
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Communication
|
||||||
|
|
||||||
```
|
```
|
||||||
UI (Textual TUI)
|
TUI (Textual) ←→ BackendClient ←→ queue.Queue ←→ BackendServer (thread)
|
||||||
↓ BackendClient
|
|
||||||
Packet → queue.Queue → BackendServer (independent thread)
|
|
||||||
↓
|
|
||||||
Process request → Return response
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Config Management
|
### Config Management
|
||||||
|
|
||||||
- **Storage location**: `~/.trulymem/config.json`
|
- **Per-user**: `~/.trulymem/{username}/config.json`
|
||||||
- **Auto-load**: Load config from file at startup
|
- **Web config**: `~/.trulymem/trulymem.db` (web_users table)
|
||||||
- **Dynamic update**: Config changes take effect immediately at runtime
|
- **Auto-load**: reads config for logged-in user on startup
|
||||||
- **Persistence**: Auto-save to file after modification
|
- **Persistent**: saves automatically on change
|
||||||
|
|
||||||
## Common Issues
|
### Web Service Architecture
|
||||||
|
|
||||||
### Python Not Found
|
```
|
||||||
|
┌──────────────────────┐
|
||||||
|
│ TrulyMEM Process │
|
||||||
|
│ ┌──────┐ ┌────────┐ │
|
||||||
|
│ │ TUI │ │ Flask │ │ ← Same process, different threads
|
||||||
|
│ │ │ │ Thread │ │
|
||||||
|
│ └──────┘ └────────┘ │
|
||||||
|
└──────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## FAQ
|
||||||
|
|
||||||
|
### Python not found
|
||||||
|
|
||||||
Install Python 3.8+: https://www.python.org/downloads/
|
Install Python 3.8+: https://www.python.org/downloads/
|
||||||
|
|
||||||
### Dependency Installation Failed
|
### Dependency installation fails
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m venv venv
|
python -m venv venv
|
||||||
@ -108,18 +181,22 @@ pip install -r requirements.txt
|
|||||||
|
|
||||||
### Invalid API Key
|
### Invalid API Key
|
||||||
|
|
||||||
Check API Key format, ensure no extra spaces.
|
Check format and whitespace. Reconfigure in TUI sidebar.
|
||||||
|
|
||||||
## Development Commands
|
### Lost admin account
|
||||||
|
|
||||||
|
The first registered user is always admin. If all users lost admin, delete `trulymem.db` from the user directory and re-register.
|
||||||
|
|
||||||
|
### Legacy data migration
|
||||||
|
|
||||||
|
When old `~/.trulymem/config.json` and `graph_memory.db` are detected, TUI auto-enters migration flow. Legacy files are preserved.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dev Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install dependencies
|
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
|
|
||||||
# Run tests
|
|
||||||
pytest tests/
|
pytest tests/
|
||||||
|
bash build/build_linux.sh
|
||||||
# Build
|
|
||||||
bash build/build_windows.bat # Windows
|
|
||||||
bash build/build_linux.sh # Linux
|
|
||||||
```
|
```
|
||||||
@ -185,10 +185,8 @@ body = {} # 无参数
|
|||||||
"model": str # 模型名称
|
"model": str # 模型名称
|
||||||
},
|
},
|
||||||
"tool_limits": {
|
"tool_limits": {
|
||||||
"persona_query_max": int, # 人设图查询上限
|
|
||||||
"persona_update_max": int, # 人设图修改上限
|
"persona_update_max": int, # 人设图修改上限
|
||||||
"task_query_max": int, # 工作记忆查询上限
|
"task_update_max": int, # 工作记忆链修改上限
|
||||||
"task_update_max": int, # 工作记忆修改上限
|
|
||||||
"memory_query_max": int, # 一般记忆查询上限
|
"memory_query_max": int, # 一般记忆查询上限
|
||||||
"memory_update_max": int # 一般记忆修改上限
|
"memory_update_max": int # 一般记忆修改上限
|
||||||
}
|
}
|
||||||
@ -217,10 +215,8 @@ body = {
|
|||||||
"model": str # 模型名称 (默认: deepseek-chat)
|
"model": str # 模型名称 (默认: deepseek-chat)
|
||||||
},
|
},
|
||||||
"tool_limits": {
|
"tool_limits": {
|
||||||
"persona_query_max": int, # 人设图查询上限 (≥1)
|
|
||||||
"persona_update_max": int, # 人设图修改上限 (≥1)
|
"persona_update_max": int, # 人设图修改上限 (≥1)
|
||||||
"task_query_max": int, # 工作记忆查询上限 (≥1)
|
"task_update_max": int, # 工作记忆链修改上限 (≥1)
|
||||||
"task_update_max": int, # 工作记忆修改上限 (≥1)
|
|
||||||
"memory_query_max": int, # 一般记忆查询上限 (≥1)
|
"memory_query_max": int, # 一般记忆查询上限 (≥1)
|
||||||
"memory_update_max": int # 一般记忆修改上限 (≥1)
|
"memory_update_max": int # 一般记忆修改上限 (≥1)
|
||||||
}
|
}
|
||||||
@ -243,8 +239,8 @@ result = client.update_settings(
|
|||||||
"model": "deepseek-chat"
|
"model": "deepseek-chat"
|
||||||
},
|
},
|
||||||
tool_limits={
|
tool_limits={
|
||||||
"persona_query_max": 2,
|
"persona_update_max": 2,
|
||||||
"task_query_max": 5,
|
"task_update_max": 5,
|
||||||
"memory_query_max": 30
|
"memory_query_max": 30
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@ -478,12 +474,11 @@ asyncio.run(main())
|
|||||||
|
|
||||||
| 类别 | 操作 | 每轮上限 |
|
| 类别 | 操作 | 每轮上限 |
|
||||||
|------|------|---------|
|
|------|------|---------|
|
||||||
| 人设图 | 查询 | 1 次 |
|
|
||||||
| 人设图 | 修改 | 1 次 |
|
| 人设图 | 修改 | 1 次 |
|
||||||
| 工作记忆链 | 查询 | 4 次 |
|
| 工作记忆链 | 修改 | 5 次 |
|
||||||
| 工作记忆链 | 修改 | 2 次 |
|
|
||||||
| 一般记忆 | 查询 | 20 次 |
|
| 一般记忆 | 查询 | 20 次 |
|
||||||
| 一般记忆 | 修改 | 10 次 |
|
| 一般记忆 | 修改 | 10 次 |
|
||||||
|
| 上下文压缩 | 查询 | 计入一般记忆查询 |
|
||||||
|
|
||||||
### 重置机制
|
### 重置机制
|
||||||
|
|
||||||
@ -517,3 +512,24 @@ asyncio.run(main())
|
|||||||
| `API Key 未配置` | 未设置 API Key |
|
| `API Key 未配置` | 未设置 API Key |
|
||||||
| `timeout` | 请求超时 |
|
| `timeout` | 请求超时 |
|
||||||
| `工具调用被拒绝: ...` | 工具调用频率超限 |
|
| `工具调用被拒绝: ...` | 工具调用频率超限 |
|
||||||
|
|
||||||
|
## Web API 端点
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| GET | /api/check-auth | 检查当前会话是否已登录 |
|
||||||
|
| POST | /api/login | 登录(JSON body: username, password) |
|
||||||
|
| POST | /api/logout | 登出 |
|
||||||
|
| GET | /api/history | 获取聊天历史 |
|
||||||
|
| POST | /api/message | 发送消息给 AI |
|
||||||
|
| POST | /api/tools/execute | 执行工具调用 |
|
||||||
|
| GET | /api/status | 获取系统状态 |
|
||||||
|
| GET | /api/settings | 获取设置 |
|
||||||
|
| PUT | /api/settings | 更新设置 |
|
||||||
|
| DELETE | /api/history | 清空历史 |
|
||||||
|
| POST | /api/shutdown | 关闭服务器 |
|
||||||
|
| GET | /api/activity | 获取数据库操作记录 |
|
||||||
|
| GET | /api/graph | 获取知识图谱数据 |
|
||||||
|
| GET | /api/graph/highlight | 获取高亮节点 |
|
||||||
|
|
||||||
|
所有 API 端点(除 /api/login 和 /api/check-auth 外)需要登录认证。登录使用 Flask session,有效期 7 天。
|
||||||
@ -8,6 +8,72 @@
|
|||||||
- TUI 与后端分离,多线程通信
|
- TUI 与后端分离,多线程通信
|
||||||
- **一切皆图**,AI 推理全部在后端
|
- **一切皆图**,AI 推理全部在后端
|
||||||
|
|
||||||
|
## 部署方式
|
||||||
|
|
||||||
|
### 开发环境(从 Git 仓库直接运行)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd TrulyMEM-TrueHumanMEM
|
||||||
|
python3 trulymem_entry.py --web --port 4096
|
||||||
|
```
|
||||||
|
|
||||||
|
### 生产环境(Systemd + 独立部署目录)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 将代码复制到独立目录
|
||||||
|
cp -r TrulyMEM-TrueHumanMEM /home/trulymem
|
||||||
|
|
||||||
|
# 创建 Systemd 服务
|
||||||
|
cat > /etc/systemd/system/trulymem-web.service << 'EOF'
|
||||||
|
[Unit]
|
||||||
|
Description=TrulyMEM - True Human Memory (Web Mode)
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=root
|
||||||
|
WorkingDirectory=/home/trulymem
|
||||||
|
ExecStart=/usr/bin/python3 /home/trulymem/trulymem_entry.py --web --port 4096
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable trulymem-web.service
|
||||||
|
systemctl start trulymem-web.service
|
||||||
|
|
||||||
|
# 查看状态
|
||||||
|
systemctl status trulymem-web.service
|
||||||
|
```
|
||||||
|
|
||||||
|
> **注意**: 不要直接从 Git 仓库启动服务,以免日志文件、数据库等运行时产物污染仓库。
|
||||||
|
|
||||||
|
### Web 访问
|
||||||
|
|
||||||
|
服务默认运行在 `http://localhost:4096`,首次访问需设置管理员账号并登录。
|
||||||
|
|
||||||
|
### 更新部署
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 拉取最新代码
|
||||||
|
cd TrulyMEM-TrueHumanMEM
|
||||||
|
git pull
|
||||||
|
|
||||||
|
# 同步到部署目录
|
||||||
|
cp -r * /home/trulymem/
|
||||||
|
|
||||||
|
# 重启服务
|
||||||
|
systemctl restart trulymem-web.service
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 原始架构说明
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
```
|
```
|
||||||
@ -21,18 +87,40 @@ TrulyMEM-TrueHumanMEM/
|
|||||||
│ ├── graph_client.py # OpenAI/DeepSeek API 客户端
|
│ ├── graph_client.py # OpenAI/DeepSeek API 客户端
|
||||||
│ ├── tool_executor.py # 工具执行器
|
│ ├── tool_executor.py # 工具执行器
|
||||||
│ ├── tool_limiter.py # 工具调用限制器
|
│ ├── tool_limiter.py # 工具调用限制器
|
||||||
|
│ ├── web_api.py # Web API 服务(登录 + RESTful API)
|
||||||
│ ├── tools/ # 工具定义
|
│ ├── tools/ # 工具定义
|
||||||
│ │ └── memory_tools.py
|
│ │ └── memory_tools.py
|
||||||
│ └── prompts/ # 提示词管理
|
│ └── prompts/ # 提示词管理(PromptManager + system_prompt.md)
|
||||||
├── ui/ # TUI 显示层(仅显示,无 AI 逻辑)
|
├── ui/ # TUI 显示层 + Web 前端
|
||||||
│ ├── __init__.py # 导出 GraphMemoryApp
|
│ ├── __init__.py # 导出 GraphMemoryApp
|
||||||
│ ├── app.py # GraphMemoryApp (通过 BackendClient 通信)
|
│ ├── app.py # GraphMemoryApp (通过 BackendClient 通信)
|
||||||
│ ├── widgets/ # TUI 组件
|
│ ├── widgets/ # TUI 组件
|
||||||
│ ├── models/ # 数据模型
|
│ ├── models/ # 数据模型
|
||||||
│ ├── services/ # 服务层(仅配置管理)
|
│ ├── services/ # 服务层(仅配置管理)
|
||||||
│ ├── handlers/ # 事件处理
|
│ ├── handlers/ # 事件处理
|
||||||
│ └── styles/ # 样式文件
|
│ ├── styles/ # 样式文件
|
||||||
└── tests/ # 测试 (42 tests)
|
│ ├── static/ # Web 前端静态文件
|
||||||
|
│ │ ├── graph.html # 星图可视化(Three.js)
|
||||||
|
│ │ └── index.html # Web 聊天界面
|
||||||
|
│ ├── templates/ # 页面模板
|
||||||
|
│ │ ├── login.html
|
||||||
|
│ │ ├── setup.html
|
||||||
|
│ │ └── settings.html
|
||||||
|
│ ├── web_config.json # Web 服务配置文件
|
||||||
|
│ └── web_config.example.json # Web 配置模板
|
||||||
|
├── tests/ # 测试套件
|
||||||
|
│ ├── test_core/ # 核心逻辑测试
|
||||||
|
│ ├── test_ui/ # UI 层测试
|
||||||
|
│ └── test_integration/ # 集成测试
|
||||||
|
├── docs/ # 文档
|
||||||
|
│ ├── zh/ # 中文文档
|
||||||
|
│ └── en/ # 英文文档
|
||||||
|
└── build/ # 打包脚本
|
||||||
|
├── build_linux.sh
|
||||||
|
├── build_macos.sh
|
||||||
|
├── build_windows.bat
|
||||||
|
├── build_appimage.sh
|
||||||
|
└── trulymem.spec
|
||||||
```
|
```
|
||||||
|
|
||||||
## 架构图
|
## 架构图
|
||||||
@ -146,19 +234,23 @@ def main():
|
|||||||
|
|
||||||
## 工具系统
|
## 工具系统
|
||||||
|
|
||||||
### 记忆工具 (6个)
|
### 记忆工具 (7个)
|
||||||
- `memory_recall` - 检索记忆
|
- `memory_recall` - 检索记忆
|
||||||
- `memory_commit` - 写入记忆
|
- `memory_commit` - 写入记忆
|
||||||
- `memory_purge` - 删除记忆
|
- `memory_purge` - 删除记忆
|
||||||
- `memory_introspect` - 查看状态
|
- `memory_introspect` - 查看状态
|
||||||
- `memory_archive` - 归档记忆
|
- `memory_archive` - 归档记忆
|
||||||
- `memory_cleanup` - 清理数据
|
- `memory_cleanup` - 清理数据
|
||||||
|
- `context_rewrite` - 压缩单轮工具调用上下文
|
||||||
|
|
||||||
### 人设工具 (2个)
|
### 人设工具 (2个)
|
||||||
|
| `persona_remove` | 删除单条人设属性 | 保留其他人设不变 |
|
||||||
- `persona_update` - 更新人设
|
- `persona_update` - 更新人设
|
||||||
- `persona_clear` - 清除人设
|
- `persona_clear` - 清除人设
|
||||||
|
|
||||||
### 任务工具 (4个)
|
### 任务工具 (6个)
|
||||||
|
| `task_archive` | 归档已完成/过期的任务 | 步骤6强制执行,写入完成摘要 |
|
||||||
|
| `task_query` | 查询最近任务列表 | 新对话时优先调用,避免重复创建任务 |
|
||||||
- `task_create` - 创建任务
|
- `task_create` - 创建任务
|
||||||
- `task_set_state` - 设置状态
|
- `task_set_state` - 设置状态
|
||||||
- `task_delete` - 删除任务
|
- `task_delete` - 删除任务
|
||||||
@ -166,6 +258,20 @@ def main():
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 工具调用限制
|
||||||
|
|
||||||
|
| 类别 | 操作 | 每轮上限 |
|
||||||
|
|------|------|---------|
|
||||||
|
| 人设图 | 修改 | 1 次 |
|
||||||
|
| 工作记忆链 | 查询 | 30 次 |
|
||||||
|
| 工作记忆链 | 修改 | 20 次 |
|
||||||
|
| 一般记忆 | 查询 | 30 次 |
|
||||||
|
| 一般记忆 | 修改 | 15 次 |
|
||||||
|
|
||||||
|
> 注:`memory_recall` 统一计入一般记忆查询,不再区分人设/工作记忆查询。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 错误处理原则
|
## 错误处理原则
|
||||||
|
|
||||||
所有 API **不抛出异常**,错误通过返回字典传递:
|
所有 API **不抛出异常**,错误通过返回字典传递:
|
||||||
|
|||||||
@ -25,6 +25,14 @@ TrulyMEM 的解决思路:
|
|||||||
所有记忆必须通过以下方式读取:
|
所有记忆必须通过以下方式读取:
|
||||||
- `memory_recall` - 检索记忆
|
- `memory_recall` - 检索记忆
|
||||||
|
|
||||||
|
### 工作记忆管理
|
||||||
|
|
||||||
|
`context_rewrite` 允许 AI 在单轮对话内主动压缩工具调用的临时上下文:
|
||||||
|
- 将冗长的 JSON 工具结果提炼为简洁的自然语言摘要
|
||||||
|
- 摘要必须包含调用了哪些工具、对几次调用的总结
|
||||||
|
- 系统验证格式后,替换 `messages_history` 为 `[用户消息, 摘要]`
|
||||||
|
- 确保 LLM 保留元认知(知道"我调用过工具"),同时减少 JSON 噪音
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 强制执行流程(每轮对话)
|
## 强制执行流程(每轮对话)
|
||||||
|
|||||||
@ -53,9 +53,13 @@ system_prompt = prompt_manager.get_system_prompt()
|
|||||||
步骤 1: 查询人设图(最高优先级)
|
步骤 1: 查询人设图(最高优先级)
|
||||||
步骤 2: 查询工作记忆链
|
步骤 2: 查询工作记忆链
|
||||||
步骤 3: 处理对话
|
步骤 3: 处理对话
|
||||||
步骤 4: 更新工作记忆链
|
步骤 4: memory_commit (写入关键信息) → 将用户明确提到的重要信息写入图数据库
|
||||||
|
步骤 5: 更新工作记忆链
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **注意**: 原提示词仅包含 4 步,缺少显式的写入步骤,导致 AI 只查不写、聊完即忘。
|
||||||
|
> 步骤 4 确保每轮对话的关键信息被持久化到图数据库中。
|
||||||
|
|
||||||
### 5. 工具系统
|
### 5. 工具系统
|
||||||
|
|
||||||
#### 记忆工具
|
#### 记忆工具
|
||||||
@ -66,6 +70,9 @@ system_prompt = prompt_manager.get_system_prompt()
|
|||||||
| `memory_commit` | 写入记忆 |
|
| `memory_commit` | 写入记忆 |
|
||||||
| `memory_purge` | 删除记忆 |
|
| `memory_purge` | 删除记忆 |
|
||||||
| `memory_introspect` | 查看状态 |
|
| `memory_introspect` | 查看状态 |
|
||||||
|
| `memory_archive` | 归档记忆 |
|
||||||
|
| `memory_cleanup` | 清理数据 |
|
||||||
|
| `context_rewrite` | 压缩单轮工具调用上下文 |
|
||||||
|
|
||||||
#### 人设工具
|
#### 人设工具
|
||||||
|
|
||||||
|
|||||||
@ -1,74 +1,135 @@
|
|||||||
# TrulyMEM 启动指南
|
# TrulyMEM 启动指南
|
||||||
|
|
||||||
|
> **版本**: 多用户版 (v2) — 支持 TUI 登录、多用户隔离、Web 服务内嵌
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 运行方式
|
## 运行方式
|
||||||
|
|
||||||
### 从源码运行
|
### 快速启动(推荐)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone <repo-url>
|
# 从源码
|
||||||
cd TrulyMEM-TrueHumanMEM
|
|
||||||
|
|
||||||
pip install -r requirements.txt
|
|
||||||
|
|
||||||
python trulymem_entry.py
|
python trulymem_entry.py
|
||||||
|
|
||||||
|
# 或打包后
|
||||||
|
./dist/TrulyMEM
|
||||||
```
|
```
|
||||||
|
|
||||||
### 打包后运行
|
### 首次使用 —— 登录流程
|
||||||
|
|
||||||
打包后会生成可执行文件:
|
首次启动会自动检查是否需要迁移旧数据,然后进入**登录页面**:
|
||||||
|
|
||||||
|
1. **新部署** → 直接输入用户名和密码创建账户(首个用户自动成为管理员)
|
||||||
|
2. **旧版升级** → 自动检测 `~/.trulymem/config.json`,引导设置用户名密码,迁移数据
|
||||||
|
3. **已有账户** → 直接登录进入聊天界面
|
||||||
|
|
||||||
|
> 💡 所有用户数据隔离存储:`~/.trulymem/{用户名}/`
|
||||||
|
|
||||||
|
### 聊天配置
|
||||||
|
|
||||||
|
登录后按 **F2** 展开右侧配置面板:
|
||||||
|
|
||||||
|
1. **API Key** — 必须(支持 DeepSeek、OpenAI 等)
|
||||||
|
2. **模型** — 可选,默认已配置
|
||||||
|
3. **Base URL** — 可选
|
||||||
|
|
||||||
|
配置自动保存,下次启动自动加载。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Web 可视化界面
|
||||||
|
|
||||||
|
TrulyMEM 的 Web 服务现在**内嵌在主进程中**(无需独立启动子进程)。
|
||||||
|
|
||||||
|
### TUI 内启动(管理员专有)
|
||||||
|
|
||||||
|
管理员按 F2 打开右侧面板,勾选「启用 Web 服务」即可。
|
||||||
|
|
||||||
|
### 手动启动
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Linux/macOS
|
python -m core.web_api --port 4096
|
||||||
chmod +x TrulyMEM
|
# 访问 http://localhost:4096
|
||||||
./TrulyMEM
|
|
||||||
|
|
||||||
# Windows
|
|
||||||
TrulyMEM.exe
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 系统要求
|
### 首次访问流程
|
||||||
|
|
||||||
- **Python 3.8+**
|
1. 浏览器打开 `http://localhost:4096`
|
||||||
- **API Key**(DeepSeek、OpenAI 或其他兼容 API)
|
2. **无用户** → 自动跳转至设置页,创建管理员账号
|
||||||
|
3. **有用户** → 跳转至登录页
|
||||||
|
4. 登录后进入星图可视化页面
|
||||||
|
|
||||||
## 首次配置
|
### Web 功能
|
||||||
|
|
||||||
1. 运行应用
|
| 页面 | 访问权限 | 功能 |
|
||||||
2. 按 **F2** 展开侧边栏
|
|------|----------|------|
|
||||||
3. 输入 **API Key**、**模型**、**Base URL**
|
| 🌟 星图 | 所有已登录用户 | 浏览知识图谱三元组 |
|
||||||
4. 按 **Enter** 保存
|
| ⚙ 设置 | 所有已登录用户 | 修改密码 |
|
||||||
|
| 🧑💼 用户管理 | **仅管理员** | 添加/删除用户 |
|
||||||
|
|
||||||
配置会自动保存到 `~/.trulymem/config.json`,下次启动自动加载。
|
---
|
||||||
|
|
||||||
|
## 多用户系统
|
||||||
|
|
||||||
|
### 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.trulymem/
|
||||||
|
├── trulymem.db # 全局用户数据库(web_users 表)
|
||||||
|
├── .migrated # 旧版迁移标记
|
||||||
|
├── admin/
|
||||||
|
│ ├── config.json # 管理员配置
|
||||||
|
│ └── admin_graph.db # 管理员知识图谱
|
||||||
|
└── user2/
|
||||||
|
├── config.json # user2 配置
|
||||||
|
└── user2_graph.db # user2 知识图谱
|
||||||
|
```
|
||||||
|
|
||||||
|
### 角色体系
|
||||||
|
|
||||||
|
| 功能 | 普通用户 | 管理员 |
|
||||||
|
|------|---------|--------|
|
||||||
|
| 修改自己密码 | ✅ | ✅ |
|
||||||
|
| 配置 API Key / 模型 | ✅ | ✅ |
|
||||||
|
| Web 服务开关(TUI) | ❌ | ✅ |
|
||||||
|
| Web 登录凭据 | ❌ | ✅ |
|
||||||
|
| 查看用户列表 | ❌ | ✅ |
|
||||||
|
| 添加/删除用户 | ❌ | ✅ |
|
||||||
|
|
||||||
|
> ⚠️ 首个注册用户自动成为管理员。Web 设置页可添加新用户。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 快捷键
|
## 快捷键
|
||||||
|
|
||||||
| 按键 | 功能 |
|
| 按键 | 功能 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| F1 | 帮助 |
|
| F1 | 帮助 |
|
||||||
| F2 | 切换侧边栏 |
|
| F2 | 切换侧边栏(配置面板) |
|
||||||
| F3 | 工具详情 |
|
| F3 | 工具详情 |
|
||||||
| F5 | 清屏 |
|
| F5 | 清屏 |
|
||||||
| F6 | 退出 |
|
| F6 | 退出 |
|
||||||
|
|
||||||
## 数据存储
|
---
|
||||||
|
|
||||||
### 源码运行模式
|
## 打包构建
|
||||||
|
|
||||||
| 数据 | 位置 |
|
```bash
|
||||||
|------|------|
|
# 安装依赖
|
||||||
| 图数据库 | 项目目录 `graph_memory.db` |
|
pip install -r requirements.txt
|
||||||
| 配置文件 | 项目目录 `config.json`(如存在) |
|
|
||||||
| 数据库格式 | SQLite |
|
|
||||||
|
|
||||||
### 打包运行模式
|
# 构建(Linux / macOS)
|
||||||
|
pyinstaller --clean build/trulymem.spec
|
||||||
|
```
|
||||||
|
|
||||||
| 数据 | 位置 |
|
构建产出:`dist/TrulyMEM`(单文件,TUI + Web 服务均内嵌于同一二进制)
|
||||||
|------|------|
|
|
||||||
| 图数据库 | `~/.trulymem/graph_memory.db` |
|
|
||||||
| 配置文件 | `~/.trulymem/config.json` |
|
|
||||||
| 数据库格式 | SQLite |
|
|
||||||
|
|
||||||
> **说明**:后端统一管理配置。前端仅负责消息展示,配置修改通过后端持久化到文件系统。
|
> 📦 从 v2 开始,Web 服务作为线程嵌入主程序,不再需要独立打包 `trulymem-web`。
|
||||||
|
>
|
||||||
|
> 💡 修改 `ui/static/` 或 `core/` 等源码后必须重新编译才能生效(静态文件在构建时打入二进制)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 架构说明
|
## 架构说明
|
||||||
|
|
||||||
@ -77,19 +138,29 @@ TrulyMEM.exe
|
|||||||
UI 与后端通过 **Packet 协议** 通信:
|
UI 与后端通过 **Packet 协议** 通信:
|
||||||
|
|
||||||
```
|
```
|
||||||
UI (Textual TUI)
|
TUI (Textual) ←→ BackendClient ←→ queue.Queue ←→ BackendServer (独立线程)
|
||||||
↓ BackendClient
|
|
||||||
Packet → queue.Queue → BackendServer (独立线程)
|
|
||||||
↓
|
|
||||||
处理请求 → 返回响应
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 配置管理
|
### 配置管理
|
||||||
|
|
||||||
- **存储位置**: `~/.trulymem/config.json`
|
- **用户级存储**: `~/.trulymem/{username}/config.json`
|
||||||
- **自动加载**: 启动时从文件读取配置
|
- **Web 配置**: `~/.trulymem/trulymem.db`(web_users 表)
|
||||||
- **动态更新**: 运行时修改配置立即生效
|
- **自动加载**: 启动时根据登录用户加载对应配置文件
|
||||||
- **持久化**: 修改后自动保存到文件
|
- **动态更新**: 运行时修改配置立即生效,自动持久化
|
||||||
|
|
||||||
|
### Web 服务架构
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────┐
|
||||||
|
│ TrulyMEM 主进程 │
|
||||||
|
│ ┌──────┐ ┌────────┐ │
|
||||||
|
│ │ TUI │ │ Flask │ │ ← 同一进程,不同线程
|
||||||
|
│ │ │ │ Thread │ │
|
||||||
|
│ └──────┘ └────────┘ │
|
||||||
|
└──────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 常见问题
|
## 常见问题
|
||||||
|
|
||||||
@ -108,18 +179,22 @@ pip install -r requirements.txt
|
|||||||
|
|
||||||
### API Key 无效
|
### API Key 无效
|
||||||
|
|
||||||
检查 API Key 格式,确保无多余空格。
|
检查 API Key 格式,确保无多余空格。可在 TUI 右侧面板重新配置。
|
||||||
|
|
||||||
|
### 管理员账号丢失
|
||||||
|
|
||||||
|
数据库中第一个注册账号总是 admin。如果所有用户都丢失了 admin 权限,删除用户目录下的 `trulymem.db` 后重新注册即可。
|
||||||
|
|
||||||
|
### 旧版数据迁移
|
||||||
|
|
||||||
|
检测到旧版 `~/.trulymem/config.json` 和 `graph_memory.db` 时,TUI 启动时自动进入迁移引导。迁移后旧文件保留,不会删除。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 开发命令
|
## 开发命令
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 安装依赖
|
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
|
|
||||||
# 运行测试
|
|
||||||
pytest tests/
|
pytest tests/
|
||||||
|
bash build/build_linux.sh
|
||||||
# 打包
|
|
||||||
bash build/build_windows.bat # Windows
|
|
||||||
bash build/build_linux.sh # Linux
|
|
||||||
```
|
```
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"app": {
|
||||||
|
"bundleName": "com.trulymem.app",
|
||||||
|
"debug": true,
|
||||||
|
"versionCode": 1000001,
|
||||||
|
"versionName": "1.0.0",
|
||||||
|
"minAPIVersion": 60100023,
|
||||||
|
"targetAPIVersion": 60100023,
|
||||||
|
"apiReleaseType": "Release",
|
||||||
|
"targetMinorAPIVersion": 0,
|
||||||
|
"targetPatchAPIVersion": 0,
|
||||||
|
"compileSdkVersion": "6.1.0.105",
|
||||||
|
"compileSdkType": "HarmonyOS",
|
||||||
|
"appEnvironments": [],
|
||||||
|
"bundleType": "app",
|
||||||
|
"buildMode": "debug"
|
||||||
|
},
|
||||||
|
"module": {
|
||||||
|
"name": "chat",
|
||||||
|
"type": "har",
|
||||||
|
"description": "TrulyMEM chat feature module",
|
||||||
|
"deviceTypes": [
|
||||||
|
"phone",
|
||||||
|
"tablet",
|
||||||
|
"2in1"
|
||||||
|
],
|
||||||
|
"packageName": "@ohos/chat",
|
||||||
|
"installationFree": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"app": {
|
||||||
|
"bundleName": "com.trulymem.app",
|
||||||
|
"debug": true,
|
||||||
|
"versionCode": 1000001,
|
||||||
|
"versionName": "1.0.0",
|
||||||
|
"minAPIVersion": 60100023,
|
||||||
|
"targetAPIVersion": 60100023,
|
||||||
|
"apiReleaseType": "Release",
|
||||||
|
"targetMinorAPIVersion": 0,
|
||||||
|
"targetPatchAPIVersion": 0,
|
||||||
|
"compileSdkVersion": "6.1.0.105",
|
||||||
|
"compileSdkType": "HarmonyOS",
|
||||||
|
"appEnvironments": [],
|
||||||
|
"bundleType": "app",
|
||||||
|
"buildMode": "debug"
|
||||||
|
},
|
||||||
|
"module": {
|
||||||
|
"name": "graph",
|
||||||
|
"type": "har",
|
||||||
|
"description": "TrulyMEM graph feature module",
|
||||||
|
"deviceTypes": [
|
||||||
|
"phone",
|
||||||
|
"tablet",
|
||||||
|
"2in1"
|
||||||
|
],
|
||||||
|
"packageName": "@ohos/graph",
|
||||||
|
"installationFree": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"app": {
|
||||||
|
"bundleName": "com.trulymem.app",
|
||||||
|
"debug": true,
|
||||||
|
"versionCode": 1000001,
|
||||||
|
"versionName": "1.0.0",
|
||||||
|
"minAPIVersion": 60100023,
|
||||||
|
"targetAPIVersion": 60100023,
|
||||||
|
"apiReleaseType": "Release",
|
||||||
|
"targetMinorAPIVersion": 0,
|
||||||
|
"targetPatchAPIVersion": 0,
|
||||||
|
"compileSdkVersion": "6.1.0.105",
|
||||||
|
"compileSdkType": "HarmonyOS",
|
||||||
|
"appEnvironments": [],
|
||||||
|
"bundleType": "app",
|
||||||
|
"buildMode": "debug"
|
||||||
|
},
|
||||||
|
"module": {
|
||||||
|
"name": "settings",
|
||||||
|
"type": "har",
|
||||||
|
"description": "TrulyMEM settings feature module",
|
||||||
|
"deviceTypes": [
|
||||||
|
"phone",
|
||||||
|
"tablet",
|
||||||
|
"2in1"
|
||||||
|
],
|
||||||
|
"packageName": "@ohos/settings",
|
||||||
|
"installationFree": false
|
||||||
|
}
|
||||||
|
}
|
||||||
4529
products/phone/build/default/cache/default/default@CompileArkTS/esmodule/.ts_checker_cache
vendored
Normal file
4529
products/phone/build/default/cache/default/default@CompileArkTS/esmodule/.ts_checker_cache
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1
products/phone/build/default/cache/default/default@CompileArkTS/esmodule/.tsbuildinfo
vendored
Normal file
1
products/phone/build/default/cache/default/default@CompileArkTS/esmodule/.tsbuildinfo
vendored
Normal file
File diff suppressed because one or more lines are too long
1
products/phone/build/default/cache/default/default@CompileArkTS/esmodule/.tsbuildinfo.linter
vendored
Normal file
1
products/phone/build/default/cache/default/default@CompileArkTS/esmodule/.tsbuildinfo.linter
vendored
Normal file
File diff suppressed because one or more lines are too long
1
products/phone/build/default/cache/default/default@CompileArkTS/esmodule/compileInfo.json
vendored
Normal file
1
products/phone/build/default/cache/default/default@CompileArkTS/esmodule/compileInfo.json
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"buildMode":"Debug"}
|
||||||
BIN
products/phone/build/default/cache/default/default@CompileArkTS/esmodule/debug/chat/Index.protoBin
vendored
Normal file
BIN
products/phone/build/default/cache/default/default@CompileArkTS/esmodule/debug/chat/Index.protoBin
vendored
Normal file
Binary file not shown.
1
products/phone/build/default/cache/default/default@CompileArkTS/esmodule/debug/chat/Index.ts
vendored
Normal file
1
products/phone/build/default/cache/default/default@CompileArkTS/esmodule/debug/chat/Index.ts
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { ChatPage } from "@normalized:N&&&@ohos/chat/src/main/ets/pages/ChatPage&1.0.0";
|
||||||
Binary file not shown.
@ -0,0 +1,488 @@
|
|||||||
|
if (!("finalizeConstruction" in ViewPU.prototype)) {
|
||||||
|
Reflect.set(ViewPU.prototype, "finalizeConstruction", () => { });
|
||||||
|
}
|
||||||
|
interface ChatMessageList_Params {
|
||||||
|
messages?: ChatMessage[];
|
||||||
|
isThinking?: boolean;
|
||||||
|
toolCallLog?: string;
|
||||||
|
scrollController?: Scroller;
|
||||||
|
}
|
||||||
|
interface ChatInputBar_Params {
|
||||||
|
inputText?: string;
|
||||||
|
isThinking?: boolean;
|
||||||
|
onSend?: () => void;
|
||||||
|
}
|
||||||
|
interface ToolCallLogPanel_Params {
|
||||||
|
logText?: string;
|
||||||
|
}
|
||||||
|
interface ThinkingIndicator_Params {
|
||||||
|
}
|
||||||
|
interface ChatMessageBubble_Params {
|
||||||
|
msg?: ChatMessage;
|
||||||
|
}
|
||||||
|
import type { ChatMessage } from '@ohos/common';
|
||||||
|
export class ChatMessageBubble extends ViewPU {
|
||||||
|
constructor(parent, params, __localStorage, elmtId = -1, paramsLambda = undefined, extraInfo) {
|
||||||
|
super(parent, __localStorage, elmtId, extraInfo);
|
||||||
|
if (typeof paramsLambda === "function") {
|
||||||
|
this.paramsGenerator_ = paramsLambda;
|
||||||
|
}
|
||||||
|
this.__msg = new SynchedPropertyNesedObjectPU(params.msg, this, "msg");
|
||||||
|
this.setInitiallyProvidedValue(params);
|
||||||
|
this.finalizeConstruction();
|
||||||
|
}
|
||||||
|
setInitiallyProvidedValue(params: ChatMessageBubble_Params) {
|
||||||
|
this.__msg.set(params.msg);
|
||||||
|
}
|
||||||
|
updateStateVars(params: ChatMessageBubble_Params) {
|
||||||
|
this.__msg.set(params.msg);
|
||||||
|
}
|
||||||
|
purgeVariableDependenciesOnElmtId(rmElmtId) {
|
||||||
|
this.__msg.purgeDependencyOnElmtId(rmElmtId);
|
||||||
|
}
|
||||||
|
aboutToBeDeleted() {
|
||||||
|
this.__msg.aboutToBeDeleted();
|
||||||
|
SubscriberManager.Get().delete(this.id__());
|
||||||
|
this.aboutToBeDeletedInternal();
|
||||||
|
}
|
||||||
|
private __msg: SynchedPropertyNesedObjectPU<ChatMessage>;
|
||||||
|
get msg() {
|
||||||
|
return this.__msg.get();
|
||||||
|
}
|
||||||
|
initialRender() {
|
||||||
|
this.observeComponentCreation2((elmtId, isInitialRender) => {
|
||||||
|
Column.create();
|
||||||
|
Column.padding(12);
|
||||||
|
Column.backgroundColor(this.msg.role === 'user' ? 'rgba(124,77,255,0.15)' : 'rgba(245,245,245,0.1)');
|
||||||
|
Column.borderRadius(12);
|
||||||
|
Column.border({
|
||||||
|
width: 1,
|
||||||
|
color: this.msg.role === 'user' ? 'rgba(124,77,255,0.3)' : 'rgba(255,255,255,0.1)'
|
||||||
|
});
|
||||||
|
Column.backgroundBlurStyle(BlurStyle.Thin);
|
||||||
|
Column.margin({ left: 8, right: 8, bottom: 8 });
|
||||||
|
Column.width('100%');
|
||||||
|
Column.alignItems(HorizontalAlign.Start);
|
||||||
|
}, Column);
|
||||||
|
this.observeComponentCreation2((elmtId, isInitialRender) => {
|
||||||
|
// 角色标识
|
||||||
|
Text.create(this.msg.role === 'user' ? '🧑 你' : '🤖 AI');
|
||||||
|
// 角色标识
|
||||||
|
Text.fontSize(11);
|
||||||
|
// 角色标识
|
||||||
|
Text.fontColor(this.msg.role === 'user' ? '#7C4DFF' : '#999');
|
||||||
|
// 角色标识
|
||||||
|
Text.width('100%');
|
||||||
|
}, Text);
|
||||||
|
// 角色标识
|
||||||
|
Text.pop();
|
||||||
|
this.observeComponentCreation2((elmtId, isInitialRender) => {
|
||||||
|
// 消息内容
|
||||||
|
Text.create(this.msg.content);
|
||||||
|
// 消息内容
|
||||||
|
Text.fontSize(15);
|
||||||
|
// 消息内容
|
||||||
|
Text.width('100%');
|
||||||
|
// 消息内容
|
||||||
|
Text.margin({ top: 4 });
|
||||||
|
// 消息内容
|
||||||
|
Text.fontColor('#FFFFFF');
|
||||||
|
}, Text);
|
||||||
|
// 消息内容
|
||||||
|
Text.pop();
|
||||||
|
Column.pop();
|
||||||
|
}
|
||||||
|
rerender() {
|
||||||
|
this.updateDirtyElements();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class ThinkingIndicator extends ViewPU {
|
||||||
|
constructor(parent, params, __localStorage, elmtId = -1, paramsLambda = undefined, extraInfo) {
|
||||||
|
super(parent, __localStorage, elmtId, extraInfo);
|
||||||
|
if (typeof paramsLambda === "function") {
|
||||||
|
this.paramsGenerator_ = paramsLambda;
|
||||||
|
}
|
||||||
|
this.setInitiallyProvidedValue(params);
|
||||||
|
this.finalizeConstruction();
|
||||||
|
}
|
||||||
|
setInitiallyProvidedValue(params: ThinkingIndicator_Params) {
|
||||||
|
}
|
||||||
|
updateStateVars(params: ThinkingIndicator_Params) {
|
||||||
|
}
|
||||||
|
purgeVariableDependenciesOnElmtId(rmElmtId) {
|
||||||
|
}
|
||||||
|
aboutToBeDeleted() {
|
||||||
|
SubscriberManager.Get().delete(this.id__());
|
||||||
|
this.aboutToBeDeletedInternal();
|
||||||
|
}
|
||||||
|
initialRender() {
|
||||||
|
this.observeComponentCreation2((elmtId, isInitialRender) => {
|
||||||
|
Row.create();
|
||||||
|
Row.padding(12);
|
||||||
|
Row.backgroundColor('rgba(124,77,255,0.1)');
|
||||||
|
Row.borderRadius(12);
|
||||||
|
Row.border({ width: 1, color: 'rgba(124,77,255,0.2)' });
|
||||||
|
Row.backgroundBlurStyle(BlurStyle.Thin);
|
||||||
|
Row.margin({ left: 8, right: 8, bottom: 8 });
|
||||||
|
}, Row);
|
||||||
|
this.observeComponentCreation2((elmtId, isInitialRender) => {
|
||||||
|
LoadingProgress.create();
|
||||||
|
LoadingProgress.width(20);
|
||||||
|
LoadingProgress.height(20);
|
||||||
|
LoadingProgress.margin({ right: 8 });
|
||||||
|
LoadingProgress.color('#7C4DFF');
|
||||||
|
}, LoadingProgress);
|
||||||
|
this.observeComponentCreation2((elmtId, isInitialRender) => {
|
||||||
|
Text.create('AI 思考中...');
|
||||||
|
Text.fontSize(13);
|
||||||
|
Text.fontColor('#7C4DFF');
|
||||||
|
}, Text);
|
||||||
|
Text.pop();
|
||||||
|
Row.pop();
|
||||||
|
}
|
||||||
|
rerender() {
|
||||||
|
this.updateDirtyElements();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class ToolCallLogPanel extends ViewPU {
|
||||||
|
constructor(parent, params, __localStorage, elmtId = -1, paramsLambda = undefined, extraInfo) {
|
||||||
|
super(parent, __localStorage, elmtId, extraInfo);
|
||||||
|
if (typeof paramsLambda === "function") {
|
||||||
|
this.paramsGenerator_ = paramsLambda;
|
||||||
|
}
|
||||||
|
this.__logText = new SynchedPropertySimpleOneWayPU(params.logText, this, "logText");
|
||||||
|
this.setInitiallyProvidedValue(params);
|
||||||
|
this.finalizeConstruction();
|
||||||
|
}
|
||||||
|
setInitiallyProvidedValue(params: ToolCallLogPanel_Params) {
|
||||||
|
}
|
||||||
|
updateStateVars(params: ToolCallLogPanel_Params) {
|
||||||
|
this.__logText.reset(params.logText);
|
||||||
|
}
|
||||||
|
purgeVariableDependenciesOnElmtId(rmElmtId) {
|
||||||
|
this.__logText.purgeDependencyOnElmtId(rmElmtId);
|
||||||
|
}
|
||||||
|
aboutToBeDeleted() {
|
||||||
|
this.__logText.aboutToBeDeleted();
|
||||||
|
SubscriberManager.Get().delete(this.id__());
|
||||||
|
this.aboutToBeDeletedInternal();
|
||||||
|
}
|
||||||
|
private __logText: SynchedPropertySimpleOneWayPU<string>;
|
||||||
|
get logText() {
|
||||||
|
return this.__logText.get();
|
||||||
|
}
|
||||||
|
set logText(newValue: string) {
|
||||||
|
this.__logText.set(newValue);
|
||||||
|
}
|
||||||
|
initialRender() {
|
||||||
|
this.observeComponentCreation2((elmtId, isInitialRender) => {
|
||||||
|
Text.create(this.logText);
|
||||||
|
Text.fontSize(10);
|
||||||
|
Text.fontColor('#FF9800');
|
||||||
|
Text.backgroundColor('rgba(255,152,0,0.1)');
|
||||||
|
Text.padding(8);
|
||||||
|
Text.borderRadius(8);
|
||||||
|
Text.border({ width: 1, color: 'rgba(255,152,0,0.2)' });
|
||||||
|
Text.backgroundBlurStyle(BlurStyle.Thin);
|
||||||
|
Text.margin({ left: 8, right: 8, bottom: 4 });
|
||||||
|
Text.lineHeight(16);
|
||||||
|
}, Text);
|
||||||
|
Text.pop();
|
||||||
|
}
|
||||||
|
rerender() {
|
||||||
|
this.updateDirtyElements();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class ChatInputBar extends ViewPU {
|
||||||
|
constructor(parent, params, __localStorage, elmtId = -1, paramsLambda = undefined, extraInfo) {
|
||||||
|
super(parent, __localStorage, elmtId, extraInfo);
|
||||||
|
if (typeof paramsLambda === "function") {
|
||||||
|
this.paramsGenerator_ = paramsLambda;
|
||||||
|
}
|
||||||
|
this.__inputText = new SynchedPropertySimpleTwoWayPU(params.inputText, this, "inputText");
|
||||||
|
this.__isThinking = new SynchedPropertySimpleOneWayPU(params.isThinking, this, "isThinking");
|
||||||
|
this.onSend = undefined;
|
||||||
|
this.setInitiallyProvidedValue(params);
|
||||||
|
this.finalizeConstruction();
|
||||||
|
}
|
||||||
|
setInitiallyProvidedValue(params: ChatInputBar_Params) {
|
||||||
|
if (params.onSend !== undefined) {
|
||||||
|
this.onSend = params.onSend;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateStateVars(params: ChatInputBar_Params) {
|
||||||
|
this.__isThinking.reset(params.isThinking);
|
||||||
|
}
|
||||||
|
purgeVariableDependenciesOnElmtId(rmElmtId) {
|
||||||
|
this.__inputText.purgeDependencyOnElmtId(rmElmtId);
|
||||||
|
this.__isThinking.purgeDependencyOnElmtId(rmElmtId);
|
||||||
|
}
|
||||||
|
aboutToBeDeleted() {
|
||||||
|
this.__inputText.aboutToBeDeleted();
|
||||||
|
this.__isThinking.aboutToBeDeleted();
|
||||||
|
SubscriberManager.Get().delete(this.id__());
|
||||||
|
this.aboutToBeDeletedInternal();
|
||||||
|
}
|
||||||
|
private __inputText: SynchedPropertySimpleTwoWayPU<string>;
|
||||||
|
get inputText() {
|
||||||
|
return this.__inputText.get();
|
||||||
|
}
|
||||||
|
set inputText(newValue: string) {
|
||||||
|
this.__inputText.set(newValue);
|
||||||
|
}
|
||||||
|
private __isThinking: SynchedPropertySimpleOneWayPU<boolean>;
|
||||||
|
get isThinking() {
|
||||||
|
return this.__isThinking.get();
|
||||||
|
}
|
||||||
|
set isThinking(newValue: boolean) {
|
||||||
|
this.__isThinking.set(newValue);
|
||||||
|
}
|
||||||
|
private onSend?: () => void;
|
||||||
|
initialRender() {
|
||||||
|
this.observeComponentCreation2((elmtId, isInitialRender) => {
|
||||||
|
Row.create();
|
||||||
|
Row.width('100%');
|
||||||
|
Row.padding(8);
|
||||||
|
Row.backgroundColor('rgba(255,255,255,0.05)');
|
||||||
|
Row.backgroundBlurStyle(BlurStyle.Regular);
|
||||||
|
Row.border({
|
||||||
|
width: 1,
|
||||||
|
color: 'rgba(124,77,255,0.2)',
|
||||||
|
style: BorderStyle.Solid
|
||||||
|
});
|
||||||
|
}, Row);
|
||||||
|
this.observeComponentCreation2((elmtId, isInitialRender) => {
|
||||||
|
TextArea.create({ text: this.inputText, placeholder: '输入消息...' });
|
||||||
|
TextArea.layoutWeight(1);
|
||||||
|
TextArea.onChange((v: string) => { this.inputText = v; });
|
||||||
|
TextArea.height(40);
|
||||||
|
TextArea.backgroundColor('rgba(255,255,255,0.1)');
|
||||||
|
TextArea.borderRadius(8);
|
||||||
|
TextArea.border({ width: 1, color: 'rgba(124,77,255,0.3)' });
|
||||||
|
}, TextArea);
|
||||||
|
this.observeComponentCreation2((elmtId, isInitialRender) => {
|
||||||
|
Button.createWithLabel('发送');
|
||||||
|
Button.enabled(!this.isThinking);
|
||||||
|
Button.onClick(() => { this.onSend?.(); });
|
||||||
|
Button.backgroundColor('#7C4DFF');
|
||||||
|
Button.borderRadius(8);
|
||||||
|
}, Button);
|
||||||
|
Button.pop();
|
||||||
|
Row.pop();
|
||||||
|
}
|
||||||
|
rerender() {
|
||||||
|
this.updateDirtyElements();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class ChatMessageList extends ViewPU {
|
||||||
|
constructor(parent, params, __localStorage, elmtId = -1, paramsLambda = undefined, extraInfo) {
|
||||||
|
super(parent, __localStorage, elmtId, extraInfo);
|
||||||
|
if (typeof paramsLambda === "function") {
|
||||||
|
this.paramsGenerator_ = paramsLambda;
|
||||||
|
}
|
||||||
|
this.__messages = new SynchedPropertyObjectOneWayPU(params.messages, this, "messages");
|
||||||
|
this.__isThinking = new SynchedPropertySimpleOneWayPU(params.isThinking, this, "isThinking");
|
||||||
|
this.__toolCallLog = new SynchedPropertySimpleOneWayPU(params.toolCallLog, this, "toolCallLog");
|
||||||
|
this.scrollController = new Scroller();
|
||||||
|
this.setInitiallyProvidedValue(params);
|
||||||
|
this.finalizeConstruction();
|
||||||
|
}
|
||||||
|
setInitiallyProvidedValue(params: ChatMessageList_Params) {
|
||||||
|
if (params.scrollController !== undefined) {
|
||||||
|
this.scrollController = params.scrollController;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateStateVars(params: ChatMessageList_Params) {
|
||||||
|
this.__messages.reset(params.messages);
|
||||||
|
this.__isThinking.reset(params.isThinking);
|
||||||
|
this.__toolCallLog.reset(params.toolCallLog);
|
||||||
|
}
|
||||||
|
purgeVariableDependenciesOnElmtId(rmElmtId) {
|
||||||
|
this.__messages.purgeDependencyOnElmtId(rmElmtId);
|
||||||
|
this.__isThinking.purgeDependencyOnElmtId(rmElmtId);
|
||||||
|
this.__toolCallLog.purgeDependencyOnElmtId(rmElmtId);
|
||||||
|
}
|
||||||
|
aboutToBeDeleted() {
|
||||||
|
this.__messages.aboutToBeDeleted();
|
||||||
|
this.__isThinking.aboutToBeDeleted();
|
||||||
|
this.__toolCallLog.aboutToBeDeleted();
|
||||||
|
SubscriberManager.Get().delete(this.id__());
|
||||||
|
this.aboutToBeDeletedInternal();
|
||||||
|
}
|
||||||
|
private __messages: SynchedPropertySimpleOneWayPU<ChatMessage[]>;
|
||||||
|
get messages() {
|
||||||
|
return this.__messages.get();
|
||||||
|
}
|
||||||
|
set messages(newValue: ChatMessage[]) {
|
||||||
|
this.__messages.set(newValue);
|
||||||
|
}
|
||||||
|
private __isThinking: SynchedPropertySimpleOneWayPU<boolean>;
|
||||||
|
get isThinking() {
|
||||||
|
return this.__isThinking.get();
|
||||||
|
}
|
||||||
|
set isThinking(newValue: boolean) {
|
||||||
|
this.__isThinking.set(newValue);
|
||||||
|
}
|
||||||
|
private __toolCallLog: SynchedPropertySimpleOneWayPU<string>;
|
||||||
|
get toolCallLog() {
|
||||||
|
return this.__toolCallLog.get();
|
||||||
|
}
|
||||||
|
set toolCallLog(newValue: string) {
|
||||||
|
this.__toolCallLog.set(newValue);
|
||||||
|
}
|
||||||
|
private scrollController: Scroller;
|
||||||
|
initialRender() {
|
||||||
|
this.observeComponentCreation2((elmtId, isInitialRender) => {
|
||||||
|
List.create();
|
||||||
|
List.width('100%');
|
||||||
|
List.layoutWeight(1);
|
||||||
|
List.backgroundColor('rgba(0,0,0,0.1)');
|
||||||
|
}, List);
|
||||||
|
this.observeComponentCreation2((elmtId, isInitialRender) => {
|
||||||
|
ForEach.create();
|
||||||
|
const forEachItemGenFunction = _item => {
|
||||||
|
const msg = _item;
|
||||||
|
{
|
||||||
|
const itemCreation = (elmtId, isInitialRender) => {
|
||||||
|
ViewStackProcessor.StartGetAccessRecordingFor(elmtId);
|
||||||
|
ListItem.create(deepRenderFunction, true);
|
||||||
|
if (!isInitialRender) {
|
||||||
|
ListItem.pop();
|
||||||
|
}
|
||||||
|
ViewStackProcessor.StopGetAccessRecording();
|
||||||
|
};
|
||||||
|
const itemCreation2 = (elmtId, isInitialRender) => {
|
||||||
|
ListItem.create(deepRenderFunction, true);
|
||||||
|
};
|
||||||
|
const deepRenderFunction = (elmtId, isInitialRender) => {
|
||||||
|
itemCreation(elmtId, isInitialRender);
|
||||||
|
{
|
||||||
|
this.observeComponentCreation2((elmtId, isInitialRender) => {
|
||||||
|
if (isInitialRender) {
|
||||||
|
let componentCall = new ChatMessageBubble(this, { msg: msg }, undefined, elmtId, () => { }, { page: "features/chat/src/main/ets/components/ChatComponents.ets", line: 141, col: 11 });
|
||||||
|
ViewPU.create(componentCall);
|
||||||
|
let paramsLambda = () => {
|
||||||
|
return {
|
||||||
|
msg: msg
|
||||||
|
};
|
||||||
|
};
|
||||||
|
componentCall.paramsGenerator_ = paramsLambda;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.updateStateVarsOfChildByElmtId(elmtId, {
|
||||||
|
msg: msg
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, { name: "ChatMessageBubble" });
|
||||||
|
}
|
||||||
|
ListItem.pop();
|
||||||
|
};
|
||||||
|
this.observeComponentCreation2(itemCreation2, ListItem);
|
||||||
|
ListItem.pop();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.forEachUpdateFunction(elmtId, this.messages, forEachItemGenFunction);
|
||||||
|
}, ForEach);
|
||||||
|
ForEach.pop();
|
||||||
|
this.observeComponentCreation2((elmtId, isInitialRender) => {
|
||||||
|
If.create();
|
||||||
|
if (this.isThinking) {
|
||||||
|
this.ifElseBranchUpdateFunction(0, () => {
|
||||||
|
{
|
||||||
|
const itemCreation = (elmtId, isInitialRender) => {
|
||||||
|
ViewStackProcessor.StartGetAccessRecordingFor(elmtId);
|
||||||
|
ListItem.create(deepRenderFunction, true);
|
||||||
|
if (!isInitialRender) {
|
||||||
|
ListItem.pop();
|
||||||
|
}
|
||||||
|
ViewStackProcessor.StopGetAccessRecording();
|
||||||
|
};
|
||||||
|
const itemCreation2 = (elmtId, isInitialRender) => {
|
||||||
|
ListItem.create(deepRenderFunction, true);
|
||||||
|
};
|
||||||
|
const deepRenderFunction = (elmtId, isInitialRender) => {
|
||||||
|
itemCreation(elmtId, isInitialRender);
|
||||||
|
{
|
||||||
|
this.observeComponentCreation2((elmtId, isInitialRender) => {
|
||||||
|
if (isInitialRender) {
|
||||||
|
let componentCall = new ThinkingIndicator(this, {}, undefined, elmtId, () => { }, { page: "features/chat/src/main/ets/components/ChatComponents.ets", line: 147, col: 11 });
|
||||||
|
ViewPU.create(componentCall);
|
||||||
|
let paramsLambda = () => {
|
||||||
|
return {};
|
||||||
|
};
|
||||||
|
componentCall.paramsGenerator_ = paramsLambda;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.updateStateVarsOfChildByElmtId(elmtId, {});
|
||||||
|
}
|
||||||
|
}, { name: "ThinkingIndicator" });
|
||||||
|
}
|
||||||
|
ListItem.pop();
|
||||||
|
};
|
||||||
|
this.observeComponentCreation2(itemCreation2, ListItem);
|
||||||
|
ListItem.pop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.ifElseBranchUpdateFunction(1, () => {
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, If);
|
||||||
|
If.pop();
|
||||||
|
this.observeComponentCreation2((elmtId, isInitialRender) => {
|
||||||
|
If.create();
|
||||||
|
if (this.toolCallLog && !this.isThinking) {
|
||||||
|
this.ifElseBranchUpdateFunction(0, () => {
|
||||||
|
{
|
||||||
|
const itemCreation = (elmtId, isInitialRender) => {
|
||||||
|
ViewStackProcessor.StartGetAccessRecordingFor(elmtId);
|
||||||
|
ListItem.create(deepRenderFunction, true);
|
||||||
|
if (!isInitialRender) {
|
||||||
|
ListItem.pop();
|
||||||
|
}
|
||||||
|
ViewStackProcessor.StopGetAccessRecording();
|
||||||
|
};
|
||||||
|
const itemCreation2 = (elmtId, isInitialRender) => {
|
||||||
|
ListItem.create(deepRenderFunction, true);
|
||||||
|
};
|
||||||
|
const deepRenderFunction = (elmtId, isInitialRender) => {
|
||||||
|
itemCreation(elmtId, isInitialRender);
|
||||||
|
{
|
||||||
|
this.observeComponentCreation2((elmtId, isInitialRender) => {
|
||||||
|
if (isInitialRender) {
|
||||||
|
let componentCall = new ToolCallLogPanel(this, { logText: this.toolCallLog }, undefined, elmtId, () => { }, { page: "features/chat/src/main/ets/components/ChatComponents.ets", line: 153, col: 11 });
|
||||||
|
ViewPU.create(componentCall);
|
||||||
|
let paramsLambda = () => {
|
||||||
|
return {
|
||||||
|
logText: this.toolCallLog
|
||||||
|
};
|
||||||
|
};
|
||||||
|
componentCall.paramsGenerator_ = paramsLambda;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.updateStateVarsOfChildByElmtId(elmtId, {
|
||||||
|
logText: this.toolCallLog
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, { name: "ToolCallLogPanel" });
|
||||||
|
}
|
||||||
|
ListItem.pop();
|
||||||
|
};
|
||||||
|
this.observeComponentCreation2(itemCreation2, ListItem);
|
||||||
|
ListItem.pop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.ifElseBranchUpdateFunction(1, () => {
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, If);
|
||||||
|
If.pop();
|
||||||
|
List.pop();
|
||||||
|
}
|
||||||
|
rerender() {
|
||||||
|
this.updateDirtyElements();
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
@ -0,0 +1,219 @@
|
|||||||
|
if (!("finalizeConstruction" in ViewPU.prototype)) {
|
||||||
|
Reflect.set(ViewPU.prototype, "finalizeConstruction", () => { });
|
||||||
|
}
|
||||||
|
interface ChatPage_Params {
|
||||||
|
messages?: ChatMessage[];
|
||||||
|
inputText?: string;
|
||||||
|
db?: GraphDatabase;
|
||||||
|
toolCallLog?: string;
|
||||||
|
isThinking?: boolean;
|
||||||
|
agentService?: AIAgentService;
|
||||||
|
}
|
||||||
|
import { GraphMemoryService, AIAgentService, Logger } from "@normalized:N&&&@ohos/common/Index&1.0.0";
|
||||||
|
import type { GraphDatabase, ChatMessage, AgentResponse } from "@normalized:N&&&@ohos/common/Index&1.0.0";
|
||||||
|
import { ChatMessageList, ChatInputBar } from "@normalized:N&&&@ohos/chat/src/main/ets/components/ChatComponents&1.0.0";
|
||||||
|
export class ChatPage extends ViewPU {
|
||||||
|
constructor(parent, params, __localStorage, elmtId = -1, paramsLambda = undefined, extraInfo) {
|
||||||
|
super(parent, __localStorage, elmtId, extraInfo);
|
||||||
|
if (typeof paramsLambda === "function") {
|
||||||
|
this.paramsGenerator_ = paramsLambda;
|
||||||
|
}
|
||||||
|
this.__messages = new ObservedPropertyObjectPU([], this, "messages");
|
||||||
|
this.__inputText = new ObservedPropertySimplePU('', this, "inputText");
|
||||||
|
this.__db = new SynchedPropertyObjectOneWayPU(params.db, this, "db");
|
||||||
|
this.__toolCallLog = new ObservedPropertySimplePU('', this, "toolCallLog");
|
||||||
|
this.__isThinking = new ObservedPropertySimplePU(false, this, "isThinking");
|
||||||
|
this.agentService = undefined;
|
||||||
|
this.setInitiallyProvidedValue(params);
|
||||||
|
this.finalizeConstruction();
|
||||||
|
}
|
||||||
|
setInitiallyProvidedValue(params: ChatPage_Params) {
|
||||||
|
if (params.messages !== undefined) {
|
||||||
|
this.messages = params.messages;
|
||||||
|
}
|
||||||
|
if (params.inputText !== undefined) {
|
||||||
|
this.inputText = params.inputText;
|
||||||
|
}
|
||||||
|
if (params.toolCallLog !== undefined) {
|
||||||
|
this.toolCallLog = params.toolCallLog;
|
||||||
|
}
|
||||||
|
if (params.isThinking !== undefined) {
|
||||||
|
this.isThinking = params.isThinking;
|
||||||
|
}
|
||||||
|
if (params.agentService !== undefined) {
|
||||||
|
this.agentService = params.agentService;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateStateVars(params: ChatPage_Params) {
|
||||||
|
this.__db.reset(params.db);
|
||||||
|
}
|
||||||
|
purgeVariableDependenciesOnElmtId(rmElmtId) {
|
||||||
|
this.__messages.purgeDependencyOnElmtId(rmElmtId);
|
||||||
|
this.__inputText.purgeDependencyOnElmtId(rmElmtId);
|
||||||
|
this.__db.purgeDependencyOnElmtId(rmElmtId);
|
||||||
|
this.__toolCallLog.purgeDependencyOnElmtId(rmElmtId);
|
||||||
|
this.__isThinking.purgeDependencyOnElmtId(rmElmtId);
|
||||||
|
}
|
||||||
|
aboutToBeDeleted() {
|
||||||
|
this.__messages.aboutToBeDeleted();
|
||||||
|
this.__inputText.aboutToBeDeleted();
|
||||||
|
this.__db.aboutToBeDeleted();
|
||||||
|
this.__toolCallLog.aboutToBeDeleted();
|
||||||
|
this.__isThinking.aboutToBeDeleted();
|
||||||
|
SubscriberManager.Get().delete(this.id__());
|
||||||
|
this.aboutToBeDeletedInternal();
|
||||||
|
}
|
||||||
|
private __messages: ObservedPropertyObjectPU<ChatMessage[]>;
|
||||||
|
get messages() {
|
||||||
|
return this.__messages.get();
|
||||||
|
}
|
||||||
|
set messages(newValue: ChatMessage[]) {
|
||||||
|
this.__messages.set(newValue);
|
||||||
|
}
|
||||||
|
private __inputText: ObservedPropertySimplePU<string>;
|
||||||
|
get inputText() {
|
||||||
|
return this.__inputText.get();
|
||||||
|
}
|
||||||
|
set inputText(newValue: string) {
|
||||||
|
this.__inputText.set(newValue);
|
||||||
|
}
|
||||||
|
private __db: SynchedPropertySimpleOneWayPU<GraphDatabase>;
|
||||||
|
get db() {
|
||||||
|
return this.__db.get();
|
||||||
|
}
|
||||||
|
set db(newValue: GraphDatabase) {
|
||||||
|
this.__db.set(newValue);
|
||||||
|
}
|
||||||
|
private __toolCallLog: ObservedPropertySimplePU<string>;
|
||||||
|
get toolCallLog() {
|
||||||
|
return this.__toolCallLog.get();
|
||||||
|
}
|
||||||
|
set toolCallLog(newValue: string) {
|
||||||
|
this.__toolCallLog.set(newValue);
|
||||||
|
}
|
||||||
|
private __isThinking: ObservedPropertySimplePU<boolean>;
|
||||||
|
get isThinking() {
|
||||||
|
return this.__isThinking.get();
|
||||||
|
}
|
||||||
|
set isThinking(newValue: boolean) {
|
||||||
|
this.__isThinking.set(newValue);
|
||||||
|
}
|
||||||
|
private agentService?: AIAgentService;
|
||||||
|
async aboutToAppear() {
|
||||||
|
// 初始化图记忆服务和 Agent
|
||||||
|
const memoryService = new GraphMemoryService(this.db);
|
||||||
|
this.agentService = new AIAgentService(memoryService, getContext(this));
|
||||||
|
// 加载历史消息(兼容旧数据:无 session_id 时加载全部)
|
||||||
|
const rawHistory = await this.db.getChatHistory(50, this.agentService.getSessionId());
|
||||||
|
if (rawHistory.length === 0) {
|
||||||
|
// 新 session,尝试加载旧消息
|
||||||
|
const legacyHistory = await this.db.getChatHistory(50);
|
||||||
|
this.messages = legacyHistory.map(m => {
|
||||||
|
const msg: ChatMessage = { role: m.role, content: m.content };
|
||||||
|
return msg;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.messages = rawHistory.map(m => {
|
||||||
|
const msg: ChatMessage = { role: m.role, content: m.content };
|
||||||
|
return msg;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async sendMessage() {
|
||||||
|
if (!this.inputText.trim() || !this.agentService)
|
||||||
|
return;
|
||||||
|
const userMessage: string = this.inputText;
|
||||||
|
this.inputText = '';
|
||||||
|
// 添加用户消息
|
||||||
|
await this.db.saveChatMessage('user', userMessage, '', this.agentService.getSessionId());
|
||||||
|
this.messages = [...this.messages, { role: 'user', content: userMessage }];
|
||||||
|
// 显示 loading
|
||||||
|
this.isThinking = true;
|
||||||
|
this.toolCallLog = '';
|
||||||
|
try {
|
||||||
|
// 通过 Agent 发送消息
|
||||||
|
const agentResponse: AgentResponse = await this.agentService.sendMessage(userMessage);
|
||||||
|
// 记录工具调用日志
|
||||||
|
if (agentResponse.toolCalls.length > 0) {
|
||||||
|
const logs: string[] = agentResponse.toolCalls.map(tc => `🛠 ${tc.name}: ${tc.message}`);
|
||||||
|
this.toolCallLog = logs.join('\n');
|
||||||
|
}
|
||||||
|
// 保存并显示 AI 回复
|
||||||
|
await this.db.saveChatMessage('assistant', agentResponse.content, this.toolCallLog, this.agentService.getSessionId());
|
||||||
|
this.messages = [...this.messages, { role: 'assistant', content: agentResponse.content }];
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
Logger.error('Agent request failed: ' + JSON.stringify(err));
|
||||||
|
const errMsg = (err as Error).message || JSON.stringify(err);
|
||||||
|
this.messages = [...this.messages, { role: 'assistant', content: `⚠️ 请求失败: ${errMsg}` }];
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
this.isThinking = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
initialRender() {
|
||||||
|
this.observeComponentCreation2((elmtId, isInitialRender) => {
|
||||||
|
Column.create();
|
||||||
|
Column.width('100%');
|
||||||
|
Column.height('100%');
|
||||||
|
Column.backgroundColor('rgba(26,27,46,0.95)');
|
||||||
|
}, Column);
|
||||||
|
{
|
||||||
|
this.observeComponentCreation2((elmtId, isInitialRender) => {
|
||||||
|
if (isInitialRender) {
|
||||||
|
let componentCall = new ChatMessageList(this, {
|
||||||
|
messages: this.messages,
|
||||||
|
isThinking: this.isThinking,
|
||||||
|
toolCallLog: this.toolCallLog
|
||||||
|
}, undefined, elmtId, () => { }, { page: "features/chat/src/main/ets/pages/ChatPage.ets", line: 73, col: 13 });
|
||||||
|
ViewPU.create(componentCall);
|
||||||
|
let paramsLambda = () => {
|
||||||
|
return {
|
||||||
|
messages: this.messages,
|
||||||
|
isThinking: this.isThinking,
|
||||||
|
toolCallLog: this.toolCallLog
|
||||||
|
};
|
||||||
|
};
|
||||||
|
componentCall.paramsGenerator_ = paramsLambda;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.updateStateVarsOfChildByElmtId(elmtId, {
|
||||||
|
messages: this.messages,
|
||||||
|
isThinking: this.isThinking,
|
||||||
|
toolCallLog: this.toolCallLog
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, { name: "ChatMessageList" });
|
||||||
|
}
|
||||||
|
{
|
||||||
|
this.observeComponentCreation2((elmtId, isInitialRender) => {
|
||||||
|
if (isInitialRender) {
|
||||||
|
let componentCall = new ChatInputBar(this, {
|
||||||
|
inputText: this.__inputText,
|
||||||
|
isThinking: this.isThinking,
|
||||||
|
onSend: (): void => { this.sendMessage(); }
|
||||||
|
}, undefined, elmtId, () => { }, { page: "features/chat/src/main/ets/pages/ChatPage.ets", line: 79, col: 13 });
|
||||||
|
ViewPU.create(componentCall);
|
||||||
|
let paramsLambda = () => {
|
||||||
|
return {
|
||||||
|
inputText: this.inputText,
|
||||||
|
isThinking: this.isThinking,
|
||||||
|
onSend: (): void => { this.sendMessage(); }
|
||||||
|
};
|
||||||
|
};
|
||||||
|
componentCall.paramsGenerator_ = paramsLambda;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.updateStateVarsOfChildByElmtId(elmtId, {
|
||||||
|
isThinking: this.isThinking
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, { name: "ChatInputBar" });
|
||||||
|
}
|
||||||
|
Column.pop();
|
||||||
|
}
|
||||||
|
rerender() {
|
||||||
|
this.updateDirtyElements();
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
products/phone/build/default/cache/default/default@CompileArkTS/esmodule/debug/common/Index.protoBin
vendored
Normal file
BIN
products/phone/build/default/cache/default/default@CompileArkTS/esmodule/debug/common/Index.protoBin
vendored
Normal file
Binary file not shown.
16
products/phone/build/default/cache/default/default@CompileArkTS/esmodule/debug/common/Index.ts
vendored
Normal file
16
products/phone/build/default/cache/default/default@CompileArkTS/esmodule/debug/common/Index.ts
vendored
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
export { defaultLogger } from "@normalized:N&&&@ohos/common/src/main/ets/util/Logger&1.0.0";
|
||||||
|
export { defaultLogger as Logger } from "@normalized:N&&&@ohos/common/src/main/ets/util/Logger&1.0.0";
|
||||||
|
export { BreakpointType, WidthBreakpoint } from "@normalized:N&&&@ohos/common/src/main/ets/util/BreakpointSystem&1.0.0";
|
||||||
|
export type { BreakpointTypes } from "@normalized:N&&&@ohos/common/src/main/ets/util/BreakpointSystem&1.0.0";
|
||||||
|
export { PageContext } from "@normalized:N&&&@ohos/common/src/main/ets/routermanager/PageContext&1.0.0";
|
||||||
|
export type { RouterParam, IPageContext } from "@normalized:N&&&@ohos/common/src/main/ets/routermanager/PageContext&1.0.0";
|
||||||
|
export { Constants as TrulyMEMConstants } from "@normalized:N&&&@ohos/common/src/main/ets/constant/TrulyMEMConstants&1.0.0";
|
||||||
|
export { GraphDatabase } from "@normalized:N&&&@ohos/common/src/main/ets/model/GraphDatabase&1.0.0";
|
||||||
|
export type { RecallEntity, TimeRangeParams } from "@normalized:N&&&@ohos/common/src/main/ets/model/GraphDatabase&1.0.0";
|
||||||
|
export { GraphMemoryService } from "@normalized:N&&&@ohos/common/src/main/ets/service/GraphMemoryService&1.0.0";
|
||||||
|
export type { ConnectionItem, NodeDetailInfo } from "@normalized:N&&&@ohos/common/src/main/ets/service/GraphMemoryService&1.0.0";
|
||||||
|
export { AIAgentService } from "@normalized:N&&&@ohos/common/src/main/ets/service/AIAgentService&1.0.0";
|
||||||
|
export type { ChatMessage, AgentResponse } from "@normalized:N&&&@ohos/common/src/main/ets/service/AIAgentService&1.0.0";
|
||||||
|
export { BaseViewModel } from "@normalized:N&&&@ohos/common/src/main/ets/viewmodel/BaseViewModel&1.0.0";
|
||||||
|
export type { VMEvent } from "@normalized:N&&&@ohos/common/src/main/ets/viewmodel/BaseViewModel&1.0.0";
|
||||||
|
export { ImmersiveTabNavigation } from "@normalized:N&&&@ohos/common/src/main/ets/component/ImmersiveTabNavigation&1.0.0";
|
||||||
Binary file not shown.
@ -0,0 +1,236 @@
|
|||||||
|
if (!("finalizeConstruction" in ViewPU.prototype)) {
|
||||||
|
Reflect.set(ViewPU.prototype, "finalizeConstruction", () => { });
|
||||||
|
}
|
||||||
|
interface ImmersiveTabNavigation_Params {
|
||||||
|
currentIndex?: number;
|
||||||
|
contentBuilder?: () => void;
|
||||||
|
onTabChange?: (index: number) => void;
|
||||||
|
windowFocused?: boolean;
|
||||||
|
bottomAvoidHeight?: number;
|
||||||
|
}
|
||||||
|
import { defaultLogger } from "@normalized:N&&&@ohos/common/src/main/ets/util/Logger&1.0.0";
|
||||||
|
import window from "@ohos:window";
|
||||||
|
import type { BusinessError } from "@ohos:base";
|
||||||
|
const THEME_COLOR = '#7C4DFF';
|
||||||
|
export class ImmersiveTabNavigation extends ViewPU {
|
||||||
|
constructor(parent, params, __localStorage, elmtId = -1, paramsLambda = undefined, extraInfo) {
|
||||||
|
super(parent, __localStorage, elmtId, extraInfo);
|
||||||
|
if (typeof paramsLambda === "function") {
|
||||||
|
this.paramsGenerator_ = paramsLambda;
|
||||||
|
}
|
||||||
|
this.__currentIndex = new ObservedPropertySimplePU(0, this, "currentIndex");
|
||||||
|
this.contentBuilder = undefined;
|
||||||
|
this.onTabChange = undefined;
|
||||||
|
this.windowFocused = true;
|
||||||
|
this.bottomAvoidHeight = 0;
|
||||||
|
this.setInitiallyProvidedValue(params);
|
||||||
|
this.finalizeConstruction();
|
||||||
|
}
|
||||||
|
setInitiallyProvidedValue(params: ImmersiveTabNavigation_Params) {
|
||||||
|
if (params.currentIndex !== undefined) {
|
||||||
|
this.currentIndex = params.currentIndex;
|
||||||
|
}
|
||||||
|
if (params.contentBuilder !== undefined) {
|
||||||
|
this.contentBuilder = params.contentBuilder;
|
||||||
|
}
|
||||||
|
if (params.onTabChange !== undefined) {
|
||||||
|
this.onTabChange = params.onTabChange;
|
||||||
|
}
|
||||||
|
if (params.windowFocused !== undefined) {
|
||||||
|
this.windowFocused = params.windowFocused;
|
||||||
|
}
|
||||||
|
if (params.bottomAvoidHeight !== undefined) {
|
||||||
|
this.bottomAvoidHeight = params.bottomAvoidHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateStateVars(params: ImmersiveTabNavigation_Params) {
|
||||||
|
}
|
||||||
|
purgeVariableDependenciesOnElmtId(rmElmtId) {
|
||||||
|
this.__currentIndex.purgeDependencyOnElmtId(rmElmtId);
|
||||||
|
}
|
||||||
|
aboutToBeDeleted() {
|
||||||
|
this.__currentIndex.aboutToBeDeleted();
|
||||||
|
SubscriberManager.Get().delete(this.id__());
|
||||||
|
this.aboutToBeDeletedInternal();
|
||||||
|
}
|
||||||
|
private __currentIndex: ObservedPropertySimplePU<number>;
|
||||||
|
get currentIndex() {
|
||||||
|
return this.__currentIndex.get();
|
||||||
|
}
|
||||||
|
set currentIndex(newValue: number) {
|
||||||
|
this.__currentIndex.set(newValue);
|
||||||
|
}
|
||||||
|
private __contentBuilder;
|
||||||
|
private onTabChange?: (index: number) => void;
|
||||||
|
private windowFocused: boolean;
|
||||||
|
private bottomAvoidHeight: number;
|
||||||
|
aboutToAppear() {
|
||||||
|
const mainWindow = AppStorage.get<window.Window>('main_window');
|
||||||
|
if (mainWindow) {
|
||||||
|
try {
|
||||||
|
const avoidArea = mainWindow.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM);
|
||||||
|
this.bottomAvoidHeight = avoidArea.bottomRect.height || 0;
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
defaultLogger.error('Failed to get avoid area: ' + (e as BusinessError).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
triggerTabSwitchFeedback(index: number) {
|
||||||
|
this.currentIndex = index;
|
||||||
|
AppStorage.setOrCreate('global_theme_color', THEME_COLOR);
|
||||||
|
this.onTabChange?.(index);
|
||||||
|
}
|
||||||
|
tabBarBuilder(index: number, icon: string, label: string, parent = null) {
|
||||||
|
this.observeComponentCreation2((elmtId, isInitialRender) => {
|
||||||
|
Column.create();
|
||||||
|
Column.width('100%');
|
||||||
|
Column.height(56);
|
||||||
|
Column.justifyContent(FlexAlign.Center);
|
||||||
|
Column.alignItems(HorizontalAlign.Center);
|
||||||
|
}, Column);
|
||||||
|
this.observeComponentCreation2((elmtId, isInitialRender) => {
|
||||||
|
If.create();
|
||||||
|
if (this.currentIndex === index && this.windowFocused) {
|
||||||
|
this.ifElseBranchUpdateFunction(0, () => {
|
||||||
|
this.observeComponentCreation2((elmtId, isInitialRender) => {
|
||||||
|
Circle.create();
|
||||||
|
Circle.width(32);
|
||||||
|
Circle.height(32);
|
||||||
|
Circle.backgroundColor(`${THEME_COLOR}33`);
|
||||||
|
Circle.blur(8);
|
||||||
|
Circle.position({ x: '50%', y: '50%' });
|
||||||
|
Circle.translate({ x: '-50%', y: '-50%' });
|
||||||
|
}, Circle);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.ifElseBranchUpdateFunction(1, () => {
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, If);
|
||||||
|
If.pop();
|
||||||
|
this.observeComponentCreation2((elmtId, isInitialRender) => {
|
||||||
|
Text.create(icon);
|
||||||
|
Text.fontSize(20);
|
||||||
|
Text.opacity(this.currentIndex === index ? 1 : 0.5);
|
||||||
|
}, Text);
|
||||||
|
Text.pop();
|
||||||
|
this.observeComponentCreation2((elmtId, isInitialRender) => {
|
||||||
|
Text.create(label);
|
||||||
|
Text.fontSize(10);
|
||||||
|
Text.fontColor(this.currentIndex === index ? THEME_COLOR : '#999');
|
||||||
|
Text.fontWeight(this.currentIndex === index ? FontWeight.Bold : FontWeight.Normal);
|
||||||
|
}, Text);
|
||||||
|
Text.pop();
|
||||||
|
Column.pop();
|
||||||
|
}
|
||||||
|
initialRender() {
|
||||||
|
this.observeComponentCreation2((elmtId, isInitialRender) => {
|
||||||
|
Stack.create();
|
||||||
|
Stack.width('100%');
|
||||||
|
Stack.height('100%');
|
||||||
|
Stack.backgroundColor('#00000000');
|
||||||
|
}, Stack);
|
||||||
|
this.observeComponentCreation2((elmtId, isInitialRender) => {
|
||||||
|
Column.create();
|
||||||
|
Column.width('100%');
|
||||||
|
Column.height('100%');
|
||||||
|
}, Column);
|
||||||
|
this.contentBuilder.bind(this)();
|
||||||
|
Column.pop();
|
||||||
|
this.observeComponentCreation2((elmtId, isInitialRender) => {
|
||||||
|
Column.create();
|
||||||
|
Column.width('92%');
|
||||||
|
Column.height(72);
|
||||||
|
Column.alignSelf(ItemAlign.Center);
|
||||||
|
Column.position({ y: `calc(100% - ${this.bottomAvoidHeight > 0 ? this.bottomAvoidHeight : 16}px - 72px)` });
|
||||||
|
Column.borderRadius(24);
|
||||||
|
Column.shadow({
|
||||||
|
radius: 20,
|
||||||
|
offsetY: -4,
|
||||||
|
color: 'rgba(0,0,0,0.15)'
|
||||||
|
});
|
||||||
|
}, Column);
|
||||||
|
this.observeComponentCreation2((elmtId, isInitialRender) => {
|
||||||
|
Stack.create();
|
||||||
|
Stack.width('100%');
|
||||||
|
Stack.height('100%');
|
||||||
|
}, Stack);
|
||||||
|
this.observeComponentCreation2((elmtId, isInitialRender) => {
|
||||||
|
Column.create();
|
||||||
|
Column.width('100%');
|
||||||
|
Column.height('100%');
|
||||||
|
Column.backgroundBlurStyle(BlurStyle.Regular);
|
||||||
|
Column.borderRadius(24);
|
||||||
|
}, Column);
|
||||||
|
Column.pop();
|
||||||
|
this.observeComponentCreation2((elmtId, isInitialRender) => {
|
||||||
|
Column.create();
|
||||||
|
Column.width('100%');
|
||||||
|
Column.height('100%');
|
||||||
|
Column.backgroundColor(`${THEME_COLOR}0D`);
|
||||||
|
Column.borderRadius(24);
|
||||||
|
}, Column);
|
||||||
|
Column.pop();
|
||||||
|
this.observeComponentCreation2((elmtId, isInitialRender) => {
|
||||||
|
Column.create();
|
||||||
|
Column.width('100%');
|
||||||
|
Column.height('100%');
|
||||||
|
Column.linearGradient({
|
||||||
|
angle: 180,
|
||||||
|
colors: [['rgba(255,255,255,0.15)', 0.0], ['rgba(255,255,255,0.05)', 1.0]]
|
||||||
|
});
|
||||||
|
Column.borderRadius(24);
|
||||||
|
}, Column);
|
||||||
|
Column.pop();
|
||||||
|
Stack.pop();
|
||||||
|
this.observeComponentCreation2((elmtId, isInitialRender) => {
|
||||||
|
Tabs.create({ index: this.currentIndex });
|
||||||
|
Tabs.width('100%');
|
||||||
|
Tabs.height(64);
|
||||||
|
Tabs.barPosition(BarPosition.End);
|
||||||
|
Tabs.onChange((index: number) => {
|
||||||
|
this.triggerTabSwitchFeedback(index);
|
||||||
|
});
|
||||||
|
}, Tabs);
|
||||||
|
this.observeComponentCreation2((elmtId, isInitialRender) => {
|
||||||
|
TabContent.create(() => {
|
||||||
|
this.observeComponentCreation2((elmtId, isInitialRender) => {
|
||||||
|
Column.create();
|
||||||
|
}, Column);
|
||||||
|
this.observeComponentCreation2((elmtId, isInitialRender) => {
|
||||||
|
Blank.create();
|
||||||
|
}, Blank);
|
||||||
|
Blank.pop();
|
||||||
|
Column.pop();
|
||||||
|
});
|
||||||
|
TabContent.tabBar({ builder: () => {
|
||||||
|
this.tabBarBuilder.call(this, 0, '🌌', 'TrulyMEM');
|
||||||
|
} });
|
||||||
|
}, TabContent);
|
||||||
|
TabContent.pop();
|
||||||
|
this.observeComponentCreation2((elmtId, isInitialRender) => {
|
||||||
|
TabContent.create(() => {
|
||||||
|
this.observeComponentCreation2((elmtId, isInitialRender) => {
|
||||||
|
Column.create();
|
||||||
|
}, Column);
|
||||||
|
this.observeComponentCreation2((elmtId, isInitialRender) => {
|
||||||
|
Blank.create();
|
||||||
|
}, Blank);
|
||||||
|
Blank.pop();
|
||||||
|
Column.pop();
|
||||||
|
});
|
||||||
|
TabContent.tabBar({ builder: () => {
|
||||||
|
this.tabBarBuilder.call(this, 1, '⚙', '设置');
|
||||||
|
} });
|
||||||
|
}, TabContent);
|
||||||
|
TabContent.pop();
|
||||||
|
Tabs.pop();
|
||||||
|
Column.pop();
|
||||||
|
Stack.pop();
|
||||||
|
}
|
||||||
|
rerender() {
|
||||||
|
this.updateDirtyElements();
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
@ -0,0 +1,40 @@
|
|||||||
|
export class Constants {
|
||||||
|
static readonly DB_NAME: string = 'trulymem.db';
|
||||||
|
static readonly CONFIG_PREF_NAME: string = 'trulymem_config';
|
||||||
|
static readonly DEFAULT_BASE_URL: string = 'https://api.deepseek.com';
|
||||||
|
static readonly DEFAULT_MODEL: string = 'deepseek-chat';
|
||||||
|
static readonly SECURITY_LEVEL: number = 1; // S1
|
||||||
|
// Table names
|
||||||
|
static readonly TABLE_NODES: string = 'nodes';
|
||||||
|
static readonly TABLE_RELATIONS: string = 'relations';
|
||||||
|
static readonly TABLE_CHAT: string = 'chat_records';
|
||||||
|
// SQL definitions
|
||||||
|
static readonly SQL_CREATE_NODES: string = `
|
||||||
|
CREATE TABLE IF NOT EXISTS nodes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
type TEXT DEFAULT 'concept',
|
||||||
|
mentions INTEGER DEFAULT 1,
|
||||||
|
created_at TEXT DEFAULT (datetime('now','localtime')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now','localtime'))
|
||||||
|
)`;
|
||||||
|
static readonly SQL_CREATE_RELATIONS: string = `
|
||||||
|
CREATE TABLE IF NOT EXISTS relations (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
subject_id INTEGER NOT NULL,
|
||||||
|
relation TEXT NOT NULL,
|
||||||
|
object_id INTEGER NOT NULL,
|
||||||
|
weight REAL DEFAULT 1.0,
|
||||||
|
created_at TEXT DEFAULT (datetime('now','localtime')),
|
||||||
|
FOREIGN KEY (subject_id) REFERENCES nodes(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (object_id) REFERENCES nodes(id) ON DELETE CASCADE
|
||||||
|
)`;
|
||||||
|
static readonly SQL_CREATE_CHAT: string = `
|
||||||
|
CREATE TABLE IF NOT EXISTS chat_records (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
tools TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now','localtime'))
|
||||||
|
)`;
|
||||||
|
}
|
||||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@ -0,0 +1,53 @@
|
|||||||
|
import { defaultLogger } from "@normalized:N&&&@ohos/common/src/main/ets/util/Logger&1.0.0";
|
||||||
|
import type { BusinessError } from "@ohos:base";
|
||||||
|
export interface RouterParam {
|
||||||
|
routerName: string;
|
||||||
|
param?: object;
|
||||||
|
}
|
||||||
|
export interface IPageContext {
|
||||||
|
openPage(data: RouterParam, animated?: boolean): void;
|
||||||
|
popPage(animated?: boolean): void;
|
||||||
|
replacePage(data: RouterParam, animated?: boolean): void;
|
||||||
|
}
|
||||||
|
export class PageContext implements IPageContext {
|
||||||
|
private readonly pathStack: NavPathStack;
|
||||||
|
constructor() {
|
||||||
|
this.pathStack = new NavPathStack();
|
||||||
|
}
|
||||||
|
public get navPathStack(): NavPathStack {
|
||||||
|
return this.pathStack;
|
||||||
|
}
|
||||||
|
public replacePage(data: RouterParam, animated: boolean = true): void {
|
||||||
|
try {
|
||||||
|
this.pathStack.replacePath({ name: data.routerName, param: data.param }, animated);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
const errMsg = (err as BusinessError).message || JSON.stringify(err);
|
||||||
|
defaultLogger.error('replacePage: ' + data.routerName + ' failed. ' + errMsg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public openPage(data: RouterParam, animated: boolean = true): void {
|
||||||
|
try {
|
||||||
|
this.pathStack.pushPath({ name: data.routerName, param: data.param }, animated);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
const errMsg = (err as BusinessError).message || JSON.stringify(err);
|
||||||
|
defaultLogger.error('openPage: ' + data.routerName + ' failed. ' + errMsg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public popPage(animated: boolean = true): void {
|
||||||
|
try {
|
||||||
|
this.pathStack.pop(animated);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
const errMsg = (err as BusinessError).message || JSON.stringify(err);
|
||||||
|
defaultLogger.error('popPage failed. ' + errMsg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public popPageByIndex(index: number, animated: boolean = true): void {
|
||||||
|
this.pathStack.popToIndex(index, animated);
|
||||||
|
}
|
||||||
|
public clear(animated: boolean = true): void {
|
||||||
|
this.pathStack.clear(animated);
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
@ -0,0 +1,785 @@
|
|||||||
|
import http from "@ohos:net.http";
|
||||||
|
import dataPreferences from "@ohos:data.preferences";
|
||||||
|
import type common from "@ohos:app.ability.common";
|
||||||
|
import { defaultLogger } from "@normalized:N&&&@ohos/common/src/main/ets/util/Logger&1.0.0";
|
||||||
|
import type { GraphMemoryService, EntityInfo, RelationInfo, MemoryRecallParams, MemoryCommitParams, MemoryPurgeParams, PurgeCriteriaParams, NewRelationParams, PersonaUpdateParams, TaskCreateParams, TaskSetStateParams, TaskDeleteParams, TaskLinkInfoParams, TaskArchiveParams, TripletInput, PersonaQueryResult, MemoryRecallResult } from './GraphMemoryService';
|
||||||
|
import type { TimeRangeParams } from '../model/GraphDatabase';
|
||||||
|
export interface ChatMessage {
|
||||||
|
role: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
export interface AgentResponse {
|
||||||
|
content: string;
|
||||||
|
toolCalls: ToolCallResult[];
|
||||||
|
}
|
||||||
|
export interface ToolCallResult {
|
||||||
|
name: string;
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
// ========= 工具定义类型 =========
|
||||||
|
// Concrete interface for tool property definitions (replaces Record<string, T>)
|
||||||
|
interface ToolPropertiesDefinition {
|
||||||
|
days?: ToolParamProperty;
|
||||||
|
queryIntent?: ToolParamProperty;
|
||||||
|
seedEntities?: ToolParamProperty;
|
||||||
|
depth?: ToolParamProperty;
|
||||||
|
timeRange?: ToolParamProperty;
|
||||||
|
sessionFilter?: ToolParamProperty;
|
||||||
|
triplets?: ToolParamProperty;
|
||||||
|
entityTypes?: ToolParamProperty;
|
||||||
|
sessionId?: ToolParamProperty;
|
||||||
|
turnId?: ToolParamProperty;
|
||||||
|
criteria?: ToolParamProperty;
|
||||||
|
mode?: ToolParamProperty;
|
||||||
|
newRelation?: ToolParamProperty;
|
||||||
|
tone?: ToolParamProperty;
|
||||||
|
style?: ToolParamProperty;
|
||||||
|
personality?: ToolParamProperty;
|
||||||
|
catchphrase?: ToolParamProperty;
|
||||||
|
background?: ToolParamProperty;
|
||||||
|
taskId?: ToolParamProperty;
|
||||||
|
description?: ToolParamProperty;
|
||||||
|
infoNodes?: ToolParamProperty;
|
||||||
|
state?: ToolParamProperty;
|
||||||
|
deleteInfoNodes?: ToolParamProperty;
|
||||||
|
infoNodeNames?: ToolParamProperty;
|
||||||
|
summary?: ToolParamProperty;
|
||||||
|
limit?: ToolParamProperty;
|
||||||
|
stateFilter?: ToolParamProperty;
|
||||||
|
subject?: ToolParamProperty;
|
||||||
|
relation?: ToolParamProperty;
|
||||||
|
object?: ToolParamProperty;
|
||||||
|
confidence?: ToolParamProperty;
|
||||||
|
subjectContains?: ToolParamProperty;
|
||||||
|
relationType?: ToolParamProperty;
|
||||||
|
targetContains?: ToolParamProperty;
|
||||||
|
target?: ToolParamProperty;
|
||||||
|
dryRun?: ToolParamProperty;
|
||||||
|
keyword?: ToolParamProperty;
|
||||||
|
attribute?: ToolParamProperty;
|
||||||
|
sourceType?: ToolParamProperty;
|
||||||
|
targetType?: ToolParamProperty;
|
||||||
|
sourceHasStatus?: ToolParamProperty;
|
||||||
|
}
|
||||||
|
interface ToolParamProperty {
|
||||||
|
type: string;
|
||||||
|
description: string;
|
||||||
|
items?: ToolParamProperty;
|
||||||
|
properties?: ToolPropertiesDefinition;
|
||||||
|
required?: string[];
|
||||||
|
enum?: string[];
|
||||||
|
}
|
||||||
|
interface ToolParamDecl {
|
||||||
|
type: string;
|
||||||
|
properties: ToolPropertiesDefinition;
|
||||||
|
required?: string[];
|
||||||
|
}
|
||||||
|
interface ToolFunctionDecl {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
parameters: ToolParamDecl;
|
||||||
|
}
|
||||||
|
interface ToolFunctionDef {
|
||||||
|
type: string;
|
||||||
|
function: ToolFunctionDecl;
|
||||||
|
}
|
||||||
|
// ========= API 请求/响应结构 =========
|
||||||
|
interface ApiRequestMessage {
|
||||||
|
role: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
interface ApiRequest {
|
||||||
|
model: string;
|
||||||
|
messages: ApiRequestMessage[];
|
||||||
|
tools?: ToolFunctionDef[];
|
||||||
|
tool_choice?: string;
|
||||||
|
}
|
||||||
|
interface ApiToolCall {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
function: ToolFunctionCall;
|
||||||
|
}
|
||||||
|
interface ToolFunctionCall {
|
||||||
|
name: string;
|
||||||
|
arguments: string;
|
||||||
|
}
|
||||||
|
interface ApiChoiceMessage {
|
||||||
|
content?: string;
|
||||||
|
tool_calls?: ApiToolCall[];
|
||||||
|
}
|
||||||
|
interface ApiChoice {
|
||||||
|
message: ApiChoiceMessage;
|
||||||
|
}
|
||||||
|
interface ApiResponse {
|
||||||
|
choices: ApiChoice[];
|
||||||
|
}
|
||||||
|
// ========= 内部结果类型 =========
|
||||||
|
interface ExecuteToolResult {
|
||||||
|
name: string;
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
interface BuildContextBlockParams {
|
||||||
|
persona: Record<string, string>;
|
||||||
|
found: boolean;
|
||||||
|
entities: EntityInfo[];
|
||||||
|
relations: RelationInfo[];
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
// ========= 服务方法参数类型 =========
|
||||||
|
// ========= 工具定义辅助函数 =========
|
||||||
|
function makeStringProp(description: string): ToolParamProperty {
|
||||||
|
const result: ToolParamProperty = { type: 'string', description: description };
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
function makeIntegerProp(description: string): ToolParamProperty {
|
||||||
|
const result: ToolParamProperty = { type: 'integer', description: description };
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
function makeObjectProp(description: string, props: ToolPropertiesDefinition, required?: string[]): ToolParamProperty {
|
||||||
|
const param: ToolParamProperty = { type: 'object', description: description };
|
||||||
|
param.properties = props;
|
||||||
|
if (required && required.length > 0) {
|
||||||
|
param.required = required;
|
||||||
|
}
|
||||||
|
return param;
|
||||||
|
}
|
||||||
|
function makeArrayProp(description: string, item: ToolParamProperty): ToolParamProperty {
|
||||||
|
const result: ToolParamProperty = { type: 'array', description: description, items: item };
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
function makeBoolProp(description: string): ToolParamProperty {
|
||||||
|
const result: ToolParamProperty = { type: 'boolean', description: description };
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
function makeNumberProp(description: string): ToolParamProperty {
|
||||||
|
const result: ToolParamProperty = { type: 'number', description: description };
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
function makeEnumProp(description: string, enumValues: string[]): ToolParamProperty {
|
||||||
|
const result: ToolParamProperty = { type: 'string', description: description, enum: enumValues };
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
function makeToolDef(name: string, description: string, properties: ToolPropertiesDefinition, required?: string[]): ToolFunctionDef {
|
||||||
|
const params: ToolParamDecl = { type: 'object', properties: properties };
|
||||||
|
const func: ToolFunctionDecl = { name: name, description: description, parameters: params };
|
||||||
|
const tool: ToolFunctionDef = { type: 'function', function: func };
|
||||||
|
if (required && required.length > 0) {
|
||||||
|
tool.function.parameters.required = required;
|
||||||
|
}
|
||||||
|
return tool;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 系统提示词 — AI 人设 + 图记忆使用说明
|
||||||
|
*/
|
||||||
|
function buildSystemPrompt(personaContext: string): string {
|
||||||
|
return `你是 TrulyMEM(True Memory)——一个拥有真实记忆的 AI 助手。
|
||||||
|
|
||||||
|
## 核心身份
|
||||||
|
${personaContext || '你是一个帮助用户记录和回忆信息的助手。你的核心能力是基于图数据库的记忆系统。'}
|
||||||
|
|
||||||
|
## ⚠️ 内部执行顺序(不得向用户输出)
|
||||||
|
|
||||||
|
以下步骤是内部流程,绝对不要在你的回复中提及或输出。应当仅通过工具调用悄悄完成,回复时直接给出自然的对话内容。
|
||||||
|
|
||||||
|
1. memory_recall → 查询人设图
|
||||||
|
2. task_query → 查询工作记忆链/最近任务
|
||||||
|
3. 处理对话内容 + 思考回复
|
||||||
|
4. memory_commit → 写入本轮关键信息到图数据库
|
||||||
|
5. task_archive → 归档已完成的旧任务
|
||||||
|
6. 条件: 本轮调用 ≥5 次查询类工具 → context_rewrite 压缩工具 JSON
|
||||||
|
|
||||||
|
## 三元组规范
|
||||||
|
使用 memory_commit 时,subject/relation/object 每个字段必须是一个短关键字(1~5个字),不能是完整句子。
|
||||||
|
|
||||||
|
## 任务信息节点规范
|
||||||
|
- info_nodes 只能包含该任务专属的具体信息节点,严禁关联"用户"、"AI"、"系统"等全局通用实体
|
||||||
|
- 全局实体的信息直接用独立关系记录,不需要通过 Task 中转
|
||||||
|
|
||||||
|
## 可用工具
|
||||||
|
- memory_recall(queryIntent, seedEntities?, depth?, timeRange?, sessionFilter?): 检索记忆
|
||||||
|
- memory_commit(triplets, entityTypes?, sessionId?, turnId?): 写入记忆
|
||||||
|
- memory_purge(criteria, mode, newRelation?): 删除/修正记忆
|
||||||
|
- memory_introspect(sessionId?): 查看记忆状态统计
|
||||||
|
- memory_archive(days?): 归档旧记忆
|
||||||
|
- memory_cleanup(dryRun?): 清理已删除数据
|
||||||
|
- memory_query_archived(days?, keyword?): 查询已归档记忆
|
||||||
|
- context_rewrite(summary): 压缩工具调用上下文
|
||||||
|
- persona_update(tone?, style?, personality?, catchphrase?, background?): 更新人设
|
||||||
|
- persona_remove(attribute): 删除单条人设属性
|
||||||
|
- persona_clear(): 清除人设
|
||||||
|
- task_create(taskId, description, infoNodes?): 创建任务
|
||||||
|
- task_set_state(taskId, state): 设置任务状态
|
||||||
|
- task_delete(taskId, deleteInfoNodes?): 删除任务
|
||||||
|
- task_link_info(taskId, infoNodeNames): 关联信息节点
|
||||||
|
- task_archive(taskId, summary?): 归档任务
|
||||||
|
- task_query(limit?, stateFilter?): 查询任务列表
|
||||||
|
|
||||||
|
## 工具调用规则
|
||||||
|
1. ⚠️ 在完成所有工具调用之前,绝对不要输出任何文字。先默默调用工具,等所有结果返回后再输出一次完整的回复。
|
||||||
|
2. 每轮对话必须按顺序执行:步骤1查询人设 → 步骤2查询工作记忆链 → 步骤3处理请求
|
||||||
|
3. context_rewrite 必须单独调用,不能和其他工具在同一轮一起调!
|
||||||
|
4. 工具调用 ≥5 次后应使用 context_rewrite 压缩上下文
|
||||||
|
|
||||||
|
## 写入规则
|
||||||
|
用户明确表达以下信息时必须写入记忆:
|
||||||
|
- 偏好、兴趣
|
||||||
|
- 个人信息(工作、项目、学习)
|
||||||
|
- 计划安排
|
||||||
|
- 当前状态
|
||||||
|
- 结论性事实
|
||||||
|
|
||||||
|
推理得到的信息可以写入但需标注 [推测]。`;
|
||||||
|
}
|
||||||
|
// ========= 工具定义 =========
|
||||||
|
// Pre-typed property dictionaries for tool definitions
|
||||||
|
const recallTimeRangeDict: ToolPropertiesDefinition = { days: makeIntegerProp('最近N天') };
|
||||||
|
const tripletPropsDict: ToolPropertiesDefinition = {
|
||||||
|
subject: makeStringProp('主体'),
|
||||||
|
relation: makeStringProp('关系'),
|
||||||
|
object: makeStringProp('客体'),
|
||||||
|
confidence: makeNumberProp('置信度')
|
||||||
|
};
|
||||||
|
const purgeCriteriaDict: ToolPropertiesDefinition = {
|
||||||
|
subjectContains: makeStringProp('源实体名包含(模糊匹配)'),
|
||||||
|
relationType: makeStringProp('关系类型'),
|
||||||
|
targetContains: makeStringProp('目标实体名包含(模糊匹配)'),
|
||||||
|
sessionId: makeStringProp('会话ID过滤'),
|
||||||
|
sourceType: makeStringProp('源实体类型过滤(如 TaskNode)'),
|
||||||
|
targetType: makeStringProp('目标实体类型过滤'),
|
||||||
|
sourceHasStatus: makeStringProp('源实体状态过滤(如 archived)')
|
||||||
|
};
|
||||||
|
const newRelDict: ToolPropertiesDefinition = {
|
||||||
|
relation: makeStringProp(''),
|
||||||
|
target: makeStringProp('')
|
||||||
|
};
|
||||||
|
const EMPTY_PROPS: ToolPropertiesDefinition = {};
|
||||||
|
const recallProps: ToolPropertiesDefinition = {
|
||||||
|
queryIntent: makeStringProp('查询意图,支持逗号分隔多个关键词'),
|
||||||
|
seedEntities: makeArrayProp('种子实体(可选)', makeStringProp('')),
|
||||||
|
depth: makeIntegerProp('搜索深度,默认2'),
|
||||||
|
timeRange: makeObjectProp('时间范围(可选)', recallTimeRangeDict),
|
||||||
|
sessionFilter: makeStringProp('会话ID过滤(可选)')
|
||||||
|
};
|
||||||
|
const commitProps: ToolPropertiesDefinition = {
|
||||||
|
triplets: makeArrayProp('三元组列表', makeObjectProp('', tripletPropsDict, ['subject', 'relation', 'object'])),
|
||||||
|
entityTypes: makeObjectProp('实体类型映射(可选)', EMPTY_PROPS),
|
||||||
|
sessionId: makeStringProp('会话ID(可选)'),
|
||||||
|
turnId: makeIntegerProp('轮次ID(可选)')
|
||||||
|
};
|
||||||
|
const purgeProps: ToolPropertiesDefinition = {
|
||||||
|
criteria: makeObjectProp('删除条件', purgeCriteriaDict),
|
||||||
|
mode: makeEnumProp('删除模式:soft逻辑删除, hard物理删除, supersede纠错替代', ['soft', 'hard', 'supersede']),
|
||||||
|
newRelation: makeObjectProp('替代关系(supersede模式用)', newRelDict)
|
||||||
|
};
|
||||||
|
const personaProps: ToolPropertiesDefinition = {
|
||||||
|
tone: makeStringProp('语气'),
|
||||||
|
style: makeStringProp('风格'),
|
||||||
|
personality: makeStringProp('性格'),
|
||||||
|
catchphrase: makeStringProp('口头禅'),
|
||||||
|
background: makeStringProp('背景')
|
||||||
|
};
|
||||||
|
const createProps: ToolPropertiesDefinition = {
|
||||||
|
taskId: makeStringProp('任务ID'),
|
||||||
|
description: makeStringProp('任务描述'),
|
||||||
|
infoNodes: makeArrayProp('关联的信息节点名称列表', makeStringProp(''))
|
||||||
|
};
|
||||||
|
const setStateProps: ToolPropertiesDefinition = {
|
||||||
|
taskId: makeStringProp('任务ID'),
|
||||||
|
state: makeEnumProp('任务状态', ['进行中', '已完成', '已暂停', '已取消'])
|
||||||
|
};
|
||||||
|
const deleteProps: ToolPropertiesDefinition = {
|
||||||
|
taskId: makeStringProp('任务ID'),
|
||||||
|
deleteInfoNodes: makeBoolProp('是否删除关联的信息节点')
|
||||||
|
};
|
||||||
|
const linkInfoProps: ToolPropertiesDefinition = {
|
||||||
|
taskId: makeStringProp('任务ID'),
|
||||||
|
infoNodeNames: makeArrayProp('信息节点名称列表', makeStringProp(''))
|
||||||
|
};
|
||||||
|
const archiveProps: ToolPropertiesDefinition = {
|
||||||
|
taskId: makeStringProp('任务ID'),
|
||||||
|
summary: makeStringProp('归档摘要')
|
||||||
|
};
|
||||||
|
const queryProps: ToolPropertiesDefinition = {
|
||||||
|
limit: makeIntegerProp('返回数量,默认10'),
|
||||||
|
stateFilter: makeStringProp('状态过滤: 进行中/已完成/已暂停/已取消/archived')
|
||||||
|
};
|
||||||
|
const introspectProps: ToolPropertiesDefinition = {
|
||||||
|
sessionId: makeStringProp('会话ID(可选)')
|
||||||
|
};
|
||||||
|
const archiveProps2: ToolPropertiesDefinition = {
|
||||||
|
days: makeIntegerProp('归档天数,默认30')
|
||||||
|
};
|
||||||
|
const cleanupProps: ToolPropertiesDefinition = {
|
||||||
|
dryRun: makeBoolProp('仅预览不删除')
|
||||||
|
};
|
||||||
|
const queryArchivedProps: ToolPropertiesDefinition = {
|
||||||
|
days: makeIntegerProp('最近N天内的归档记录'),
|
||||||
|
keyword: makeStringProp('关键词过滤')
|
||||||
|
};
|
||||||
|
const contextRewriteProps: ToolPropertiesDefinition = {
|
||||||
|
summary: makeStringProp('压缩后的摘要文本,必须包含工具调用元信息')
|
||||||
|
};
|
||||||
|
const personaRemoveProps: ToolPropertiesDefinition = {
|
||||||
|
attribute: makeStringProp('要删除的属性名(如:扮演角色、说话风格)')
|
||||||
|
};
|
||||||
|
const TOOLS_DEFINITION: ToolFunctionDef[] = [
|
||||||
|
makeToolDef('memory_recall', '检索记忆。支持关键词、时间范围、会话过滤。返回相关实体和关系。\n\n【⚠️ 强制执行顺序 - 每轮必须严格遵守】\n1. 步骤1(必须首先执行): 查询人设图\n2. 步骤2(必须第二步执行): 查询工作记忆链\n【重要】跳过步骤1或步骤2将导致系统错误!', recallProps, ['queryIntent']),
|
||||||
|
makeToolDef('memory_commit', '写入记忆。将三元组写入图数据库,支持批量写入。\n\n【重要】写入原则:\n- 用户明确表达的信息 → 必须写入\n- AI推理得到的信息 → 可以写入,但需标注[推测]\n- 避免写入冗余或无意义的信息', commitProps, ['triplets']),
|
||||||
|
makeToolDef('memory_purge', '删除或修正记忆。支持条件删除和纠错替代。\n\n【使用场景】\n- 纠错替代修正错误信息\n- 删除特定类型的节点关系\n- 删除残留在已归档任务上的状态关系\n\n【重要】\n- 优先使用 supersede 模式修正错误\n- 软删除不会物理删除数据', purgeProps, ['criteria', 'mode']),
|
||||||
|
makeToolDef('memory_introspect', '查看记忆状态。返回实体数量、关系数量、热点实体。', introspectProps),
|
||||||
|
makeToolDef('memory_archive', '归档旧记忆。将N天前的非活跃关系标记为归档状态。', archiveProps2, ['days']),
|
||||||
|
makeToolDef('memory_cleanup', '清理无效数据。物理删除已删除状态超过90天的关系和孤立节点。', cleanupProps),
|
||||||
|
makeToolDef('memory_query_archived', '查询已归档的记忆。\n\n【使用场景】\n- 想了解之前归档过哪些记忆\n- 按关键词搜索归档内容\n- 按时间范围查看最近归档的历史\n\n【注意】\n- 只返回 status=archived 的原始关系记录\n- days 和 keyword 可以单独使用或组合使用', queryArchivedProps),
|
||||||
|
makeToolDef('context_rewrite', '压缩本轮对话的工具调用上下文。将冗长的JSON工具结果提炼为简洁摘要。\n\n【使用场景】\n- 本轮已执行 ≥5 次查询类工具调用\n- 【⚠️ 强制要求】context_rewrite 必须单独调用,不能和其他工具在同一轮一起调!', contextRewriteProps, ['summary']),
|
||||||
|
makeToolDef('persona_update', '更新AI人设属性(语气、风格、性格等)。', personaProps),
|
||||||
|
makeToolDef('persona_remove', '删除单条人设属性。保留其他人设不变。', personaRemoveProps, ['attribute']),
|
||||||
|
makeToolDef('persona_clear', '清除所有人设信息。', EMPTY_PROPS),
|
||||||
|
makeToolDef('task_create', '创建新的工作记忆任务节点。\n\n【重要】info_nodes 只能包含该任务专属的具体信息节点(如\"成语接龙_当前成语\"),**严禁关联\"用户\"、\"AI\"、\"系统\"等全局通用实体**——这些实体不应通过任务中转。', createProps, ['taskId', 'description']),
|
||||||
|
makeToolDef('task_set_state', '设置任务状态。', setStateProps, ['taskId', 'state']),
|
||||||
|
makeToolDef('task_delete', '删除任务节点。', deleteProps, ['taskId']),
|
||||||
|
makeToolDef('task_link_info', '关联信息节点到任务。\n\n【重要】info_node_names只能放任务专属的具体信息节点(如\"成语接龙_当前成语\"),**严禁放\"用户\"、\"AI\"、\"系统\"等全局通用实体**——这些实体不应通过任务中转。', linkInfoProps, ['taskId', 'infoNodeNames']),
|
||||||
|
makeToolDef('task_archive', '归档已完成/过期的任务。将任务状态设为 archived,同时写入完成摘要到图数据库。\n\n【使用场景】\n1. 话题转变时归档旧任务\n2. 已完成的任务及时归档\n3. 长时间无更新的任务归档\n\n【注意】优先使用 task_archive 替代 task_set_state(state=archived),因为它会自动写入完成摘要。', archiveProps, ['taskId']),
|
||||||
|
makeToolDef('task_query', '查询最近的任务列表。按更新时间倒序排列。新对话开始时优先使用此工具获取所有进展中的任务,避免重复创建。', queryProps)
|
||||||
|
];
|
||||||
|
// ========= 工具名称映射 =========
|
||||||
|
// Types for executeTool generic args
|
||||||
|
type ToolStateArg = '进行中' | '已完成' | '已暂停' | '已取消';
|
||||||
|
type ToolHandlerName = 'memoryRecal' | 'memoryCommit' | 'memoryPurge' | 'memoryIntrospect' | 'memoryArchive' | 'memoryCleanup' | 'memoryQueryArchived' | 'contextRewrite' | 'personaUpdate' | 'personaRemove' | 'personaClear' | 'taskCreate' | 'taskSetState' | 'taskDelete' | 'taskLinkInfo' | 'taskArchive' | 'taskQuery';
|
||||||
|
const TOOL_HANDLER_MAP: Record<string, ToolHandlerName> = {
|
||||||
|
'memory_recall': 'memoryRecal',
|
||||||
|
'memory_commit': 'memoryCommit',
|
||||||
|
'memory_purge': 'memoryPurge',
|
||||||
|
'memory_introspect': 'memoryIntrospect',
|
||||||
|
'memory_archive': 'memoryArchive',
|
||||||
|
'memory_cleanup': 'memoryCleanup',
|
||||||
|
'memory_query_archived': 'memoryQueryArchived',
|
||||||
|
'context_rewrite': 'contextRewrite',
|
||||||
|
'persona_update': 'personaUpdate',
|
||||||
|
'persona_remove': 'personaRemove',
|
||||||
|
'persona_clear': 'personaClear',
|
||||||
|
'task_create': 'taskCreate',
|
||||||
|
'task_set_state': 'taskSetState',
|
||||||
|
'task_delete': 'taskDelete',
|
||||||
|
'task_link_info': 'taskLinkInfo',
|
||||||
|
'task_archive': 'taskArchive',
|
||||||
|
'task_query': 'taskQuery',
|
||||||
|
};
|
||||||
|
// ========= AIAgentService =========
|
||||||
|
export class AIAgentService {
|
||||||
|
private memoryService: GraphMemoryService;
|
||||||
|
private currentSessionId: string;
|
||||||
|
private turnCounter: number = 0;
|
||||||
|
private appContext: common.Context;
|
||||||
|
constructor(memoryService: GraphMemoryService, appContext: common.Context, sessionId?: string) {
|
||||||
|
this.memoryService = memoryService;
|
||||||
|
this.appContext = appContext;
|
||||||
|
this.currentSessionId = sessionId || `session-hm-${Date.now()}`;
|
||||||
|
}
|
||||||
|
getSessionId(): string {
|
||||||
|
return this.currentSessionId;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 发送消息 — 完整的 Agent 流程
|
||||||
|
* 1. 查询人设
|
||||||
|
* 2. 查询工作记忆链
|
||||||
|
* 3. 注入上下文后请求 AI
|
||||||
|
* 4. 处理 tool_calls
|
||||||
|
* 5. 返回最终回复
|
||||||
|
*/
|
||||||
|
async sendMessage(userInput: string): Promise<AgentResponse> {
|
||||||
|
this.turnCounter++;
|
||||||
|
// === 步骤1+2: 获取上下文 ===
|
||||||
|
const personaResult = await this.memoryService.personaQuery();
|
||||||
|
const personaContext: string = personaResult.found ? this.formatPersona(personaResult.persona) : '';
|
||||||
|
const recallParams: MemoryRecallParams = {
|
||||||
|
queryIntent: 'TaskNode,工作记忆,任务链',
|
||||||
|
depth: 2
|
||||||
|
};
|
||||||
|
const taskResult = await this.memoryService.memoryRecall(recallParams);
|
||||||
|
// === 读取 API 配置 ===
|
||||||
|
const context = this.appContext;
|
||||||
|
const pref = await dataPreferences.getPreferences(context, 'trulymem_config');
|
||||||
|
const baseUrl: string = String(await pref.get('base_url', 'https://api.deepseek.com'));
|
||||||
|
const model: string = String(await pref.get('model', 'deepseek-chat'));
|
||||||
|
const apiKey: string = String(await pref.get('api_key', ''));
|
||||||
|
if (!apiKey) {
|
||||||
|
const noKeyResponse: AgentResponse = {
|
||||||
|
content: '⚠️ API Key 未配置,请先在设置页填写 API Key。',
|
||||||
|
toolCalls: []
|
||||||
|
};
|
||||||
|
return noKeyResponse;
|
||||||
|
}
|
||||||
|
// === 构建上下文丰富的消息 ===
|
||||||
|
const systemPrompt: string = buildSystemPrompt(personaContext);
|
||||||
|
const contextBlock: string = this.buildContextBlock(personaResult, taskResult);
|
||||||
|
const sysMsg: ApiRequestMessage = { role: 'system' as string, content: systemPrompt };
|
||||||
|
const userMsg: ApiRequestMessage = { role: 'user' as string, content: contextBlock + '\n\n---\n\n用户消息: ' + userInput };
|
||||||
|
const messages: ApiRequestMessage[] = [sysMsg, userMsg];
|
||||||
|
// === 步骤3: 请求 AI ===
|
||||||
|
const response: ApiResponse = await this.callApi(messages, baseUrl, model, apiKey);
|
||||||
|
const toolCalls: ToolCallResult[] = [];
|
||||||
|
// === 步骤4: 处理 tool_calls ===
|
||||||
|
if (response.choices && response.choices.length > 0) {
|
||||||
|
const choice: ApiChoice = response.choices[0];
|
||||||
|
const aiMessage: ApiChoiceMessage = choice.message;
|
||||||
|
// 处理函数调用
|
||||||
|
if (aiMessage.tool_calls && aiMessage.tool_calls.length > 0) {
|
||||||
|
for (const tc of aiMessage.tool_calls) {
|
||||||
|
const handlerName: ToolHandlerName | undefined = TOOL_HANDLER_MAP[tc.function.name];
|
||||||
|
if (handlerName) {
|
||||||
|
let args: Record<string, Object> = {};
|
||||||
|
try {
|
||||||
|
args = JSON.parse(tc.function.arguments);
|
||||||
|
}
|
||||||
|
catch (parseErr) {
|
||||||
|
const parseErrMsg = (parseErr as Error).message || JSON.stringify(parseErr);
|
||||||
|
defaultLogger.error('Failed to parse tool arguments: ' + parseErrMsg);
|
||||||
|
const badArgsResult: ToolCallResult = {
|
||||||
|
name: tc.function.name,
|
||||||
|
success: false,
|
||||||
|
message: '参数解析失败'
|
||||||
|
};
|
||||||
|
toolCalls.push(badArgsResult);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const result: ToolCallResult = await this.executeTool(handlerName, args);
|
||||||
|
toolCalls.push(result);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const unknownToolResult: ToolCallResult = {
|
||||||
|
name: tc.function.name,
|
||||||
|
success: false,
|
||||||
|
message: '未知工具'
|
||||||
|
};
|
||||||
|
toolCalls.push(unknownToolResult);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 有 tool_calls 时需要再次请求 AI,带上工具执行结果
|
||||||
|
const followUpSystemMsg: ApiRequestMessage = { role: 'system', content: systemPrompt };
|
||||||
|
const followUpUserMsg: ApiRequestMessage = { role: 'user', content: contextBlock + '\n\n---\n\n用户消息: ' + userInput };
|
||||||
|
const followUpAssistantMsg: ApiRequestMessage = {
|
||||||
|
role: 'assistant',
|
||||||
|
content: aiMessage.content || '(已执行记忆操作)',
|
||||||
|
};
|
||||||
|
const toolResultsMessages: ApiRequestMessage[] = [
|
||||||
|
followUpSystemMsg,
|
||||||
|
followUpUserMsg,
|
||||||
|
followUpAssistantMsg,
|
||||||
|
];
|
||||||
|
for (const tc of aiMessage.tool_calls) {
|
||||||
|
const callResult: ToolCallResult | undefined = toolCalls.find(r => r.name === tc.function.name);
|
||||||
|
const toolResultMsg: string = callResult ? callResult.message : '完成';
|
||||||
|
const toolResultMessage: ApiRequestMessage = {
|
||||||
|
role: 'tool',
|
||||||
|
content: `工具 ${tc.function.name} 执行结果: ${toolResultMsg}`
|
||||||
|
};
|
||||||
|
toolResultsMessages.push(toolResultMessage);
|
||||||
|
}
|
||||||
|
const finalResponse: ApiResponse = await this.callApi(toolResultsMessages, baseUrl, model, apiKey);
|
||||||
|
if (finalResponse.choices && finalResponse.choices.length > 0) {
|
||||||
|
const content: string = finalResponse.choices[0].message.content || '';
|
||||||
|
const finalResult: AgentResponse = { content, toolCalls };
|
||||||
|
return finalResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 普通回复(无 tool_calls)
|
||||||
|
const content: string = aiMessage.content || '';
|
||||||
|
const noToolResponse: AgentResponse = { content, toolCalls };
|
||||||
|
return noToolResponse;
|
||||||
|
}
|
||||||
|
const noResponse: AgentResponse = {
|
||||||
|
content: 'AI 无响应',
|
||||||
|
toolCalls
|
||||||
|
};
|
||||||
|
return noResponse;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 请求 DeepSeek API
|
||||||
|
*/
|
||||||
|
private async callApi(messages: ApiRequestMessage[], baseUrl: string, model: string, apiKey: string): Promise<ApiResponse> {
|
||||||
|
const httpRequest = http.createHttp();
|
||||||
|
try {
|
||||||
|
const resp = await httpRequest.request(baseUrl + '/chat/completions', {
|
||||||
|
method: http.RequestMethod.POST,
|
||||||
|
header: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': 'Bearer ' + apiKey
|
||||||
|
},
|
||||||
|
extraData: {
|
||||||
|
model: model,
|
||||||
|
messages: messages,
|
||||||
|
tools: TOOLS_DEFINITION,
|
||||||
|
tool_choice: 'auto'
|
||||||
|
},
|
||||||
|
expectDataType: http.HttpDataType.OBJECT,
|
||||||
|
readTimeout: 60000
|
||||||
|
});
|
||||||
|
if (resp.responseCode === 200) {
|
||||||
|
return resp.result as ApiResponse;
|
||||||
|
}
|
||||||
|
const errorMsg: string = `API 请求失败: HTTP ${resp.responseCode}`;
|
||||||
|
throw new Error(errorMsg);
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
httpRequest.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 执行工具调用
|
||||||
|
*/
|
||||||
|
private async executeTool(name: ToolHandlerName, args: Record<string, Object>): Promise<ToolCallResult> {
|
||||||
|
try {
|
||||||
|
switch (name) {
|
||||||
|
case 'memoryRecal': {
|
||||||
|
const recallArgs: MemoryRecallParams = {
|
||||||
|
queryIntent: args.queryIntent as string,
|
||||||
|
seedEntities: args.seedEntities as string[],
|
||||||
|
depth: (args.depth as number) ?? 2,
|
||||||
|
timeRange: args.timeRange as TimeRangeParams,
|
||||||
|
sessionFilter: args.sessionFilter as string
|
||||||
|
};
|
||||||
|
const recallResult = await this.memoryService.memoryRecall(recallArgs);
|
||||||
|
const result: ToolCallResult = {
|
||||||
|
name: 'memory_recall',
|
||||||
|
success: true,
|
||||||
|
message: `找到 ${recallResult.entities.length} 个实体, ${recallResult.relations.length} 条关系`
|
||||||
|
};
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
case 'memoryCommit': {
|
||||||
|
const commitParams: MemoryCommitParams = {
|
||||||
|
triplets: args.triplets as TripletInput[],
|
||||||
|
entityTypes: args.entityTypes as Record<string, string>,
|
||||||
|
sessionId: (args.sessionId as string) || this.currentSessionId,
|
||||||
|
turnId: (args.turnId as number) || this.turnCounter
|
||||||
|
};
|
||||||
|
const commitResult = await this.memoryService.memoryCommit(commitParams);
|
||||||
|
const result: ToolCallResult = {
|
||||||
|
name: 'memory_commit',
|
||||||
|
success: true,
|
||||||
|
message: `已写入 ${commitResult.committedCount} 条记忆`
|
||||||
|
};
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
case 'memoryPurge': {
|
||||||
|
const purgeArgs: MemoryPurgeParams = {
|
||||||
|
criteria: args.criteria as PurgeCriteriaParams,
|
||||||
|
mode: args.mode as 'soft' | 'hard' | 'supersede',
|
||||||
|
newRelation: args.newRelation as NewRelationParams
|
||||||
|
};
|
||||||
|
const purgeResult = await this.memoryService.memoryPurge(purgeArgs);
|
||||||
|
const result: ToolCallResult = {
|
||||||
|
name: 'memory_purge',
|
||||||
|
success: true,
|
||||||
|
message: purgeResult.message
|
||||||
|
};
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
case 'memoryIntrospect': {
|
||||||
|
const introspectResult = await this.memoryService.memoryIntrospect(args.sessionId as string);
|
||||||
|
const result: ToolCallResult = {
|
||||||
|
name: 'memory_introspect',
|
||||||
|
success: true,
|
||||||
|
message: `实体: ${introspectResult.entityCount}, 关系: ${introspectResult.relationCount}, 热点: ${introspectResult.hotNodes.length}`
|
||||||
|
};
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
case 'memoryArchive': {
|
||||||
|
const archiveResult = await this.memoryService.archive(args.days as number);
|
||||||
|
const result: ToolCallResult = {
|
||||||
|
name: 'memory_archive',
|
||||||
|
success: true,
|
||||||
|
message: `已归档 ${archiveResult.archived} 条关系`
|
||||||
|
};
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
case 'memoryCleanup': {
|
||||||
|
const cleanupResult = await this.memoryService.cleanup((args.dryRun as boolean) !== false);
|
||||||
|
const result: ToolCallResult = {
|
||||||
|
name: 'memory_cleanup',
|
||||||
|
success: true,
|
||||||
|
message: `清理: ${cleanupResult.cleaned} 条关系, ${cleanupResult.deletedOrphans} 个孤儿节点` + (cleanupResult.dryRun ? ' (预览模式)' : '')
|
||||||
|
};
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
case 'memoryQueryArchived': {
|
||||||
|
const qaResult = await this.memoryService.queryArchived(args.days as number, args.keyword as string);
|
||||||
|
const result: ToolCallResult = {
|
||||||
|
name: 'memory_query_archived',
|
||||||
|
success: true,
|
||||||
|
message: `找到 ${qaResult.length} 条归档记录`
|
||||||
|
};
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
case 'contextRewrite': {
|
||||||
|
const summary = args.summary as string;
|
||||||
|
const result: ToolCallResult = {
|
||||||
|
name: 'context_rewrite',
|
||||||
|
success: summary.includes('[工具调用总结'),
|
||||||
|
message: summary.includes('[工具调用总结') ? '上下文已压缩' : '格式错误:必须包含[工具调用总结]标记'
|
||||||
|
};
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
case 'personaUpdate': {
|
||||||
|
const personaParams: PersonaUpdateParams = {
|
||||||
|
tone: args.tone as string,
|
||||||
|
style: args.style as string,
|
||||||
|
personality: args.personality as string,
|
||||||
|
catchphrase: args.catchphrase as string,
|
||||||
|
background: args.background as string
|
||||||
|
};
|
||||||
|
const puResult = await this.memoryService.personaUpdate(personaParams);
|
||||||
|
const result: ToolCallResult = {
|
||||||
|
name: 'persona_update',
|
||||||
|
success: puResult.success,
|
||||||
|
message: puResult.message
|
||||||
|
};
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
case 'personaRemove': {
|
||||||
|
const prResult = await this.memoryService.personaRemove(args.attribute as string);
|
||||||
|
const result: ToolCallResult = {
|
||||||
|
name: 'persona_remove',
|
||||||
|
success: prResult.success,
|
||||||
|
message: prResult.message
|
||||||
|
};
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
case 'personaClear': {
|
||||||
|
const pcResult = await this.memoryService.personaClear();
|
||||||
|
const result: ToolCallResult = {
|
||||||
|
name: 'persona_clear',
|
||||||
|
success: pcResult.success,
|
||||||
|
message: pcResult.message
|
||||||
|
};
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
case 'taskCreate': {
|
||||||
|
const createParams: TaskCreateParams = {
|
||||||
|
taskId: args.taskId as string,
|
||||||
|
description: args.description as string,
|
||||||
|
infoNodes: args.infoNodes as string[]
|
||||||
|
};
|
||||||
|
const tcResult = await this.memoryService.taskCreate(createParams);
|
||||||
|
const result: ToolCallResult = {
|
||||||
|
name: 'task_create',
|
||||||
|
success: tcResult.success,
|
||||||
|
message: tcResult.message
|
||||||
|
};
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
case 'taskSetState': {
|
||||||
|
const setStateParams: TaskSetStateParams = {
|
||||||
|
taskId: args.taskId as string,
|
||||||
|
state: args.state as ToolStateArg
|
||||||
|
};
|
||||||
|
const tsResult = await this.memoryService.taskSetState(setStateParams);
|
||||||
|
const result: ToolCallResult = {
|
||||||
|
name: 'task_set_state',
|
||||||
|
success: tsResult.success,
|
||||||
|
message: tsResult.message
|
||||||
|
};
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
case 'taskDelete': {
|
||||||
|
const deleteParams: TaskDeleteParams = {
|
||||||
|
taskId: args.taskId as string,
|
||||||
|
deleteInfoNodes: (args.deleteInfoNodes as boolean) !== false
|
||||||
|
};
|
||||||
|
const tdResult = await this.memoryService.taskDelete(deleteParams);
|
||||||
|
const result: ToolCallResult = {
|
||||||
|
name: 'task_delete',
|
||||||
|
success: tdResult.success,
|
||||||
|
message: tdResult.message
|
||||||
|
};
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
case 'taskLinkInfo': {
|
||||||
|
const linkInfoParams: TaskLinkInfoParams = {
|
||||||
|
taskId: args.taskId as string,
|
||||||
|
infoNodeNames: args.infoNodeNames as string[]
|
||||||
|
};
|
||||||
|
const tliResult = await this.memoryService.taskLinkInfo(linkInfoParams);
|
||||||
|
const result: ToolCallResult = {
|
||||||
|
name: 'task_link_info',
|
||||||
|
success: tliResult.success,
|
||||||
|
message: tliResult.message
|
||||||
|
};
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
case 'taskArchive': {
|
||||||
|
const archiveParams: TaskArchiveParams = {
|
||||||
|
taskId: args.taskId as string,
|
||||||
|
summary: args.summary as string
|
||||||
|
};
|
||||||
|
const taResult = await this.memoryService.taskArchive(archiveParams);
|
||||||
|
const result: ToolCallResult = {
|
||||||
|
name: 'task_archive',
|
||||||
|
success: taResult.success,
|
||||||
|
message: taResult.message
|
||||||
|
};
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
case 'taskQuery': {
|
||||||
|
const tqResult = await this.memoryService.taskQuery({
|
||||||
|
limit: args.limit as number,
|
||||||
|
stateFilter: args.stateFilter as string
|
||||||
|
});
|
||||||
|
const result: ToolCallResult = {
|
||||||
|
name: 'task_query',
|
||||||
|
success: true,
|
||||||
|
message: `找到 ${tqResult.tasks.length} 个任务`
|
||||||
|
};
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
const defaultResult: ToolCallResult = {
|
||||||
|
name: name as string,
|
||||||
|
success: false,
|
||||||
|
message: '未实现的工具'
|
||||||
|
};
|
||||||
|
return defaultResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
const errorMessage: string = (e as Error).message || '';
|
||||||
|
const errorResult: ToolCallResult = {
|
||||||
|
name: name as string,
|
||||||
|
success: false,
|
||||||
|
message: `执行失败: ${errorMessage}`
|
||||||
|
};
|
||||||
|
return errorResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 格式化人设数据为文本
|
||||||
|
*/
|
||||||
|
private formatPersona(persona: Record<string, string>): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
const keys: string[] = Object.keys(persona);
|
||||||
|
for (let i = 0; i < keys.length; i++) {
|
||||||
|
const key: string = keys[i];
|
||||||
|
const val: string = persona[key];
|
||||||
|
parts.push(`${key}: ${val}`);
|
||||||
|
}
|
||||||
|
return parts.length > 0 ? parts.join(';') : '';
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 构建上下文注入块
|
||||||
|
*/
|
||||||
|
private buildContextBlock(personaResult: PersonaQueryResult, taskResult: MemoryRecallResult): string {
|
||||||
|
const blocks: string[] = [];
|
||||||
|
if (personaResult.found) {
|
||||||
|
blocks.push(`【当前人设】\n${this.formatPersona(personaResult.persona)}`);
|
||||||
|
}
|
||||||
|
if (taskResult.entities.length > 0) {
|
||||||
|
const entitySample: EntityInfo[] = taskResult.entities.slice(0, 5);
|
||||||
|
const entitiesStr: string = JSON.stringify(entitySample);
|
||||||
|
blocks.push(`【工作记忆】\n${taskResult.message}\n${entitiesStr}`);
|
||||||
|
}
|
||||||
|
return blocks.length > 0 ? blocks.join('\n\n') : '【新对话】';
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
@ -0,0 +1,935 @@
|
|||||||
|
import type { GraphDatabase, RelationQueryResult, TimeRangeParams } from '../model/GraphDatabase';
|
||||||
|
import { defaultLogger } from "@normalized:N&&&@ohos/common/src/main/ets/util/Logger&1.0.0";
|
||||||
|
// ========= 接口定义 =========
|
||||||
|
export interface SnapshotData {
|
||||||
|
entities: EntityInfo[];
|
||||||
|
relations: RelationInfo[];
|
||||||
|
}
|
||||||
|
export interface GraphDataNode {
|
||||||
|
id: number;
|
||||||
|
label: string;
|
||||||
|
type: string;
|
||||||
|
mentions: number;
|
||||||
|
depth?: number;
|
||||||
|
}
|
||||||
|
export interface GraphDataEdge {
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
label: string;
|
||||||
|
weight: number;
|
||||||
|
depth?: number;
|
||||||
|
sessionId?: string;
|
||||||
|
turnId?: number;
|
||||||
|
}
|
||||||
|
export interface SnapshotEntity {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
mention_count: number;
|
||||||
|
depth?: number;
|
||||||
|
}
|
||||||
|
export interface SnapshotRelation {
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
type: string;
|
||||||
|
confidence: number;
|
||||||
|
session_id?: string;
|
||||||
|
turn_id?: number;
|
||||||
|
depth?: number;
|
||||||
|
}
|
||||||
|
export interface GraphOutput {
|
||||||
|
nodes: GraphDataNode[];
|
||||||
|
edges: GraphDataEdge[];
|
||||||
|
}
|
||||||
|
export interface SnapshotOutput {
|
||||||
|
entities: SnapshotEntity[];
|
||||||
|
relations: SnapshotRelation[];
|
||||||
|
}
|
||||||
|
export interface EntityInfo {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
mentionCount: number;
|
||||||
|
depth?: number;
|
||||||
|
}
|
||||||
|
export interface RelationInfo {
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
type: string;
|
||||||
|
confidence: number;
|
||||||
|
sessionId?: string;
|
||||||
|
turnId?: number;
|
||||||
|
depth?: number;
|
||||||
|
}
|
||||||
|
export interface TripletInput {
|
||||||
|
subject: string;
|
||||||
|
relation: string;
|
||||||
|
object: string;
|
||||||
|
confidence?: number;
|
||||||
|
}
|
||||||
|
export interface CleanupResult {
|
||||||
|
cleaned: number;
|
||||||
|
deletedRelations?: number;
|
||||||
|
deletedOrphans?: number;
|
||||||
|
dryRun?: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
export interface SearchResult {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
mentions: number;
|
||||||
|
}
|
||||||
|
export interface TaskInfo {
|
||||||
|
taskId: string;
|
||||||
|
description: string;
|
||||||
|
state: string;
|
||||||
|
infoCount: number;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
export interface NodeData {
|
||||||
|
id: number;
|
||||||
|
label: string;
|
||||||
|
type: string;
|
||||||
|
mentions: number;
|
||||||
|
}
|
||||||
|
export interface EdgeData {
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
label: string;
|
||||||
|
weight: number;
|
||||||
|
}
|
||||||
|
export interface GraphData {
|
||||||
|
nodes: NodeData[];
|
||||||
|
edges: EdgeData[];
|
||||||
|
}
|
||||||
|
// 内部接口 — 用于替换内联对象类型声明
|
||||||
|
export interface PurgeCriteriaParams {
|
||||||
|
subjectContains?: string;
|
||||||
|
relationType?: string;
|
||||||
|
targetContains?: string;
|
||||||
|
sessionId?: string;
|
||||||
|
sourceType?: string;
|
||||||
|
targetType?: string;
|
||||||
|
sourceHasStatus?: string;
|
||||||
|
}
|
||||||
|
export interface NewRelationParams {
|
||||||
|
relation: string;
|
||||||
|
target: string;
|
||||||
|
}
|
||||||
|
interface TripletData {
|
||||||
|
subject: string;
|
||||||
|
relation: string;
|
||||||
|
object: string;
|
||||||
|
}
|
||||||
|
interface CriteriaData {
|
||||||
|
subject?: string;
|
||||||
|
target?: string;
|
||||||
|
relation?: string;
|
||||||
|
sessionId?: string;
|
||||||
|
}
|
||||||
|
export interface MemoryRecallResult {
|
||||||
|
entities: EntityInfo[];
|
||||||
|
relations: RelationInfo[];
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
export interface MemoryRecallParams {
|
||||||
|
queryIntent: string;
|
||||||
|
seedEntities?: string[];
|
||||||
|
depth?: number;
|
||||||
|
timeRange?: TimeRangeParams;
|
||||||
|
sessionFilter?: string;
|
||||||
|
}
|
||||||
|
export interface MemoryCommitParams {
|
||||||
|
triplets: TripletInput[];
|
||||||
|
entityTypes?: Record<string, string>;
|
||||||
|
sessionId?: string;
|
||||||
|
turnId?: number;
|
||||||
|
}
|
||||||
|
interface MemoryCommitResult {
|
||||||
|
committedCount: number;
|
||||||
|
details: string[];
|
||||||
|
}
|
||||||
|
export interface MemoryPurgeParams {
|
||||||
|
criteria: PurgeCriteriaParams;
|
||||||
|
mode: 'soft' | 'hard' | 'supersede';
|
||||||
|
newRelation?: NewRelationParams;
|
||||||
|
}
|
||||||
|
interface MemoryPurgeResult {
|
||||||
|
deletedCount: number;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
interface HotNodeInfo {
|
||||||
|
name: string;
|
||||||
|
mentionCount: number;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
interface MemoryIntrospectResult {
|
||||||
|
entityCount: number;
|
||||||
|
relationCount: number;
|
||||||
|
hotNodes: HotNodeInfo[];
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
interface ArchiveResult {
|
||||||
|
archived: number;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
interface PersonaResult {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
export interface PersonaQueryResult {
|
||||||
|
persona: Record<string, string>;
|
||||||
|
found: boolean;
|
||||||
|
}
|
||||||
|
interface TaskCreateResult {
|
||||||
|
success: boolean;
|
||||||
|
taskId: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
interface TaskActionResult {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
export interface TaskQueryResult {
|
||||||
|
tasks: TaskInfo[];
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
export interface PersonaUpdateParams {
|
||||||
|
tone?: string;
|
||||||
|
style?: string;
|
||||||
|
personality?: string;
|
||||||
|
catchphrase?: string;
|
||||||
|
background?: string;
|
||||||
|
}
|
||||||
|
export interface TaskCreateParams {
|
||||||
|
taskId: string;
|
||||||
|
description: string;
|
||||||
|
infoNodes?: string[];
|
||||||
|
}
|
||||||
|
export interface TaskSetStateParams {
|
||||||
|
taskId: string;
|
||||||
|
state: '进行中' | '已完成' | '已暂停' | '已取消' | '已归档';
|
||||||
|
}
|
||||||
|
export interface TaskDeleteParams {
|
||||||
|
taskId: string;
|
||||||
|
deleteInfoNodes?: boolean;
|
||||||
|
}
|
||||||
|
export interface TaskLinkInfoParams {
|
||||||
|
taskId: string;
|
||||||
|
infoNodeNames: string[];
|
||||||
|
}
|
||||||
|
export interface TaskArchiveParams {
|
||||||
|
taskId: string;
|
||||||
|
summary?: string;
|
||||||
|
}
|
||||||
|
export interface TaskQueryParams {
|
||||||
|
limit?: number;
|
||||||
|
stateFilter?: string;
|
||||||
|
}
|
||||||
|
// 节点详情连接项接口
|
||||||
|
export interface ConnectionItem {
|
||||||
|
type: string;
|
||||||
|
target_name: string;
|
||||||
|
}
|
||||||
|
// 节点详情返回接口
|
||||||
|
export interface NodeDetailInfo {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
mention_count: number;
|
||||||
|
connection_count: number;
|
||||||
|
connections: ConnectionItem[];
|
||||||
|
}
|
||||||
|
// 图数据统计接口
|
||||||
|
export interface GraphStats {
|
||||||
|
maxDegree: number;
|
||||||
|
avgDegree: number;
|
||||||
|
}
|
||||||
|
// 人设节点的固定名称
|
||||||
|
const PERSONA_NODE_NAME: string = 'trulymem_persona_identity';
|
||||||
|
const PERSONA_NODE_TYPE: string = 'PersonaNode';
|
||||||
|
// ========= GraphMemoryService =========
|
||||||
|
export class GraphMemoryService {
|
||||||
|
private db: GraphDatabase;
|
||||||
|
constructor(db: GraphDatabase) {
|
||||||
|
this.db = db;
|
||||||
|
}
|
||||||
|
// ========= 记忆操作 =========
|
||||||
|
/**
|
||||||
|
* 记忆召回 — 关键词搜索 + BFS 扩展
|
||||||
|
* 对应 tools.memory_recall
|
||||||
|
*/
|
||||||
|
async memoryRecall(params: MemoryRecallParams): Promise<MemoryRecallResult> {
|
||||||
|
const depth: number = params.depth ?? 2;
|
||||||
|
const result = await this.db.recall(params.queryIntent, params.seedEntities, depth, undefined, params.sessionFilter);
|
||||||
|
const entities: EntityInfo[] = result.entities.map(e => {
|
||||||
|
const entityItem: EntityInfo = {
|
||||||
|
name: e.name,
|
||||||
|
type: e.type,
|
||||||
|
mentionCount: e.mention_count,
|
||||||
|
depth: e.depth ?? 0
|
||||||
|
};
|
||||||
|
return entityItem;
|
||||||
|
});
|
||||||
|
const relations: RelationInfo[] = result.relations.map(r => {
|
||||||
|
const relationItem: RelationInfo = {
|
||||||
|
source: r.source,
|
||||||
|
target: r.target,
|
||||||
|
type: r.type,
|
||||||
|
confidence: r.confidence,
|
||||||
|
sessionId: r.session_id,
|
||||||
|
turnId: r.turn_id,
|
||||||
|
depth: r.depth ?? 0
|
||||||
|
};
|
||||||
|
return relationItem;
|
||||||
|
});
|
||||||
|
return { entities, relations, message: result.message };
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 记忆写入 — 批量三元组
|
||||||
|
* 对应 tools.memory_commit
|
||||||
|
*/
|
||||||
|
async memoryCommit(params: MemoryCommitParams): Promise<MemoryCommitResult> {
|
||||||
|
const details: string[] = [];
|
||||||
|
for (const t of params.triplets) {
|
||||||
|
const subject = t.subject.trim();
|
||||||
|
const relation = t.relation.trim();
|
||||||
|
const object = t.object.trim();
|
||||||
|
if (!subject || !relation || !object) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const triplets: TripletData[] = [{ subject, relation, object }];
|
||||||
|
await this.db.commit(triplets, params.entityTypes, params.sessionId, params.turnId);
|
||||||
|
details.push(`${subject} -[${relation}]-> ${object}`);
|
||||||
|
}
|
||||||
|
const result: MemoryCommitResult = {
|
||||||
|
committedCount: details.length,
|
||||||
|
details
|
||||||
|
};
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 记忆删除
|
||||||
|
* 对应 tools.memory_purge
|
||||||
|
*/
|
||||||
|
async memoryPurge(params: MemoryPurgeParams): Promise<MemoryPurgeResult> {
|
||||||
|
// soft 模式:调用 db.purge 逻辑删除
|
||||||
|
if (params.mode === 'supersede' && params.newRelation) {
|
||||||
|
// supersede: 先软删除旧关系,再新建
|
||||||
|
const softSubject = params.criteria.subjectContains;
|
||||||
|
const softRelation = params.criteria.relationType;
|
||||||
|
const softTarget = params.criteria.targetContains;
|
||||||
|
await this.db.purge({
|
||||||
|
subject: softSubject,
|
||||||
|
relation: softRelation,
|
||||||
|
target: softTarget
|
||||||
|
}, 'soft');
|
||||||
|
// 新建替代关系
|
||||||
|
const newSubj = softSubject || '';
|
||||||
|
if (newSubj) {
|
||||||
|
await this.db.commit([{
|
||||||
|
subject: newSubj,
|
||||||
|
relation: params.newRelation.relation,
|
||||||
|
object: params.newRelation.target
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
const supersedeResult: MemoryPurgeResult = {
|
||||||
|
deletedCount: 1,
|
||||||
|
message: '已用 supersede 模式替代记忆'
|
||||||
|
};
|
||||||
|
return supersedeResult;
|
||||||
|
}
|
||||||
|
const subjContains = params.criteria.subjectContains;
|
||||||
|
const relType = params.criteria.relationType;
|
||||||
|
const tgtContains = params.criteria.targetContains;
|
||||||
|
const sessId = params.criteria.sessionId;
|
||||||
|
await this.db.purge({
|
||||||
|
subject: subjContains,
|
||||||
|
relation: relType,
|
||||||
|
target: tgtContains,
|
||||||
|
sessionId: sessId,
|
||||||
|
subjectContains: params.criteria.subjectContains,
|
||||||
|
targetContains: params.criteria.targetContains,
|
||||||
|
sourceType: params.criteria.sourceType,
|
||||||
|
targetType: params.criteria.targetType,
|
||||||
|
sourceHasStatus: params.criteria.sourceHasStatus
|
||||||
|
}, params.mode === 'hard' ? 'hard' : 'soft');
|
||||||
|
const purgeResult: MemoryPurgeResult = {
|
||||||
|
deletedCount: subjContains || tgtContains || relType || sessId ? 1 : 0,
|
||||||
|
message: `已${params.mode === 'hard' ? '物理删除' : '软删除'}匹配的记忆`
|
||||||
|
};
|
||||||
|
return purgeResult;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 记忆状态查询
|
||||||
|
*/
|
||||||
|
async memoryIntrospect(sessionId?: string): Promise<MemoryIntrospectResult> {
|
||||||
|
const stats = await this.db.introspect();
|
||||||
|
const hotNodes: HotNodeInfo[] = [];
|
||||||
|
// 从所有节点中获取前10个高频节点
|
||||||
|
const searchAll = await this.db.search('');
|
||||||
|
const sorted = searchAll.sort((a, b) => b.mentions - a.mentions).slice(0, 10);
|
||||||
|
for (const n of sorted) {
|
||||||
|
const nodeInfo: HotNodeInfo = {
|
||||||
|
name: n.name,
|
||||||
|
mentionCount: n.mentions,
|
||||||
|
type: n.type
|
||||||
|
};
|
||||||
|
hotNodes.push(nodeInfo);
|
||||||
|
}
|
||||||
|
const result: MemoryIntrospectResult = {
|
||||||
|
entityCount: stats.entity_count,
|
||||||
|
relationCount: stats.relation_count,
|
||||||
|
hotNodes,
|
||||||
|
message: stats.message
|
||||||
|
};
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 关键词搜索节点
|
||||||
|
*/
|
||||||
|
async search(keyword: string): Promise<SearchResult[]> {
|
||||||
|
return await this.db.search(keyword);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 归档旧记忆
|
||||||
|
*/
|
||||||
|
async archive(days: number): Promise<ArchiveResult> {
|
||||||
|
return await this.db.archive(days);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 清理已删除的记忆
|
||||||
|
*/
|
||||||
|
async cleanup(dryRun: boolean): Promise<CleanupResult> {
|
||||||
|
const result = await this.db.cleanup(dryRun);
|
||||||
|
const cleanupResult: CleanupResult = {
|
||||||
|
cleaned: result.cleaned,
|
||||||
|
deletedRelations: result.deleted_relations,
|
||||||
|
deletedOrphans: result.deleted_orphans,
|
||||||
|
dryRun: result.dry_run,
|
||||||
|
message: result.message || ''
|
||||||
|
};
|
||||||
|
return cleanupResult;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 记忆图谱 — 在指定时间范围内查询关系
|
||||||
|
* 对应 tools.memory_graph
|
||||||
|
*/
|
||||||
|
async memoryGraph(timeRange: TimeRangeParams, sessionFilter?: string): Promise<GraphOutput> {
|
||||||
|
const dbResult = await this.db.graph(timeRange, sessionFilter);
|
||||||
|
const nodeCount: number = dbResult.nodes.length;
|
||||||
|
const edgeCount: number = dbResult.edges.length;
|
||||||
|
const nodeList: GraphDataNode[] = [];
|
||||||
|
const edgeList: GraphDataEdge[] = [];
|
||||||
|
let idx: number = 0;
|
||||||
|
while (idx < nodeCount) {
|
||||||
|
const n = dbResult.nodes[idx];
|
||||||
|
const id1: number = n.id;
|
||||||
|
const label1: string = n.label;
|
||||||
|
const type1: string = n.type;
|
||||||
|
const mentions1: number = n.mentions;
|
||||||
|
const depth1: number | undefined = n.depth;
|
||||||
|
const graphNode: GraphDataNode = {
|
||||||
|
id: id1,
|
||||||
|
label: label1,
|
||||||
|
type: type1,
|
||||||
|
mentions: mentions1,
|
||||||
|
depth: depth1
|
||||||
|
};
|
||||||
|
nodeList.push(graphNode);
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
idx = 0;
|
||||||
|
while (idx < edgeCount) {
|
||||||
|
const e = dbResult.edges[idx];
|
||||||
|
const from1: number = e.from;
|
||||||
|
const to1: number = e.to;
|
||||||
|
const label1: string = e.label;
|
||||||
|
const weight1: number = e.weight;
|
||||||
|
const depth1: number | undefined = e.depth;
|
||||||
|
const sessionId1: string | undefined = e.sessionId;
|
||||||
|
const turnId1: number | undefined = e.turnId;
|
||||||
|
const graphEdge: GraphDataEdge = {
|
||||||
|
from: from1,
|
||||||
|
to: to1,
|
||||||
|
label: label1,
|
||||||
|
weight: weight1,
|
||||||
|
depth: depth1,
|
||||||
|
sessionId: sessionId1,
|
||||||
|
turnId: turnId1
|
||||||
|
};
|
||||||
|
edgeList.push(graphEdge);
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
const out: GraphOutput = {
|
||||||
|
nodes: nodeList,
|
||||||
|
edges: edgeList
|
||||||
|
};
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 记忆快照 — 在指定时间范围内查询实体和关系
|
||||||
|
* 对应 tools.memory_snapshot
|
||||||
|
*/
|
||||||
|
async memorySnapshot(timeRange: TimeRangeParams, sessionFilter?: string): Promise<SnapshotOutput> {
|
||||||
|
const dbResult = await this.db.snapshot(timeRange, sessionFilter);
|
||||||
|
const entityCount: number = dbResult.entities.length;
|
||||||
|
const relationCount: number = dbResult.relations.length;
|
||||||
|
const entityList: SnapshotEntity[] = [];
|
||||||
|
const relationList: SnapshotRelation[] = [];
|
||||||
|
let idx: number = 0;
|
||||||
|
while (idx < entityCount) {
|
||||||
|
const e = dbResult.entities[idx];
|
||||||
|
const name1: string = e.name;
|
||||||
|
const type1: string = e.type;
|
||||||
|
const mentionCount1: number = e.mention_count;
|
||||||
|
const depth1: number | undefined = e.depth;
|
||||||
|
const entity: SnapshotEntity = {
|
||||||
|
name: name1,
|
||||||
|
type: type1,
|
||||||
|
mention_count: mentionCount1,
|
||||||
|
depth: depth1
|
||||||
|
};
|
||||||
|
entityList.push(entity);
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
idx = 0;
|
||||||
|
while (idx < relationCount) {
|
||||||
|
const r = dbResult.relations[idx];
|
||||||
|
const source1: string = r.source;
|
||||||
|
const target1: string = r.target;
|
||||||
|
const type1: string = r.type;
|
||||||
|
const confidence1: number = r.confidence;
|
||||||
|
const sessionId1: string | undefined = r.session_id;
|
||||||
|
const turnId1: number | undefined = r.turn_id;
|
||||||
|
const depth1: number | undefined = r.depth;
|
||||||
|
const relation: SnapshotRelation = {
|
||||||
|
source: source1,
|
||||||
|
target: target1,
|
||||||
|
type: type1,
|
||||||
|
confidence: confidence1,
|
||||||
|
session_id: sessionId1,
|
||||||
|
turn_id: turnId1,
|
||||||
|
depth: depth1
|
||||||
|
};
|
||||||
|
relationList.push(relation);
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
const out2: SnapshotOutput = {
|
||||||
|
entities: entityList,
|
||||||
|
relations: relationList
|
||||||
|
};
|
||||||
|
return out2;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 记忆清理 — 归档旧记忆并清理孤立节点
|
||||||
|
* 对应 tools.memory_cleanup
|
||||||
|
*/
|
||||||
|
async memoryCleanup(dryRun: boolean = false): Promise<CleanupResult> {
|
||||||
|
const result = await this.db.cleanup(dryRun);
|
||||||
|
const cleanupResult: CleanupResult = {
|
||||||
|
cleaned: result.cleaned,
|
||||||
|
deletedRelations: result.deleted_relations,
|
||||||
|
deletedOrphans: result.deleted_orphans,
|
||||||
|
dryRun: result.dry_run,
|
||||||
|
message: result.message || ''
|
||||||
|
};
|
||||||
|
return cleanupResult;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 记忆清理 — 删除指定条件的记忆
|
||||||
|
* 对应 tools.memory_purge
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* 查询已归档的记忆
|
||||||
|
* 对应 tools.memory_query_archived
|
||||||
|
*/
|
||||||
|
async queryArchived(days?: number, keyword?: string): Promise<RelationQueryResult[]> {
|
||||||
|
try {
|
||||||
|
const result = await this.db.queryArchived(days, keyword);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
defaultLogger.error('queryArchived error: ' + JSON.stringify(e));
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ========= 人设管理 =========
|
||||||
|
/**
|
||||||
|
* 更新人设
|
||||||
|
* 对应 tools.persona_update
|
||||||
|
* 使用 PersonaNode + HAS_PERSONA 关系存储属性
|
||||||
|
*/
|
||||||
|
async personaUpdate(params: PersonaUpdateParams): Promise<PersonaResult> {
|
||||||
|
try {
|
||||||
|
// 1. 确保 PersonaNode 存在
|
||||||
|
const personaTriplets: TripletData[] = [];
|
||||||
|
const entityTypes: Record<string, string> = {};
|
||||||
|
entityTypes[PERSONA_NODE_NAME] = PERSONA_NODE_TYPE;
|
||||||
|
// 2. 逐个属性写入(作为关系),不使用 as any
|
||||||
|
const toneVal = params.tone;
|
||||||
|
const styleVal = params.style;
|
||||||
|
const personalityVal = params.personality;
|
||||||
|
const catchphraseVal = params.catchphrase;
|
||||||
|
const backgroundVal = params.background;
|
||||||
|
if (toneVal) {
|
||||||
|
personaTriplets.push({
|
||||||
|
subject: PERSONA_NODE_NAME,
|
||||||
|
relation: 'HAS_PERSONA_TONE',
|
||||||
|
object: toneVal
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (styleVal) {
|
||||||
|
personaTriplets.push({
|
||||||
|
subject: PERSONA_NODE_NAME,
|
||||||
|
relation: 'HAS_PERSONA_STYLE',
|
||||||
|
object: styleVal
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (personalityVal) {
|
||||||
|
personaTriplets.push({
|
||||||
|
subject: PERSONA_NODE_NAME,
|
||||||
|
relation: 'HAS_PERSONA_PERSONALITY',
|
||||||
|
object: personalityVal
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (catchphraseVal) {
|
||||||
|
personaTriplets.push({
|
||||||
|
subject: PERSONA_NODE_NAME,
|
||||||
|
relation: 'HAS_PERSONA_CATCHPHRASE',
|
||||||
|
object: catchphraseVal
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (backgroundVal) {
|
||||||
|
personaTriplets.push({
|
||||||
|
subject: PERSONA_NODE_NAME,
|
||||||
|
relation: 'HAS_PERSONA_BACKGROUND',
|
||||||
|
object: backgroundVal
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (personaTriplets.length > 0) {
|
||||||
|
await this.db.commit(personaTriplets, entityTypes);
|
||||||
|
}
|
||||||
|
const successResult: PersonaResult = {
|
||||||
|
success: true,
|
||||||
|
message: `已更新 ${personaTriplets.length} 个人设属性`
|
||||||
|
};
|
||||||
|
return successResult;
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
const errorResult: PersonaResult = {
|
||||||
|
success: false,
|
||||||
|
message: `更新人设失败: ${(e as Error).message || ''}`
|
||||||
|
};
|
||||||
|
return errorResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 清除人设
|
||||||
|
* 对应 tools.persona_clear
|
||||||
|
*/
|
||||||
|
async personaClear(): Promise<PersonaResult> {
|
||||||
|
try {
|
||||||
|
await this.db.purge({ subject: PERSONA_NODE_NAME }, 'hard');
|
||||||
|
const result: PersonaResult = { success: true, message: '已清除所有人设信息' };
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
const errorResult: PersonaResult = {
|
||||||
|
success: false,
|
||||||
|
message: `清除人设失败: ${(e as Error).message || ''}`
|
||||||
|
};
|
||||||
|
return errorResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 删除单条人设属性
|
||||||
|
* 对应 tools.persona_remove
|
||||||
|
*/
|
||||||
|
async personaRemove(attribute: string): Promise<PersonaResult> {
|
||||||
|
try {
|
||||||
|
// 将 attribute 转为关系名格式
|
||||||
|
const relationName = 'HAS_PERSONA_' + attribute.toUpperCase();
|
||||||
|
await this.db.purge({ subject: PERSONA_NODE_NAME, relation: relationName }, 'hard');
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `已删除人设属性: ${attribute}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `删除人设属性失败: ${(e as Error).message || ''}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 查询当前人设
|
||||||
|
*/
|
||||||
|
async personaQuery(): Promise<PersonaQueryResult> {
|
||||||
|
const result = await this.db.recall(PERSONA_NODE_NAME, [PERSONA_NODE_NAME], 2);
|
||||||
|
const persona: Record<string, string> = {};
|
||||||
|
for (const rel of result.relations) {
|
||||||
|
if (rel.source === PERSONA_NODE_NAME && rel.type.startsWith('HAS_PERSONA_')) {
|
||||||
|
const key = rel.type.replace('HAS_PERSONA_', '').toLowerCase();
|
||||||
|
persona[key] = rel.target;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const queryResult: PersonaQueryResult = {
|
||||||
|
persona,
|
||||||
|
found: Object.keys(persona).length > 0
|
||||||
|
};
|
||||||
|
return queryResult;
|
||||||
|
}
|
||||||
|
// ========= 任务管理 =========
|
||||||
|
/**
|
||||||
|
* 创建任务节点
|
||||||
|
* 对应 tools.task_create
|
||||||
|
*/
|
||||||
|
async taskCreate(params: TaskCreateParams): Promise<TaskCreateResult> {
|
||||||
|
try {
|
||||||
|
const entityTypes: Record<string, string> = {};
|
||||||
|
entityTypes[params.taskId] = 'TaskNode';
|
||||||
|
const triplets: TripletData[] = [
|
||||||
|
{ subject: params.taskId, relation: 'description', object: params.description },
|
||||||
|
{ subject: params.taskId, relation: 'has_state', object: '进行中' }
|
||||||
|
];
|
||||||
|
if (params.infoNodes && params.infoNodes.length > 0) {
|
||||||
|
for (const infoNode of params.infoNodes) {
|
||||||
|
entityTypes[infoNode] = 'InfoNode';
|
||||||
|
triplets.push({ subject: params.taskId, relation: 'CONTAINS_INFO', object: infoNode });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await this.db.commit(triplets, entityTypes);
|
||||||
|
const result: TaskCreateResult = {
|
||||||
|
success: true,
|
||||||
|
taskId: params.taskId,
|
||||||
|
message: `已创建任务: ${params.taskId}`
|
||||||
|
};
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
const errorResult: TaskCreateResult = {
|
||||||
|
success: false,
|
||||||
|
taskId: params.taskId,
|
||||||
|
message: `创建任务失败: ${(e as Error).message || ''}`
|
||||||
|
};
|
||||||
|
return errorResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 设置任务状态
|
||||||
|
* 对应 tools.task_set_state
|
||||||
|
*/
|
||||||
|
async taskSetState(params: TaskSetStateParams): Promise<TaskActionResult> {
|
||||||
|
try {
|
||||||
|
// 先删旧的 has_state 关系,再新建
|
||||||
|
await this.db.purge({ subject: params.taskId, relation: 'has_state' }, 'soft');
|
||||||
|
await this.db.commit([{ subject: params.taskId, relation: 'has_state', object: params.state }]);
|
||||||
|
const result: TaskActionResult = {
|
||||||
|
success: true,
|
||||||
|
message: `任务 ${params.taskId} 状态已设为: ${params.state}`
|
||||||
|
};
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
const errorResult: TaskActionResult = {
|
||||||
|
success: false,
|
||||||
|
message: `设置任务状态失败: ${(e as Error).message || ''}`
|
||||||
|
};
|
||||||
|
return errorResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 删除任务
|
||||||
|
* 对应 tools.task_delete
|
||||||
|
*/
|
||||||
|
async taskDelete(params: TaskDeleteParams): Promise<TaskActionResult> {
|
||||||
|
try {
|
||||||
|
// 删除所有关联关系
|
||||||
|
await this.db.purge({ subject: params.taskId }, 'hard');
|
||||||
|
if (params.deleteInfoNodes !== false) {
|
||||||
|
await this.db.purge({ target: params.taskId }, 'hard');
|
||||||
|
}
|
||||||
|
const result: TaskActionResult = { success: true, message: `已删除任务: ${params.taskId}` };
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
const errorResult: TaskActionResult = {
|
||||||
|
success: false,
|
||||||
|
message: `删除任务失败: ${(e as Error).message || ''}`
|
||||||
|
};
|
||||||
|
return errorResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 关联信息节点到任务
|
||||||
|
* 对应 tools.task_link_info
|
||||||
|
*/
|
||||||
|
async taskLinkInfo(params: TaskLinkInfoParams): Promise<TaskActionResult> {
|
||||||
|
try {
|
||||||
|
const triplets: TripletData[] = [];
|
||||||
|
const entityTypes: Record<string, string> = {};
|
||||||
|
for (const nodeName of params.infoNodeNames) {
|
||||||
|
entityTypes[nodeName] = 'InfoNode';
|
||||||
|
triplets.push({ subject: params.taskId, relation: 'CONTAINS_INFO', object: nodeName });
|
||||||
|
}
|
||||||
|
await this.db.commit(triplets, entityTypes);
|
||||||
|
const result: TaskActionResult = { success: true, message: `已关联 ${triplets.length} 个信息节点` };
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
const errorResult: TaskActionResult = {
|
||||||
|
success: false,
|
||||||
|
message: `关联信息节点失败: ${(e as Error).message || ''}`
|
||||||
|
};
|
||||||
|
return errorResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 归档任务
|
||||||
|
* 对应 tools.task_archive
|
||||||
|
*/
|
||||||
|
async taskArchive(params: TaskArchiveParams): Promise<TaskActionResult> {
|
||||||
|
try {
|
||||||
|
// 归档任务:将状态设为已归档(archived),同时写入完成摘要
|
||||||
|
await this.taskSetState({ taskId: params.taskId, state: '已归档' });
|
||||||
|
if (params.summary) {
|
||||||
|
await this.db.commit([
|
||||||
|
{ subject: params.taskId, relation: 'archive_summary', object: params.summary }
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
const result: TaskActionResult = { success: true, message: `已归档任务: ${params.taskId}` };
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
const errorResult: TaskActionResult = {
|
||||||
|
success: false,
|
||||||
|
message: `归档任务失败: ${(e as Error).message || ''}`
|
||||||
|
};
|
||||||
|
return errorResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 查询任务列表
|
||||||
|
* 对应 tools.task_query
|
||||||
|
*/
|
||||||
|
async taskQuery(params?: TaskQueryParams): Promise<TaskQueryResult> {
|
||||||
|
const limit: number = params?.limit ?? 10;
|
||||||
|
const stateFilter: string | undefined = params?.stateFilter;
|
||||||
|
const queryResult: TaskQueryResult = {
|
||||||
|
tasks: await this.db.getRecentTasks(limit, stateFilter) as TaskInfo[],
|
||||||
|
message: `找到 ${limit} 个任务`
|
||||||
|
};
|
||||||
|
return queryResult;
|
||||||
|
}
|
||||||
|
// ========= 图数据 =========
|
||||||
|
/**
|
||||||
|
* 获取用于 WebView 的完整图数据
|
||||||
|
*/
|
||||||
|
async getGraphDataForView(): Promise<GraphData> {
|
||||||
|
// 用空关键词召回所有数据
|
||||||
|
const recallResult = await this.db.recall('', [], 3);
|
||||||
|
const nodes: NodeData[] = [];
|
||||||
|
const edges: EdgeData[] = [];
|
||||||
|
let nodeIdCounter = 1;
|
||||||
|
const nameToId: Record<string, number> = {};
|
||||||
|
for (const entity of recallResult.entities) {
|
||||||
|
const id = nodeIdCounter;
|
||||||
|
nodeIdCounter++;
|
||||||
|
nameToId[entity.name] = id;
|
||||||
|
const node: NodeData = {
|
||||||
|
id,
|
||||||
|
label: entity.name,
|
||||||
|
type: entity.type,
|
||||||
|
mentions: entity.mention_count
|
||||||
|
};
|
||||||
|
nodes.push(node);
|
||||||
|
}
|
||||||
|
for (const rel of recallResult.relations) {
|
||||||
|
const from = nameToId[rel.source];
|
||||||
|
const to = nameToId[rel.target];
|
||||||
|
if (from !== undefined && to !== undefined) {
|
||||||
|
const edge: EdgeData = {
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
label: rel.type,
|
||||||
|
weight: rel.confidence
|
||||||
|
};
|
||||||
|
edges.push(edge);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const graphData: GraphData = { nodes, edges };
|
||||||
|
return graphData;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 查询节点的完整信息 — 自身属性 + 所有相连关系
|
||||||
|
*/
|
||||||
|
async getNodeDetail(nodeName: string): Promise<NodeDetailInfo | null> {
|
||||||
|
try {
|
||||||
|
const recallResult = await this.db.recall(nodeName, [nodeName], 1);
|
||||||
|
if (recallResult.entities.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const entity = recallResult.entities[0];
|
||||||
|
const connections: ConnectionItem[] = [];
|
||||||
|
let connectionCount = 0;
|
||||||
|
for (const rel of recallResult.relations) {
|
||||||
|
if (rel.source === nodeName) {
|
||||||
|
const conn: ConnectionItem = {
|
||||||
|
type: rel.type,
|
||||||
|
target_name: rel.target
|
||||||
|
};
|
||||||
|
connections.push(conn);
|
||||||
|
connectionCount++;
|
||||||
|
}
|
||||||
|
else if (rel.target === nodeName) {
|
||||||
|
const conn: ConnectionItem = {
|
||||||
|
type: rel.type + ' (反向)',
|
||||||
|
target_name: rel.source
|
||||||
|
};
|
||||||
|
connections.push(conn);
|
||||||
|
connectionCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const detail: NodeDetailInfo = {
|
||||||
|
name: entity.name,
|
||||||
|
type: entity.type,
|
||||||
|
mention_count: entity.mention_count,
|
||||||
|
connection_count: connectionCount,
|
||||||
|
connections
|
||||||
|
};
|
||||||
|
return detail;
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
defaultLogger.error('getNodeDetail error: ' + JSON.stringify(err));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 返回带连接度数的图数据(每个节点增加 degree 字段)
|
||||||
|
*/
|
||||||
|
async getJoinedData(): Promise<GraphData> {
|
||||||
|
const graphData = await this.getGraphDataForView();
|
||||||
|
// 计算每个节点的连接度数
|
||||||
|
const degreeMap: Record<number, number> = {};
|
||||||
|
for (const edge of graphData.edges) {
|
||||||
|
degreeMap[edge.from] = (degreeMap[edge.from] || 0) + 1;
|
||||||
|
degreeMap[edge.to] = (degreeMap[edge.to] || 0) + 1;
|
||||||
|
}
|
||||||
|
// 手动为节点附加 degree(ArkTS 不支持展开运算符)
|
||||||
|
const nodesWithDegree: NodeData[] = [];
|
||||||
|
for (let i = 0; i < graphData.nodes.length; i++) {
|
||||||
|
const orig = graphData.nodes[i];
|
||||||
|
const copy: NodeData = {
|
||||||
|
id: orig.id,
|
||||||
|
label: orig.label,
|
||||||
|
type: orig.type,
|
||||||
|
mentions: orig.mentions
|
||||||
|
};
|
||||||
|
nodesWithDegree.push(copy);
|
||||||
|
}
|
||||||
|
const graphResult: GraphData = {
|
||||||
|
nodes: nodesWithDegree,
|
||||||
|
edges: graphData.edges
|
||||||
|
};
|
||||||
|
return graphResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
@ -0,0 +1,43 @@
|
|||||||
|
export enum WidthBreakpoint {
|
||||||
|
WIDTH_XS = "xs",
|
||||||
|
WIDTH_SM = "sm",
|
||||||
|
WIDTH_MD = "md",
|
||||||
|
WIDTH_LG = "lg",
|
||||||
|
WIDTH_XL = "xl"
|
||||||
|
}
|
||||||
|
export interface BreakpointTypes<T> {
|
||||||
|
xs?: T;
|
||||||
|
sm: T;
|
||||||
|
md: T;
|
||||||
|
lg: T;
|
||||||
|
xl?: T;
|
||||||
|
}
|
||||||
|
export class BreakpointType<T> {
|
||||||
|
private xs: T;
|
||||||
|
private sm: T;
|
||||||
|
private md: T;
|
||||||
|
private lg: T;
|
||||||
|
private xl: T;
|
||||||
|
public constructor(param: BreakpointTypes<T>) {
|
||||||
|
this.xs = param.xs ?? param.sm;
|
||||||
|
this.sm = param.sm;
|
||||||
|
this.md = param.md;
|
||||||
|
this.lg = param.lg;
|
||||||
|
this.xl = param.xl ?? param.lg;
|
||||||
|
}
|
||||||
|
public getValue(currentBreakpoint: WidthBreakpoint): T {
|
||||||
|
if (currentBreakpoint === WidthBreakpoint.WIDTH_XS) {
|
||||||
|
return this.xs;
|
||||||
|
}
|
||||||
|
if (currentBreakpoint === WidthBreakpoint.WIDTH_SM) {
|
||||||
|
return this.sm;
|
||||||
|
}
|
||||||
|
if (currentBreakpoint === WidthBreakpoint.WIDTH_MD) {
|
||||||
|
return this.md;
|
||||||
|
}
|
||||||
|
if (currentBreakpoint === WidthBreakpoint.WIDTH_XL) {
|
||||||
|
return this.xl;
|
||||||
|
}
|
||||||
|
return this.lg;
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
@ -0,0 +1,24 @@
|
|||||||
|
import hilog from "@ohos:hilog";
|
||||||
|
class Logger {
|
||||||
|
private domain: number;
|
||||||
|
private prefix: string;
|
||||||
|
private format: string = "%{public}s, %{public}s";
|
||||||
|
public constructor(prefix: string) {
|
||||||
|
this.prefix = prefix;
|
||||||
|
this.domain = 0xFF00;
|
||||||
|
}
|
||||||
|
public debug(...args: Object[]): void {
|
||||||
|
hilog.debug(this.domain, this.prefix, this.format, args);
|
||||||
|
}
|
||||||
|
public info(...args: Object[]): void {
|
||||||
|
hilog.info(this.domain, this.prefix, this.format, args);
|
||||||
|
}
|
||||||
|
public warn(...args: Object[]): void {
|
||||||
|
hilog.warn(this.domain, this.prefix, this.format, args);
|
||||||
|
}
|
||||||
|
public error(...args: Object[]): void {
|
||||||
|
hilog.error(this.domain, this.prefix, this.format, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export const defaultLogger = new Logger("[TrulyMEM]");
|
||||||
|
export default defaultLogger;
|
||||||
Binary file not shown.
@ -0,0 +1,42 @@
|
|||||||
|
import { WidthBreakpoint } from "@normalized:N&&&@ohos/common/src/main/ets/util/BreakpointSystem&1.0.0";
|
||||||
|
export interface VMEvent {
|
||||||
|
}
|
||||||
|
export class BaseViewModel {
|
||||||
|
protected isAttached: boolean = false;
|
||||||
|
protected isDisposed: boolean = false;
|
||||||
|
protected currentBreakpoint: WidthBreakpoint = WidthBreakpoint.WIDTH_MD;
|
||||||
|
attach(): void {
|
||||||
|
if (this.isAttached) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.isAttached = true;
|
||||||
|
this.onAttach();
|
||||||
|
}
|
||||||
|
detach(): void {
|
||||||
|
if (!this.isAttached) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.isAttached = false;
|
||||||
|
this.onDetach();
|
||||||
|
}
|
||||||
|
dispose(): void {
|
||||||
|
if (this.isDisposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.isDisposed = true;
|
||||||
|
this.detach();
|
||||||
|
this.onDispose();
|
||||||
|
}
|
||||||
|
protected onAttach(): void {
|
||||||
|
}
|
||||||
|
protected onDetach(): void {
|
||||||
|
}
|
||||||
|
protected onDispose(): void {
|
||||||
|
}
|
||||||
|
public get attached(): boolean {
|
||||||
|
return this.isAttached;
|
||||||
|
}
|
||||||
|
public get disposed(): boolean {
|
||||||
|
return this.isDisposed;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
{"hspPkgNames":[],"compileEntries":["&@ohos/common/Index&1.0.0","&@ohos/common/src/main/ets/component/ImmersiveTabNavigation&1.0.0","&@ohos/common/src/main/ets/constant/TrulyMEMConstants&1.0.0","&@ohos/common/src/main/ets/model/GraphDatabase&1.0.0","&@ohos/common/src/main/ets/routermanager/PageContext&1.0.0","&@ohos/common/src/main/ets/service/AIAgentService&1.0.0","&@ohos/common/src/main/ets/service/GraphMemoryService&1.0.0","&@ohos/common/src/main/ets/util/BreakpointSystem&1.0.0","&@ohos/common/src/main/ets/util/Logger&1.0.0","&@ohos/common/src/main/ets/viewmodel/BaseViewModel&1.0.0","&@ohos/graph/Index&1.0.0","&@ohos/graph/src/main/ets/components/GraphComponents&1.0.0","&@ohos/graph/src/main/ets/pages/GraphPage&1.0.0","&@ohos/chat/Index&1.0.0","&@ohos/chat/src/main/ets/components/ChatComponents&1.0.0","&@ohos/chat/src/main/ets/pages/ChatPage&1.0.0","&@ohos/settings/Index&1.0.0","&@ohos/settings/src/main/ets/components/SettingsComponents&1.0.0","&@ohos/settings/src/main/ets/pages/SettingsPage&1.0.0","&@ohos/phone/src/main/ets/pages/MainPage&","&phone/build/generated/r/ResourceTable&","&@ohos/phone/src/main/ets/entryability/EntryAbility&","&@ohos/phone/src/main/ets/pages/SplashPage&","&@ohos/phone/src/main/ets/pages/Index&"],"updateVersionInfo":{}}
|
||||||
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
@ -0,0 +1 @@
|
|||||||
|
<EFBFBD>r@<40>
|
||||||
Binary file not shown.
1
products/phone/build/default/cache/default/default@CompileArkTS/esmodule/debug/dep_info.json
vendored
Normal file
1
products/phone/build/default/cache/default/default@CompileArkTS/esmodule/debug/dep_info.json
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"resolveConflictMode":true,"depName2RootPath":{"@ohos/common":"/home/program/TrulyMEM-TrueHumanMEM/common","@ohos/graph":"/home/program/TrulyMEM-TrueHumanMEM/features/graph","@ohos/chat":"/home/program/TrulyMEM-TrueHumanMEM/features/chat","@ohos/settings":"/home/program/TrulyMEM-TrueHumanMEM/features/settings","@ohos/hypium":"/home/program/TrulyMEM-TrueHumanMEM/oh_modules/.ohpm/@ohos+hypium@1.0.24/oh_modules/@ohos/hypium","@ohos/hamock":"/home/program/TrulyMEM-TrueHumanMEM/oh_modules/.ohpm/@ohos+hamock@1.0.0/oh_modules/@ohos/hamock"},"depName2DepInfo":{"@ohos/common":{"dependencyType":"har","isByteCodeHar":false,"pkgRootPath":"/home/program/TrulyMEM-TrueHumanMEM/common","pkgName":"@ohos/common","pkgVersion":"1.0.0"},"@ohos/graph":{"dependencyType":"har","isByteCodeHar":false,"pkgRootPath":"/home/program/TrulyMEM-TrueHumanMEM/features/graph","pkgName":"@ohos/graph","pkgVersion":"1.0.0"},"@ohos/chat":{"dependencyType":"har","isByteCodeHar":false,"pkgRootPath":"/home/program/TrulyMEM-TrueHumanMEM/features/chat","pkgName":"@ohos/chat","pkgVersion":"1.0.0"},"@ohos/settings":{"dependencyType":"har","isByteCodeHar":false,"pkgRootPath":"/home/program/TrulyMEM-TrueHumanMEM/features/settings","pkgName":"@ohos/settings","pkgVersion":"1.0.0"},"@ohos/hypium":{"dependencyType":"har","isByteCodeHar":false,"pkgRootPath":"/home/program/TrulyMEM-TrueHumanMEM/oh_modules/.ohpm/@ohos+hypium@1.0.24/oh_modules/@ohos/hypium","pkgName":"@ohos/hypium","pkgVersion":"1.0.24"},"@ohos/hamock":{"dependencyType":"har","isByteCodeHar":false,"pkgRootPath":"/home/program/TrulyMEM-TrueHumanMEM/oh_modules/.ohpm/@ohos+hamock@1.0.0/oh_modules/@ohos/hamock","pkgName":"@ohos/hamock","pkgVersion":"1.0.0"}}}
|
||||||
24
products/phone/build/default/cache/default/default@CompileArkTS/esmodule/debug/filesInfo.txt
vendored
Normal file
24
products/phone/build/default/cache/default/default@CompileArkTS/esmodule/debug/filesInfo.txt
vendored
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
/home/program/TrulyMEM-TrueHumanMEM/products/phone/build/default/cache/default/default@CompileArkTS/esmodule/debug/common/Index.ts;&@ohos/common/Index&1.0.0;esm;@ohos/phone|@ohos/common|1.0.0|Index.ts;@ohos/common;false;ets
|
||||||
|
/home/program/TrulyMEM-TrueHumanMEM/products/phone/build/default/cache/default/default@CompileArkTS/esmodule/debug/common/src/main/ets/constant/TrulyMEMConstants.ts;&@ohos/common/src/main/ets/constant/TrulyMEMConstants&1.0.0;esm;@ohos/phone|@ohos/common|1.0.0|src/main/ets/constant/TrulyMEMConstants.ts;@ohos/common;false;ets
|
||||||
|
/home/program/TrulyMEM-TrueHumanMEM/products/phone/build/default/cache/default/default@CompileArkTS/esmodule/debug/common/src/main/ets/component/ImmersiveTabNavigation.ts;&@ohos/common/src/main/ets/component/ImmersiveTabNavigation&1.0.0;esm;@ohos/phone|@ohos/common|1.0.0|src/main/ets/component/ImmersiveTabNavigation.ts;@ohos/common;false;ets
|
||||||
|
/home/program/TrulyMEM-TrueHumanMEM/products/phone/build/default/cache/default/default@CompileArkTS/esmodule/debug/common/src/main/ets/routermanager/PageContext.ts;&@ohos/common/src/main/ets/routermanager/PageContext&1.0.0;esm;@ohos/phone|@ohos/common|1.0.0|src/main/ets/routermanager/PageContext.ts;@ohos/common;false;ets
|
||||||
|
/home/program/TrulyMEM-TrueHumanMEM/products/phone/build/default/cache/default/default@CompileArkTS/esmodule/debug/common/src/main/ets/service/AIAgentService.ts;&@ohos/common/src/main/ets/service/AIAgentService&1.0.0;esm;@ohos/phone|@ohos/common|1.0.0|src/main/ets/service/AIAgentService.ts;@ohos/common;false;ets
|
||||||
|
/home/program/TrulyMEM-TrueHumanMEM/products/phone/build/default/cache/default/default@CompileArkTS/esmodule/debug/common/src/main/ets/service/GraphMemoryService.ts;&@ohos/common/src/main/ets/service/GraphMemoryService&1.0.0;esm;@ohos/phone|@ohos/common|1.0.0|src/main/ets/service/GraphMemoryService.ts;@ohos/common;false;ets
|
||||||
|
/home/program/TrulyMEM-TrueHumanMEM/products/phone/build/default/cache/default/default@CompileArkTS/esmodule/debug/common/src/main/ets/util/BreakpointSystem.ts;&@ohos/common/src/main/ets/util/BreakpointSystem&1.0.0;esm;@ohos/phone|@ohos/common|1.0.0|src/main/ets/util/BreakpointSystem.ts;@ohos/common;false;ets
|
||||||
|
/home/program/TrulyMEM-TrueHumanMEM/products/phone/build/default/cache/default/default@CompileArkTS/esmodule/debug/common/src/main/ets/util/Logger.ts;&@ohos/common/src/main/ets/util/Logger&1.0.0;esm;@ohos/phone|@ohos/common|1.0.0|src/main/ets/util/Logger.ts;@ohos/common;false;ets
|
||||||
|
/home/program/TrulyMEM-TrueHumanMEM/products/phone/build/default/cache/default/default@CompileArkTS/esmodule/debug/common/src/main/ets/viewmodel/BaseViewModel.ts;&@ohos/common/src/main/ets/viewmodel/BaseViewModel&1.0.0;esm;@ohos/phone|@ohos/common|1.0.0|src/main/ets/viewmodel/BaseViewModel.ts;@ohos/common;false;ets
|
||||||
|
/home/program/TrulyMEM-TrueHumanMEM/products/phone/build/default/cache/default/default@CompileArkTS/esmodule/debug/common/src/main/ets/model/GraphDatabase.ts;&@ohos/common/src/main/ets/model/GraphDatabase&1.0.0;esm;@ohos/phone|@ohos/common|1.0.0|src/main/ets/model/GraphDatabase.ts;@ohos/common;false;ets
|
||||||
|
/home/program/TrulyMEM-TrueHumanMEM/products/phone/build/default/cache/default/default@CompileArkTS/esmodule/debug/graph/Index.ts;&@ohos/graph/Index&1.0.0;esm;@ohos/phone|@ohos/graph|1.0.0|Index.ts;@ohos/graph;false;ets
|
||||||
|
/home/program/TrulyMEM-TrueHumanMEM/products/phone/build/default/cache/default/default@CompileArkTS/esmodule/debug/graph/src/main/ets/pages/GraphPage.ts;&@ohos/graph/src/main/ets/pages/GraphPage&1.0.0;esm;@ohos/phone|@ohos/graph|1.0.0|src/main/ets/pages/GraphPage.ts;@ohos/graph;false;ets
|
||||||
|
/home/program/TrulyMEM-TrueHumanMEM/products/phone/build/default/cache/default/default@CompileArkTS/esmodule/debug/graph/src/main/ets/components/GraphComponents.ts;&@ohos/graph/src/main/ets/components/GraphComponents&1.0.0;esm;@ohos/phone|@ohos/graph|1.0.0|src/main/ets/components/GraphComponents.ts;@ohos/graph;false;ets
|
||||||
|
/home/program/TrulyMEM-TrueHumanMEM/products/phone/build/default/cache/default/default@CompileArkTS/esmodule/debug/chat/Index.ts;&@ohos/chat/Index&1.0.0;esm;@ohos/phone|@ohos/chat|1.0.0|Index.ts;@ohos/chat;false;ets
|
||||||
|
/home/program/TrulyMEM-TrueHumanMEM/products/phone/build/default/cache/default/default@CompileArkTS/esmodule/debug/chat/src/main/ets/pages/ChatPage.ts;&@ohos/chat/src/main/ets/pages/ChatPage&1.0.0;esm;@ohos/phone|@ohos/chat|1.0.0|src/main/ets/pages/ChatPage.ts;@ohos/chat;false;ets
|
||||||
|
/home/program/TrulyMEM-TrueHumanMEM/products/phone/build/default/cache/default/default@CompileArkTS/esmodule/debug/chat/src/main/ets/components/ChatComponents.ts;&@ohos/chat/src/main/ets/components/ChatComponents&1.0.0;esm;@ohos/phone|@ohos/chat|1.0.0|src/main/ets/components/ChatComponents.ts;@ohos/chat;false;ets
|
||||||
|
/home/program/TrulyMEM-TrueHumanMEM/products/phone/build/default/cache/default/default@CompileArkTS/esmodule/debug/settings/Index.ts;&@ohos/settings/Index&1.0.0;esm;@ohos/phone|@ohos/settings|1.0.0|Index.ts;@ohos/settings;false;ets
|
||||||
|
/home/program/TrulyMEM-TrueHumanMEM/products/phone/build/default/cache/default/default@CompileArkTS/esmodule/debug/settings/src/main/ets/components/SettingsComponents.ts;&@ohos/settings/src/main/ets/components/SettingsComponents&1.0.0;esm;@ohos/phone|@ohos/settings|1.0.0|src/main/ets/components/SettingsComponents.ts;@ohos/settings;false;ets
|
||||||
|
/home/program/TrulyMEM-TrueHumanMEM/products/phone/build/default/cache/default/default@CompileArkTS/esmodule/debug/settings/src/main/ets/pages/SettingsPage.ts;&@ohos/settings/src/main/ets/pages/SettingsPage&1.0.0;esm;@ohos/phone|@ohos/settings|1.0.0|src/main/ets/pages/SettingsPage.ts;@ohos/settings;false;ets
|
||||||
|
/home/program/TrulyMEM-TrueHumanMEM/products/phone/build/default/cache/default/default@CompileArkTS/esmodule/debug/phone/src/main/ets/pages/MainPage.ts;&@ohos/phone/src/main/ets/pages/MainPage&;esm;@ohos/phone|@ohos/phone|1.0.0|src/main/ets/pages/MainPage.ts;@ohos/phone;false;ets
|
||||||
|
/home/program/TrulyMEM-TrueHumanMEM/products/phone/build/default/cache/default/default@CompileArkTS/esmodule/debug/phone/build/default/generated/r/default/ResourceTable.js;&phone/build/generated/r/ResourceTable&;esm;@ohos/phone|@ohos/phone|1.0.0|build/default/generated/r/default/ResourceTable.js;@ohos/phone;false;ts
|
||||||
|
/home/program/TrulyMEM-TrueHumanMEM/products/phone/build/default/cache/default/default@CompileArkTS/esmodule/debug/phone/src/main/ets/entryability/EntryAbility.ts;&@ohos/phone/src/main/ets/entryability/EntryAbility&;esm;@ohos/phone|@ohos/phone|1.0.0|src/main/ets/entryability/EntryAbility.ts;@ohos/phone;false;ets
|
||||||
|
/home/program/TrulyMEM-TrueHumanMEM/products/phone/build/default/cache/default/default@CompileArkTS/esmodule/debug/phone/src/main/ets/pages/SplashPage.ts;&@ohos/phone/src/main/ets/pages/SplashPage&;esm;@ohos/phone|@ohos/phone|1.0.0|src/main/ets/pages/SplashPage.ts;@ohos/phone;false;ets
|
||||||
|
/home/program/TrulyMEM-TrueHumanMEM/products/phone/build/default/cache/default/default@CompileArkTS/esmodule/debug/phone/src/main/ets/pages/Index.ts;&@ohos/phone/src/main/ets/pages/Index&;esm;@ohos/phone|@ohos/phone|1.0.0|src/main/ets/pages/Index.ts;@ohos/phone;false;ets
|
||||||
BIN
products/phone/build/default/cache/default/default@CompileArkTS/esmodule/debug/graph/Index.protoBin
vendored
Normal file
BIN
products/phone/build/default/cache/default/default@CompileArkTS/esmodule/debug/graph/Index.protoBin
vendored
Normal file
Binary file not shown.
@ -0,0 +1 @@
|
|||||||
|
export { GraphPage } from "@normalized:N&&&@ohos/graph/src/main/ets/pages/GraphPage&1.0.0";
|
||||||
Binary file not shown.
@ -0,0 +1,390 @@
|
|||||||
|
if (!("finalizeConstruction" in ViewPU.prototype)) {
|
||||||
|
Reflect.set(ViewPU.prototype, "finalizeConstruction", () => { });
|
||||||
|
}
|
||||||
|
interface GraphWebView_Params {
|
||||||
|
controller?: web_webview.WebviewController;
|
||||||
|
bridge?: NativeBridge;
|
||||||
|
onPageEnd?: () => void;
|
||||||
|
}
|
||||||
|
interface NodeDetailPanel_Params {
|
||||||
|
detail?: NodeDetailInfo;
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
interface GraphNodeSearchBar_Params {
|
||||||
|
searchText?: string;
|
||||||
|
onSearchInput?: (value: string) => void;
|
||||||
|
}
|
||||||
|
import web_webview from "@ohos:web.webview";
|
||||||
|
import { Logger } from "@normalized:N&&&@ohos/common/Index&1.0.0";
|
||||||
|
import type { GraphDatabase, RecallEntity, ConnectionItem, NodeDetailInfo } from "@normalized:N&&&@ohos/common/Index&1.0.0";
|
||||||
|
export class GraphNodeSearchBar extends ViewPU {
|
||||||
|
constructor(parent, params, __localStorage, elmtId = -1, paramsLambda = undefined, extraInfo) {
|
||||||
|
super(parent, __localStorage, elmtId, extraInfo);
|
||||||
|
if (typeof paramsLambda === "function") {
|
||||||
|
this.paramsGenerator_ = paramsLambda;
|
||||||
|
}
|
||||||
|
this.__searchText = new SynchedPropertySimpleTwoWayPU(params.searchText, this, "searchText");
|
||||||
|
this.onSearchInput = undefined;
|
||||||
|
this.setInitiallyProvidedValue(params);
|
||||||
|
this.finalizeConstruction();
|
||||||
|
}
|
||||||
|
setInitiallyProvidedValue(params: GraphNodeSearchBar_Params) {
|
||||||
|
if (params.onSearchInput !== undefined) {
|
||||||
|
this.onSearchInput = params.onSearchInput;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateStateVars(params: GraphNodeSearchBar_Params) {
|
||||||
|
}
|
||||||
|
purgeVariableDependenciesOnElmtId(rmElmtId) {
|
||||||
|
this.__searchText.purgeDependencyOnElmtId(rmElmtId);
|
||||||
|
}
|
||||||
|
aboutToBeDeleted() {
|
||||||
|
this.__searchText.aboutToBeDeleted();
|
||||||
|
SubscriberManager.Get().delete(this.id__());
|
||||||
|
this.aboutToBeDeletedInternal();
|
||||||
|
}
|
||||||
|
private __searchText: SynchedPropertySimpleTwoWayPU<string>;
|
||||||
|
get searchText() {
|
||||||
|
return this.__searchText.get();
|
||||||
|
}
|
||||||
|
set searchText(newValue: string) {
|
||||||
|
this.__searchText.set(newValue);
|
||||||
|
}
|
||||||
|
private onSearchInput?: (value: string) => void;
|
||||||
|
initialRender() {
|
||||||
|
this.observeComponentCreation2((elmtId, isInitialRender) => {
|
||||||
|
Column.create();
|
||||||
|
Column.width('100%');
|
||||||
|
Column.position({ x: 0, y: 0 });
|
||||||
|
Column.zIndex(10);
|
||||||
|
}, Column);
|
||||||
|
this.observeComponentCreation2((elmtId, isInitialRender) => {
|
||||||
|
TextInput.create({ placeholder: '搜索节点...', text: this.searchText });
|
||||||
|
TextInput.width('80%');
|
||||||
|
TextInput.height(40);
|
||||||
|
TextInput.backgroundColor('rgba(10, 10, 26, 0.8)');
|
||||||
|
TextInput.fontColor('#ffffff');
|
||||||
|
TextInput.placeholderColor('#666688');
|
||||||
|
TextInput.borderRadius(8);
|
||||||
|
TextInput.border({ width: 1, color: 'rgba(100, 100, 255, 0.3)' });
|
||||||
|
TextInput.margin({ top: 20 });
|
||||||
|
TextInput.onChange((value: string) => {
|
||||||
|
this.onSearchInput?.(value);
|
||||||
|
});
|
||||||
|
}, TextInput);
|
||||||
|
Column.pop();
|
||||||
|
}
|
||||||
|
rerender() {
|
||||||
|
this.updateDirtyElements();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class NodeDetailPanel extends ViewPU {
|
||||||
|
constructor(parent, params, __localStorage, elmtId = -1, paramsLambda = undefined, extraInfo) {
|
||||||
|
super(parent, __localStorage, elmtId, extraInfo);
|
||||||
|
if (typeof paramsLambda === "function") {
|
||||||
|
this.paramsGenerator_ = paramsLambda;
|
||||||
|
}
|
||||||
|
this.__detail = new SynchedPropertyObjectOneWayPU(params.detail, this, "detail");
|
||||||
|
this.onClose = undefined;
|
||||||
|
this.setInitiallyProvidedValue(params);
|
||||||
|
this.finalizeConstruction();
|
||||||
|
}
|
||||||
|
setInitiallyProvidedValue(params: NodeDetailPanel_Params) {
|
||||||
|
if (params.onClose !== undefined) {
|
||||||
|
this.onClose = params.onClose;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateStateVars(params: NodeDetailPanel_Params) {
|
||||||
|
this.__detail.reset(params.detail);
|
||||||
|
}
|
||||||
|
purgeVariableDependenciesOnElmtId(rmElmtId) {
|
||||||
|
this.__detail.purgeDependencyOnElmtId(rmElmtId);
|
||||||
|
}
|
||||||
|
aboutToBeDeleted() {
|
||||||
|
this.__detail.aboutToBeDeleted();
|
||||||
|
SubscriberManager.Get().delete(this.id__());
|
||||||
|
this.aboutToBeDeletedInternal();
|
||||||
|
}
|
||||||
|
private __detail: SynchedPropertySimpleOneWayPU<NodeDetailInfo>;
|
||||||
|
get detail() {
|
||||||
|
return this.__detail.get();
|
||||||
|
}
|
||||||
|
set detail(newValue: NodeDetailInfo) {
|
||||||
|
this.__detail.set(newValue);
|
||||||
|
}
|
||||||
|
private onClose?: () => void;
|
||||||
|
initialRender() {
|
||||||
|
this.observeComponentCreation2((elmtId, isInitialRender) => {
|
||||||
|
Column.create();
|
||||||
|
Column.width('100%');
|
||||||
|
Column.height('100%');
|
||||||
|
Column.backgroundColor('rgba(0, 0, 0, 0.5)');
|
||||||
|
Column.justifyContent(FlexAlign.Center);
|
||||||
|
Column.alignItems(HorizontalAlign.Center);
|
||||||
|
Column.zIndex(20);
|
||||||
|
}, Column);
|
||||||
|
this.observeComponentCreation2((elmtId, isInitialRender) => {
|
||||||
|
Column.create();
|
||||||
|
Column.padding(20);
|
||||||
|
Column.backgroundColor('rgba(10, 10, 26, 0.95)');
|
||||||
|
Column.borderRadius(12);
|
||||||
|
Column.border({ width: 1, color: 'rgba(100, 100, 255, 0.3)' });
|
||||||
|
Column.width(300);
|
||||||
|
}, Column);
|
||||||
|
this.observeComponentCreation2((elmtId, isInitialRender) => {
|
||||||
|
Text.create(this.detail.name);
|
||||||
|
Text.fontSize(18);
|
||||||
|
Text.fontColor('#44ff88');
|
||||||
|
Text.fontWeight(FontWeight.Bold);
|
||||||
|
Text.margin({ bottom: 10 });
|
||||||
|
}, Text);
|
||||||
|
Text.pop();
|
||||||
|
this.observeComponentCreation2((elmtId, isInitialRender) => {
|
||||||
|
Text.create('类型: ' + this.detail.type);
|
||||||
|
Text.fontSize(14);
|
||||||
|
Text.fontColor('#aaaacc');
|
||||||
|
}, Text);
|
||||||
|
Text.pop();
|
||||||
|
this.observeComponentCreation2((elmtId, isInitialRender) => {
|
||||||
|
Text.create('提及次数: ' + this.detail.mention_count);
|
||||||
|
Text.fontSize(14);
|
||||||
|
Text.fontColor('#aaaacc');
|
||||||
|
}, Text);
|
||||||
|
Text.pop();
|
||||||
|
this.observeComponentCreation2((elmtId, isInitialRender) => {
|
||||||
|
Text.create('连接数: ' + this.detail.connection_count);
|
||||||
|
Text.fontSize(14);
|
||||||
|
Text.fontColor('#aaaacc');
|
||||||
|
}, Text);
|
||||||
|
Text.pop();
|
||||||
|
this.observeComponentCreation2((elmtId, isInitialRender) => {
|
||||||
|
If.create();
|
||||||
|
if (this.detail.connections && this.detail.connections.length > 0) {
|
||||||
|
this.ifElseBranchUpdateFunction(0, () => {
|
||||||
|
this.observeComponentCreation2((elmtId, isInitialRender) => {
|
||||||
|
Text.create('连接关系:');
|
||||||
|
Text.fontSize(14);
|
||||||
|
Text.fontColor('#8888aa');
|
||||||
|
Text.margin({ top: 10, bottom: 5 });
|
||||||
|
}, Text);
|
||||||
|
Text.pop();
|
||||||
|
this.observeComponentCreation2((elmtId, isInitialRender) => {
|
||||||
|
List.create();
|
||||||
|
List.height(100);
|
||||||
|
}, List);
|
||||||
|
this.observeComponentCreation2((elmtId, isInitialRender) => {
|
||||||
|
ForEach.create();
|
||||||
|
const forEachItemGenFunction = _item => {
|
||||||
|
const conn = _item;
|
||||||
|
{
|
||||||
|
const itemCreation = (elmtId, isInitialRender) => {
|
||||||
|
ViewStackProcessor.StartGetAccessRecordingFor(elmtId);
|
||||||
|
ListItem.create(deepRenderFunction, true);
|
||||||
|
if (!isInitialRender) {
|
||||||
|
ListItem.pop();
|
||||||
|
}
|
||||||
|
ViewStackProcessor.StopGetAccessRecording();
|
||||||
|
};
|
||||||
|
const itemCreation2 = (elmtId, isInitialRender) => {
|
||||||
|
ListItem.create(deepRenderFunction, true);
|
||||||
|
};
|
||||||
|
const deepRenderFunction = (elmtId, isInitialRender) => {
|
||||||
|
itemCreation(elmtId, isInitialRender);
|
||||||
|
this.observeComponentCreation2((elmtId, isInitialRender) => {
|
||||||
|
Text.create(conn.type + ': ' + conn.target_name);
|
||||||
|
Text.fontSize(12);
|
||||||
|
Text.fontColor('#aaaacc');
|
||||||
|
}, Text);
|
||||||
|
Text.pop();
|
||||||
|
ListItem.pop();
|
||||||
|
};
|
||||||
|
this.observeComponentCreation2(itemCreation2, ListItem);
|
||||||
|
ListItem.pop();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.forEachUpdateFunction(elmtId, this.detail.connections, forEachItemGenFunction);
|
||||||
|
}, ForEach);
|
||||||
|
ForEach.pop();
|
||||||
|
List.pop();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.ifElseBranchUpdateFunction(1, () => {
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, If);
|
||||||
|
If.pop();
|
||||||
|
this.observeComponentCreation2((elmtId, isInitialRender) => {
|
||||||
|
Button.createWithLabel('关闭');
|
||||||
|
Button.width(80);
|
||||||
|
Button.height(30);
|
||||||
|
Button.margin({ top: 15 });
|
||||||
|
Button.backgroundColor('rgba(100, 100, 255, 0.3)');
|
||||||
|
Button.fontColor('#ffffff');
|
||||||
|
Button.onClick(() => {
|
||||||
|
this.onClose?.();
|
||||||
|
});
|
||||||
|
}, Button);
|
||||||
|
Button.pop();
|
||||||
|
Column.pop();
|
||||||
|
Column.pop();
|
||||||
|
}
|
||||||
|
rerender() {
|
||||||
|
this.updateDirtyElements();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class GraphWebView extends ViewPU {
|
||||||
|
constructor(parent, params, __localStorage, elmtId = -1, paramsLambda = undefined, extraInfo) {
|
||||||
|
super(parent, __localStorage, elmtId, extraInfo);
|
||||||
|
if (typeof paramsLambda === "function") {
|
||||||
|
this.paramsGenerator_ = paramsLambda;
|
||||||
|
}
|
||||||
|
this.controller = new web_webview.WebviewController();
|
||||||
|
this.bridge = undefined;
|
||||||
|
this.onPageEnd = undefined;
|
||||||
|
this.setInitiallyProvidedValue(params);
|
||||||
|
this.finalizeConstruction();
|
||||||
|
}
|
||||||
|
setInitiallyProvidedValue(params: GraphWebView_Params) {
|
||||||
|
if (params.controller !== undefined) {
|
||||||
|
this.controller = params.controller;
|
||||||
|
}
|
||||||
|
if (params.bridge !== undefined) {
|
||||||
|
this.bridge = params.bridge;
|
||||||
|
}
|
||||||
|
if (params.onPageEnd !== undefined) {
|
||||||
|
this.onPageEnd = params.onPageEnd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateStateVars(params: GraphWebView_Params) {
|
||||||
|
}
|
||||||
|
purgeVariableDependenciesOnElmtId(rmElmtId) {
|
||||||
|
}
|
||||||
|
aboutToBeDeleted() {
|
||||||
|
SubscriberManager.Get().delete(this.id__());
|
||||||
|
this.aboutToBeDeletedInternal();
|
||||||
|
}
|
||||||
|
private controller: web_webview.WebviewController;
|
||||||
|
private bridge?: NativeBridge;
|
||||||
|
private onPageEnd?: () => void;
|
||||||
|
getController(): web_webview.WebviewController {
|
||||||
|
return this.controller;
|
||||||
|
}
|
||||||
|
setBridge(bridge: NativeBridge): void {
|
||||||
|
this.bridge = bridge;
|
||||||
|
}
|
||||||
|
initialRender() {
|
||||||
|
this.observeComponentCreation2((elmtId, isInitialRender) => {
|
||||||
|
Web.create({ src: { "id": 0, "type": 30000, params: ['graph.html'], "bundleName": "com.trulymem.app", "moduleName": "phone" }, controller: this.controller });
|
||||||
|
Web.javaScriptAccess(true);
|
||||||
|
Web.width('100%');
|
||||||
|
Web.height('100%');
|
||||||
|
Web.zoomAccess(true);
|
||||||
|
Web.onPageEnd(() => {
|
||||||
|
this.onPageEnd?.();
|
||||||
|
});
|
||||||
|
Web.javaScriptProxy({
|
||||||
|
object: this.bridge,
|
||||||
|
name: 'nativeBridge',
|
||||||
|
methodList: ['onNodeClick', 'onSearch'],
|
||||||
|
asyncMethodList: ['requestGraphData'],
|
||||||
|
controller: this.controller
|
||||||
|
});
|
||||||
|
}, Web);
|
||||||
|
}
|
||||||
|
rerender() {
|
||||||
|
this.updateDirtyElements();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* NativeBridge — WebView 原生桥接类(移动自 GraphPage)
|
||||||
|
* 负责 ArkTS ↔ WebView JavaScript 双向通信
|
||||||
|
*/
|
||||||
|
export class NativeBridge {
|
||||||
|
private controller: web_webview.WebviewController;
|
||||||
|
private onRequestGraphData: () => void;
|
||||||
|
private onNodeClickCallback: (nodeId: number, nodeName: string) => void;
|
||||||
|
private onSearchCallback: (query: string) => void;
|
||||||
|
constructor(controller: web_webview.WebviewController, onRequestGraphData: () => void, onNodeClickCallback: (nodeId: number, nodeName: string) => void, onSearchCallback: (query: string) => void) {
|
||||||
|
this.controller = controller;
|
||||||
|
this.onRequestGraphData = onRequestGraphData;
|
||||||
|
this.onNodeClickCallback = onNodeClickCallback;
|
||||||
|
this.onSearchCallback = onSearchCallback;
|
||||||
|
}
|
||||||
|
onNodeClick(nodeId: number, nodeName: string): void {
|
||||||
|
Logger.info('Node clicked: id=' + nodeId + ', name=' + nodeName);
|
||||||
|
if (this.onNodeClickCallback) {
|
||||||
|
this.onNodeClickCallback(nodeId, nodeName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onSearch(query: string): void {
|
||||||
|
Logger.info('Search from WebView: ' + query);
|
||||||
|
if (this.onSearchCallback) {
|
||||||
|
this.onSearchCallback(query);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
requestGraphData(): void {
|
||||||
|
Logger.info('requestGraphData called from WebView');
|
||||||
|
if (this.onRequestGraphData) {
|
||||||
|
this.onRequestGraphData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* GraphDataService — 图数据查询服务
|
||||||
|
* 封装从 GraphDatabase 读取节点和边的逻辑
|
||||||
|
*/
|
||||||
|
export class GraphDataService {
|
||||||
|
private db: GraphDatabase;
|
||||||
|
constructor(db: GraphDatabase) {
|
||||||
|
this.db = db;
|
||||||
|
}
|
||||||
|
async getAllNodes(): Promise<GraphNodeItem[]> {
|
||||||
|
const result = await this.db.search('');
|
||||||
|
return result.map((r, idx): GraphNodeItem => {
|
||||||
|
return {
|
||||||
|
id: idx + 1,
|
||||||
|
label: r.name,
|
||||||
|
type: r.type,
|
||||||
|
mentions: r.mentions
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async getAllEdges(): Promise<GraphEdgeItem[]> {
|
||||||
|
const recallResult = await this.db.recall('', [], 3);
|
||||||
|
const nameToId: Record<string, number> = {};
|
||||||
|
recallResult.entities.forEach((e: RecallEntity, idx: number): void => {
|
||||||
|
nameToId[e.name as string] = idx + 1;
|
||||||
|
});
|
||||||
|
const edgeItems: GraphEdgeItem[] = [];
|
||||||
|
for (let i = 0; i < recallResult.relations.length; i++) {
|
||||||
|
const r = recallResult.relations[i];
|
||||||
|
const sourceId = nameToId[r.source];
|
||||||
|
const targetId = nameToId[r.target];
|
||||||
|
if (sourceId !== undefined && targetId !== undefined) {
|
||||||
|
edgeItems.push({
|
||||||
|
id: i + 1,
|
||||||
|
source: sourceId,
|
||||||
|
target: targetId,
|
||||||
|
label: r.type,
|
||||||
|
relation: r.type
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return edgeItems;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ========= 内部类型定义 =========
|
||||||
|
interface GraphNodeItem {
|
||||||
|
id: number;
|
||||||
|
label: string;
|
||||||
|
type: string;
|
||||||
|
mentions: number;
|
||||||
|
}
|
||||||
|
interface GraphEdgeItem {
|
||||||
|
id: number;
|
||||||
|
source: number;
|
||||||
|
target: number;
|
||||||
|
label: string;
|
||||||
|
relation: string;
|
||||||
|
}
|
||||||
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user