Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e779c16595 | |||
| 5e4fb8222a | |||
| 5ae2143822 | |||
| ab3d910387 | |||
| 5916d2749c | |||
| 67c6b38e50 | |||
| 1a4a8dce98 | |||
| c8571f9d5f | |||
| dd0ab4dcbe | |||
| ba44d50d7a | |||
| b9a2cba2a7 | |||
| c060a59710 | |||
| d594099f02 | |||
| 45c098d10b | |||
| ce7d3b532f | |||
| 4bfa4673be | |||
| e6dc56b985 | |||
| 11ee329943 | |||
| 4c32dbb949 | |||
| 5c59cd2ceb | |||
| e83151bd1e | |||
| 2a42183aaa | |||
| 7c2441682c | |||
| 14bbcfde3d | |||
| be6555cca1 | |||
| c4f7b5a645 | |||
| 1a971fee73 | |||
| 4770b3b990 | |||
| aefaea7b90 | |||
| 05fc252db0 | |||
| b4456a9c5b | |||
| 46008c14c5 | |||
| 0ebd1d7ac6 | |||
| de09eacabc | |||
| 14af13c050 | |||
| 47c5b99617 | |||
| e975fe409b | |||
| 0270e17a76 | |||
| fc5335a5c6 | |||
| 2aa6e9240f | |||
| 535fca933a | |||
| b7b2601180 | |||
| a1b60ed936 |
81
.gitignore
vendored
81
.gitignore
vendored
@ -1,72 +1,19 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
develop-eggs/
|
||||
# Build cache
|
||||
.hvigor/
|
||||
entry/build/default/
|
||||
trulymem-core/build/default/
|
||||
trulymem-core/.preview/
|
||||
build/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# Virtual Environment
|
||||
venv/
|
||||
test_venv/
|
||||
ENV/
|
||||
env/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
.arts/
|
||||
.codeartsdoer/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
# Python cache
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
graph_memory.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Sensitive
|
||||
.env
|
||||
*.key
|
||||
*.pem
|
||||
config.json
|
||||
|
||||
# Build
|
||||
dist/
|
||||
|
||||
# Temporary
|
||||
*.tmp
|
||||
*.bak
|
||||
|
||||
# AI Generated
|
||||
jimeng*.png
|
||||
|
||||
# Test Cache
|
||||
.pytest_cache/
|
||||
|
||||
# TypeScript
|
||||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
tsconfig.tsbuildinfo
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.iml
|
||||
|
||||
10
AppScope/app.json5
Normal file
10
AppScope/app.json5
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"app": {
|
||||
"bundleName": "com.trulymem.app",
|
||||
"vendor": "trulymem",
|
||||
"versionCode": 1000001,
|
||||
"versionName": "1.0.0",
|
||||
"icon": "$media:layered_image",
|
||||
"label": "$string:app_name"
|
||||
}
|
||||
}
|
||||
5
AppScope/resources/base/media/layered_image.svg
Normal file
5
AppScope/resources/base/media/layered_image.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" width="200" height="200">
|
||||
<rect width="200" height="200" rx="30" fill="#6366f1"/>
|
||||
<text x="100" y="130" font-family="Arial" font-size="80" fill="white" text-anchor="middle" font-weight="bold">T</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 313 B |
196
LICENSE
196
LICENSE
@ -1,196 +0,0 @@
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2026 jianf
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
================================================================================
|
||||
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2026 jianf
|
||||
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of the program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its author. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers. In our view, they should not
|
||||
allow patents to restrict development and use of software on
|
||||
general-purpose computers. But in those that do, we wish to avoid the
|
||||
special danger that patents applied to a free program could make it
|
||||
effectively proprietary. To prevent this, the GPL assures that patents
|
||||
cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or menu items, or similar,
|
||||
each item in the list is treated as if it were an independent command
|
||||
or menu item. If the interface presents a list of options as a dialog
|
||||
box, the list is treated as a single option.
|
||||
|
||||
[The full text of the GPL v3 license continues with sections 1-17,
|
||||
but is truncated here for brevity. The complete license text is
|
||||
available at https://www.gnu.org/licenses/gpl-3.0.txt]
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) 2026 jianf
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
TrulyMEM Copyright (C) 2026 jianf
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
249
README.md
249
README.md
@ -1,249 +0,0 @@
|
||||
# TrulyMEM - WaterFlow 适配版
|
||||
|
||||
让 AI 拥有真正的长期记忆能力 - WaterFlow 框架适配版
|
||||
|
||||
[English Version](./README_EN.md)
|
||||
|
||||
---
|
||||
|
||||
## 简介
|
||||
|
||||
本项目是将 TrulyMEM 的图记忆能力迁移到 WaterFlow 框架的 TypeScript 实现。
|
||||
|
||||
作为 WaterFlow 的内置模块,提供图记忆功能:
|
||||
- **recall**: 检索记忆
|
||||
- **commit**: 写入记忆
|
||||
- **purge**: 删除记忆
|
||||
- **introspect**: 查看状态
|
||||
- **persona_update/clear**: 人设管理
|
||||
- **task_create/set_state/delete**: 任务管理
|
||||
|
||||
---
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
ts/
|
||||
├── src/runtime/core/
|
||||
│ ├── graph_memory/ # 图记忆核心模块
|
||||
│ │ ├── types.ts # 类型定义
|
||||
│ │ ├── graph_database.ts # 图数据库
|
||||
│ │ ├── memory_service.ts # 记忆服务
|
||||
│ │ └── index.ts # 模块导出
|
||||
│ └── tools/
|
||||
│ └── builtin/
|
||||
│ └── graph_memory_tool.ts # Tool 实现
|
||||
│
|
||||
├── bundled-skills/ # Skill 定义
|
||||
│ └── graph_memory/
|
||||
│ ├── SKILL.md # 记忆操作
|
||||
│ ├── persona/SKILL.md # 人设管理
|
||||
│ └── task/SKILL.md # 任务管理
|
||||
│
|
||||
├── package.json # 项目配置
|
||||
└── tsconfig.json # TypeScript 配置
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 在 WaterFlow 中使用
|
||||
|
||||
本模块完全不动 WaterFlow 源码,只需在你的入口文件中注册即可。
|
||||
|
||||
### 快速开始(推荐)
|
||||
|
||||
#### 步骤 1:安装依赖
|
||||
|
||||
```bash
|
||||
npm install /path/to/TrulyMEM-TrueHumanMEM/ts
|
||||
```
|
||||
|
||||
或在 `package.json` 中添加:
|
||||
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"trulymem-waterflow": "file:../TrulyMEM-TrueHumanMEM/ts"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
然后运行:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
#### 步骤 2:在你的入口文件中注册
|
||||
|
||||
只需两行代码,完全不动 WaterFlow 源码:
|
||||
|
||||
```typescript
|
||||
import { getPlatform } from 'waterflow-ts/dist/platform/index.js';
|
||||
import { installTrulyMEM } from 'trulymem/tools';
|
||||
|
||||
// 一行安装,返回配置好的 ToolRegistry
|
||||
const registry = await installTrulyMEM(getPlatform(), 'my-session-id');
|
||||
|
||||
// 继续组装 WaterFlow...
|
||||
const toolExecutor = new ToolExecutor(registry);
|
||||
```
|
||||
|
||||
### 手动注册(更灵活)
|
||||
|
||||
如果你想自己控制 ToolRegistry 的创建:
|
||||
|
||||
```typescript
|
||||
import { getPlatform } from 'waterflow-ts/dist/platform/index.js';
|
||||
import { initializeToolRegistry } from 'waterflow-ts/dist/runtime/core/tools/builtin/index.js';
|
||||
import { registerGraphMemoryTool } from 'trulymem/tools';
|
||||
|
||||
const platform = getPlatform();
|
||||
const registry = initializeToolRegistry(platform);
|
||||
|
||||
// 注册图记忆工具
|
||||
registerGraphMemoryTool(registry, 'my-session-id');
|
||||
|
||||
// 继续组装...
|
||||
```
|
||||
|
||||
### 使用 Skill(AI Agent 调用)
|
||||
|
||||
#### 步骤 1:配置 Skill 来源
|
||||
|
||||
```typescript
|
||||
const config = {
|
||||
...DEFAULT_SKILL_LOADER_CONFIG,
|
||||
sources: {
|
||||
...DEFAULT_SKILL_LOADER_CONFIG.sources,
|
||||
bundled: './node_modules/trulymem-waterflow/bundled-skills'
|
||||
},
|
||||
enabledSources: ['project', 'bundled']
|
||||
};
|
||||
```
|
||||
|
||||
#### 步骤 2:通过 Agent 调用
|
||||
|
||||
AI Agent 会自动读取 SKILL.md 并调用 `builtin:graph_memory` 工具。
|
||||
|
||||
#### 可用 Skill 列表
|
||||
|
||||
| Skill 名称 | 功能 | 使用场景 |
|
||||
|------------|------|----------|
|
||||
| `graph_memory` | 记忆 CRUD | 读取/写入/删除记忆 |
|
||||
| `persona` | 人设管理 | 设置 AI 角色性格 |
|
||||
| `task` | 任务管理 | 创建/更新长期任务 |
|
||||
|
||||
#### Skill 定义格式说明
|
||||
|
||||
WaterFlow 的 SkillLoader 会从 `SKILL.md` 中提取:
|
||||
|
||||
- **name**: 从目录名提取(如 `graph_memory`、`persona`、`task`)
|
||||
- **description**: 从 Markdown 的第一个 `#` 标题提取
|
||||
- **allowed-tools**: 转换为 `allowedTools` 字段
|
||||
- **arguments**: 正确映射到 SkillDefinition.arguments
|
||||
- **user-invocable**: 转换为 `userInvocable` 字段
|
||||
|
||||
**注意**: `when_to_use` 信息已整合到 Markdown body 中,通过 SkillRegistry.search() 可匹配。
|
||||
|
||||
---
|
||||
|
||||
## API
|
||||
|
||||
### GraphMemoryTool
|
||||
|
||||
```typescript
|
||||
const tool = new GraphMemoryTool(sessionId?: string);
|
||||
```
|
||||
|
||||
#### Actions
|
||||
|
||||
| Action | 说明 | 参数 |
|
||||
|--------|------|------|
|
||||
| `recall` | 检索记忆 | `queryIntent`, `seedEntities`, `depth`, `sessionFilter` |
|
||||
| `commit` | 写入记忆 | `triplets`, `sessionId`, `turnId` |
|
||||
| `purge` | 删除记忆 | `criteria`, `mode` |
|
||||
| `introspect` | 查看状态 | - |
|
||||
| `persona_update` | 更新人设 | `attributes`, `mode` |
|
||||
| `persona_clear` | 清除人设 | `confirm` |
|
||||
| `task_create` | 创建任务 | `task_id`, `description`, `info_nodes` |
|
||||
| `task_set_state` | 设置状态 | `task_id`, `state` |
|
||||
| `task_delete` | 删除任务 | `task_id` |
|
||||
| `task_link_info` | 关联信息到任务 | `task_id`, `info_node` |
|
||||
|
||||
---
|
||||
|
||||
## 示例
|
||||
|
||||
### 写入记忆
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "commit",
|
||||
"params": {
|
||||
"triplets": [
|
||||
{ "subject": "用户", "relation": "喜欢", "object": "TypeScript" },
|
||||
{ "subject": "用户", "relation": "正在学习", "object": "WaterFlow" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 检索记忆
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "recall",
|
||||
"params": {
|
||||
"queryIntent": "用户 学习"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 创建任务
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "task_create",
|
||||
"params": {
|
||||
"task_id": "Task_学习TypeScript",
|
||||
"description": "学习 TypeScript 并完成项目",
|
||||
"info_nodes": ["文档链接", "教程链接"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 关联信息到任务
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "task_link_info",
|
||||
"params": {
|
||||
"task_id": "Task_学习TypeScript",
|
||||
"info_node": "用户喜欢 React"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API 名称映射
|
||||
|
||||
OpenAI/DeepSeek API 要求工具名称符合 `^[a-zA-Z0-9_-]+$` 格式(不含冒号)。
|
||||
内部工具 ID 使用 `builtin:xxx` 格式,需映射后发送给 API。
|
||||
|
||||
```typescript
|
||||
import { mapToolIdToApiName, mapApiNameToToolId } from 'trulymem/tools';
|
||||
|
||||
// 发送给 API
|
||||
const apiName = mapToolIdToApiName('builtin:graph_memory'); // -> 'graph_memory'
|
||||
|
||||
// 收到 tool_use 后映射回
|
||||
const internalId = mapApiNameToToolId('graph_memory'); // -> 'builtin:graph_memory'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 许可证
|
||||
|
||||
[GNU General Public License v3.0 (GPLv3)](LICENSE)
|
||||
250
README_EN.md
250
README_EN.md
@ -1,250 +0,0 @@
|
||||
# TrulyMEM - WaterFlow Adapter
|
||||
|
||||
Give AI true long-term memory capability - WaterFlow framework adapter version
|
||||
|
||||
[中文版本](./README.md)
|
||||
|
||||
---
|
||||
|
||||
## Introduction
|
||||
|
||||
This project ports TrulyMEM's graph memory capability to TypeScript for the WaterFlow framework.
|
||||
|
||||
As a built-in module for WaterFlow, it provides graph memory functionality:
|
||||
|
||||
- **recall**: Retrieve memories
|
||||
- **commit**: Commit memories
|
||||
- **purge**: Delete memories
|
||||
- **introspect**: Inspect status
|
||||
- **persona_update/clear**: Persona management
|
||||
- **task_create/set_state/delete**: Task management
|
||||
|
||||
---
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
ts/
|
||||
├── src/runtime/core/
|
||||
│ ├── graph_memory/ # Graph memory core module
|
||||
│ │ ├── types.ts # Type definitions
|
||||
│ │ ├── graph_database.ts # Graph database
|
||||
│ │ ├── memory_service.ts # Memory service
|
||||
│ │ └── index.ts # Module exports
|
||||
│ └── tools/
|
||||
│ └── builtin/
|
||||
│ └── graph_memory_tool.ts # Tool implementation
|
||||
│
|
||||
├── bundled-skills/ # Skill definitions
|
||||
│ └── graph_memory/
|
||||
│ ├── SKILL.md # Memory operations
|
||||
│ ├── persona/SKILL.md # Persona management
|
||||
│ └── task/SKILL.md # Task management
|
||||
│
|
||||
├── package.json # Project config
|
||||
└── tsconfig.json # TypeScript config
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Usage in WaterFlow
|
||||
|
||||
This module requires **zero changes** to WaterFlow source code. Just register it in your entry file.
|
||||
|
||||
### Quick Start (Recommended)
|
||||
|
||||
#### Step 1: Install
|
||||
|
||||
```bash
|
||||
npm install /path/to/TrulyMEM-TrueHumanMEM/ts
|
||||
```
|
||||
|
||||
Or add to `package.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"trulymem-waterflow": "file:../TrulyMEM-TrueHumanMEM/ts"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then run:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
#### Step 2: Register in your entry file
|
||||
|
||||
Just two lines, zero changes to WaterFlow:
|
||||
|
||||
```typescript
|
||||
import { getPlatform } from 'waterflow-ts/dist/platform/index.js';
|
||||
import { installTrulyMEM } from 'trulymem/tools';
|
||||
|
||||
// One-line install, returns configured ToolRegistry
|
||||
const registry = await installTrulyMEM(getPlatform(), 'my-session-id');
|
||||
|
||||
// Continue assembling WaterFlow...
|
||||
const toolExecutor = new ToolExecutor(registry);
|
||||
```
|
||||
|
||||
### Manual Registration (More control)
|
||||
|
||||
If you want to control ToolRegistry creation yourself:
|
||||
|
||||
```typescript
|
||||
import { getPlatform } from 'waterflow-ts/dist/platform/index.js';
|
||||
import { initializeToolRegistry } from 'waterflow-ts/dist/runtime/core/tools/builtin/index.js';
|
||||
import { registerGraphMemoryTool } from 'trulymem/tools';
|
||||
|
||||
const platform = getPlatform();
|
||||
const registry = initializeToolRegistry(platform);
|
||||
|
||||
// Register graph memory tool
|
||||
registerGraphMemoryTool(registry, 'my-session-id');
|
||||
|
||||
// Continue assembling...
|
||||
```
|
||||
|
||||
### Use Skill (AI Agent)
|
||||
|
||||
#### Step 1: Configure Skill source
|
||||
|
||||
```typescript
|
||||
const config = {
|
||||
...DEFAULT_SKILL_LOADER_CONFIG,
|
||||
sources: {
|
||||
...DEFAULT_SKILL_LOADER_CONFIG.sources,
|
||||
bundled: './node_modules/trulymem-waterflow/bundled-skills'
|
||||
},
|
||||
enabledSources: ['project', 'bundled']
|
||||
};
|
||||
```
|
||||
|
||||
#### Step 2: Call via Agent
|
||||
|
||||
AI Agent automatically reads SKILL.md and calls `builtin:graph_memory` tool.
|
||||
|
||||
#### Available Skills
|
||||
|
||||
| Skill Name | Function | Use Case |
|
||||
|------------|----------|----------|
|
||||
| `graph_memory` | Memory CRUD | Read/Write/Delete memories |
|
||||
| `persona` | Persona management | Set AI role/personality |
|
||||
| `task` | Task management | Create/update long-term tasks |
|
||||
|
||||
#### Skill Definition Format
|
||||
|
||||
WaterFlow's SkillLoader extracts from `SKILL.md`:
|
||||
|
||||
- **name**: Extracted from directory name (e.g., `graph_memory`, `persona`, `task`)
|
||||
- **description**: Extracted from first Markdown `#` heading
|
||||
- **allowed-tools**: Converted to `allowedTools` field
|
||||
- **arguments**: Properly mapped to SkillDefinition.arguments
|
||||
- **user-invocable**: Converted to `userInvocable` field
|
||||
|
||||
**Note**: `when_to_use` info is integrated into Markdown body, searchable via SkillRegistry.search().
|
||||
|
||||
---
|
||||
|
||||
## API
|
||||
|
||||
### GraphMemoryTool
|
||||
|
||||
```typescript
|
||||
const tool = new GraphMemoryTool(sessionId?: string);
|
||||
```
|
||||
|
||||
#### Actions
|
||||
|
||||
| Action | Description | Parameters |
|
||||
|--------|-------------|------------|
|
||||
| `recall` | Retrieve memories | `queryIntent`, `seedEntities`, `depth`, `sessionFilter` |
|
||||
| `commit` | Commit memories | `triplets`, `sessionId`, `turnId` |
|
||||
| `purge` | Delete memories | `criteria`, `mode` |
|
||||
| `introspect` | Inspect status | - |
|
||||
| `persona_update` | Update persona | `attributes`, `mode` |
|
||||
| `persona_clear` | Clear persona | `confirm` |
|
||||
| `task_create` | Create task | `task_id`, `description`, `info_nodes` |
|
||||
| `task_set_state` | Set state | `task_id`, `state` |
|
||||
| `task_delete` | Delete task | `task_id` |
|
||||
| `task_link_info` | Link info to task | `task_id`, `info_node` |
|
||||
|
||||
---
|
||||
|
||||
## Examples
|
||||
|
||||
### Commit Memory
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "commit",
|
||||
"params": {
|
||||
"triplets": [
|
||||
{ "subject": "User", "relation": "likes", "object": "TypeScript" },
|
||||
{ "subject": "User", "relation": "is learning", "object": "WaterFlow" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Recall Memory
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "recall",
|
||||
"params": {
|
||||
"queryIntent": "User learning"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Create Task
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "task_create",
|
||||
"params": {
|
||||
"task_id": "Task_LearnTypeScript",
|
||||
"description": "Learn TypeScript and complete project",
|
||||
"info_nodes": ["Documentation", "Tutorial"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Link Info to Task
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "task_link_info",
|
||||
"params": {
|
||||
"task_id": "Task_LearnTypeScript",
|
||||
"info_node": "User likes React"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Name Mapping
|
||||
|
||||
OpenAI/DeepSeek API requires tool names to match `^[a-zA-Z0-9_-]+$` (no colons).
|
||||
Internal tool IDs use `builtin:xxx` format and must be mapped before sending to API.
|
||||
|
||||
```typescript
|
||||
import { mapToolIdToApiName, mapApiNameToToolId } from 'trulymem/tools';
|
||||
|
||||
// Send to API
|
||||
const apiName = mapToolIdToApiName('builtin:graph_memory'); // -> 'graph_memory'
|
||||
|
||||
// Map back after receiving tool_use
|
||||
const internalId = mapApiNameToToolId('graph_memory'); // -> 'builtin:graph_memory'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
[GNU General Public License v3.0 (GPLv3)](LICENSE)
|
||||
45
TrulyMEM.spec
Normal file
45
TrulyMEM.spec
Normal file
@ -0,0 +1,45 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
from PyInstaller.utils.hooks import collect_all
|
||||
|
||||
datas = [('ui/styles', 'ui/styles'), ('core/prompts/templates', 'core/prompts/templates'), ('static', 'static'), ('templates', 'templates'), ('core/web_api.py', 'core/')]
|
||||
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', 'core.server', 'core.client', 'core.migrate', 'core.activity_recorder', 'ui', 'ui.app', 'ui.login_screen', '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', 'core.web_api', 'flask', 'flask_cors', 'werkzeug']
|
||||
tmp_ret = collect_all('textual')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
|
||||
|
||||
a = Analysis(
|
||||
['trulymem_entry.py'],
|
||||
pathex=[],
|
||||
binaries=binaries,
|
||||
datas=datas,
|
||||
hiddenimports=hiddenimports,
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=[],
|
||||
noarchive=False,
|
||||
optimize=0,
|
||||
)
|
||||
pyz = PYZ(a.pure)
|
||||
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
a.binaries,
|
||||
a.datas,
|
||||
[],
|
||||
name='TrulyMEM',
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
runtime_tmpdir=None,
|
||||
console=True,
|
||||
disable_windowed_traceback=False,
|
||||
argv_emulation=False,
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
)
|
||||
91
build-profile.json5
Normal file
91
build-profile.json5
Normal file
@ -0,0 +1,91 @@
|
||||
{
|
||||
"app": {
|
||||
"signingConfigs": [],
|
||||
"products": [
|
||||
{
|
||||
"name": "default",
|
||||
"targetSdkVersion": "6.1.0(23)",
|
||||
"compatibleSdkVersion": "6.1.0(23)",
|
||||
"runtimeOS": "HarmonyOS",
|
||||
"buildOption": {
|
||||
"strictMode": {
|
||||
"caseSensitiveCheck": true,
|
||||
"useNormalizedOHMUrl": true
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"buildModeSet": [
|
||||
{
|
||||
"name": "debug"
|
||||
},
|
||||
{
|
||||
"name": "release"
|
||||
}
|
||||
]
|
||||
},
|
||||
"modules": [
|
||||
{
|
||||
"name": "common",
|
||||
"srcPath": "./common",
|
||||
"targets": [
|
||||
{
|
||||
"name": "default",
|
||||
"applyToProducts": [
|
||||
"default"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
"name": "graph",
|
||||
"srcPath": "./features/graph",
|
||||
"targets": [
|
||||
{
|
||||
"name": "default",
|
||||
"applyToProducts": [
|
||||
"default"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "chat",
|
||||
"srcPath": "./features/chat",
|
||||
"targets": [
|
||||
{
|
||||
"name": "default",
|
||||
"applyToProducts": [
|
||||
"default"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "settings",
|
||||
"srcPath": "./features/settings",
|
||||
"targets": [
|
||||
{
|
||||
"name": "default",
|
||||
"applyToProducts": [
|
||||
"default"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "phone",
|
||||
"srcPath": "./products/phone",
|
||||
"targets": [
|
||||
{
|
||||
"name": "default",
|
||||
"applyToProducts": [
|
||||
"default"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
]
|
||||
}
|
||||
135
build.log
Normal file
135
build.log
Normal file
@ -0,0 +1,135 @@
|
||||
===== Building TrulyMEM for Linux =====
|
||||
Project root: /home/program/TrulyMEM-TrueHumanMEM
|
||||
The virtual environment was not created successfully because ensurepip is not
|
||||
available. On Debian/Ubuntu systems, you need to install the python3-venv
|
||||
package using the following command.
|
||||
|
||||
apt install python3.13-venv
|
||||
|
||||
You may need to use sudo with that command. After installing the python3-venv
|
||||
package, recreate your virtual environment.
|
||||
|
||||
Failing command: /home/program/TrulyMEM-TrueHumanMEM/.venv_build/bin/python3
|
||||
|
||||
Warning: venv creation failed, falling back to system Python
|
||||
Cleaning previous builds...
|
||||
================================
|
||||
Building TrulyMEM (TUI + Web embedded)
|
||||
================================
|
||||
29 INFO: PyInstaller: 6.20.0, contrib hooks: 2026.4
|
||||
29 INFO: Python: 3.13.12
|
||||
31 INFO: Platform: Linux-6.1.0-44-amd64-x86_64-with-glibc2.42
|
||||
31 INFO: Python environment: /usr
|
||||
33 INFO: Removing temporary files and cleaning cache in /root/.cache/pyinstaller
|
||||
34 INFO: Module search paths (PYTHONPATH):
|
||||
['/home/program/TrulyMEM-TrueHumanMEM',
|
||||
'/home/program/TrulyMEM-TrueHumanMEM',
|
||||
'/usr/lib/python313.zip',
|
||||
'/usr/lib/python3.13',
|
||||
'/usr/lib/python3.13/lib-dynload',
|
||||
'/usr/local/lib/python3.13/dist-packages',
|
||||
'/usr/lib/python3/dist-packages',
|
||||
'/home/program/TrulyMEM-TrueHumanMEM']
|
||||
141 INFO: Appending 'datas' from .spec
|
||||
141 INFO: checking Analysis
|
||||
141 INFO: Building Analysis because Analysis-00.toc is non existent
|
||||
141 INFO: Looking for Python shared library...
|
||||
149 INFO: Using Python shared library: /usr/lib/x86_64-linux-gnu/libpython3.13.so.1.0
|
||||
149 INFO: Running Analysis Analysis-00.toc
|
||||
149 INFO: Target bytecode optimization level: 0
|
||||
149 INFO: Initializing module dependency graph...
|
||||
149 INFO: Initializing module graph hook caches...
|
||||
153 INFO: Analyzing modules for base_library.zip ...
|
||||
645 INFO: Processing standard module hook 'hook-encodings.py' from '/usr/local/lib/python3.13/dist-packages/PyInstaller/hooks'
|
||||
1604 INFO: Processing standard module hook 'hook-pickle.py' from '/usr/local/lib/python3.13/dist-packages/PyInstaller/hooks'
|
||||
2203 INFO: Processing standard module hook 'hook-heapq.py' from '/usr/local/lib/python3.13/dist-packages/PyInstaller/hooks'
|
||||
2434 INFO: Caching module dependency graph...
|
||||
2456 INFO: Analyzing /home/program/TrulyMEM-TrueHumanMEM/trulymem_entry.py
|
||||
2487 INFO: Processing standard module hook 'hook-sqlite3.py' from '/usr/local/lib/python3.13/dist-packages/PyInstaller/hooks'
|
||||
2606 INFO: Processing standard module hook 'hook-platform.py' from '/usr/local/lib/python3.13/dist-packages/PyInstaller/hooks'
|
||||
2628 INFO: Processing standard module hook 'hook-sysconfig.py' from '/usr/local/lib/python3.13/dist-packages/PyInstaller/hooks'
|
||||
2631 INFO: Processing standard module hook 'hook-_ctypes.py' from '/usr/local/lib/python3.13/dist-packages/PyInstaller/hooks'
|
||||
2641 INFO: Processing pre-safe-import-module hook 'hook-typing_extensions.py' from '/usr/local/lib/python3.13/dist-packages/PyInstaller/hooks/pre_safe_import_module'
|
||||
2641 INFO: SetuptoolsInfo: initializing cached setuptools info...
|
||||
4602 INFO: Processing standard module hook 'hook-multiprocessing.util.py' from '/usr/local/lib/python3.13/dist-packages/PyInstaller/hooks'
|
||||
4748 INFO: Processing standard module hook 'hook-xml.py' from '/usr/local/lib/python3.13/dist-packages/PyInstaller/hooks'
|
||||
5155 INFO: Processing standard module hook 'hook-pydantic.py' from '/usr/local/lib/python3.13/dist-packages/_pyinstaller_hooks_contrib/stdhooks'
|
||||
5444 INFO: Processing standard module hook 'hook-rich.py' from '/usr/local/lib/python3.13/dist-packages/_pyinstaller_hooks_contrib/stdhooks'
|
||||
5729 INFO: Processing standard module hook 'hook-pygments.py' from '/usr/local/lib/python3.13/dist-packages/PyInstaller/hooks'
|
||||
6155 INFO: Processing standard module hook 'hook-chardet.py' from '/usr/local/lib/python3.13/dist-packages/_pyinstaller_hooks_contrib/stdhooks'
|
||||
7547 INFO: Processing standard module hook 'hook-zoneinfo.py' from '/usr/local/lib/python3.13/dist-packages/_pyinstaller_hooks_contrib/stdhooks'
|
||||
8697 INFO: Processing standard module hook 'hook-certifi.py' from '/usr/local/lib/python3.13/dist-packages/_pyinstaller_hooks_contrib/stdhooks'
|
||||
8766 INFO: Processing standard module hook 'hook-anyio.py' from '/usr/local/lib/python3.13/dist-packages/_pyinstaller_hooks_contrib/stdhooks'
|
||||
9419 INFO: Processing standard module hook 'hook-difflib.py' from '/usr/local/lib/python3.13/dist-packages/PyInstaller/hooks'
|
||||
10529 INFO: Processing standard module hook 'hook-numpy.py' from '/usr/local/lib/python3.13/dist-packages/PyInstaller/hooks'
|
||||
11824 INFO: Processing standard module hook 'hook-charset_normalizer.py' from '/usr/local/lib/python3.13/dist-packages/_pyinstaller_hooks_contrib/stdhooks'
|
||||
12622 INFO: Processing standard module hook 'hook-pytz.py' from '/usr/local/lib/python3.13/dist-packages/PyInstaller/hooks'
|
||||
13192 INFO: Processing pre-safe-import-module hook 'hook-platformdirs.py' from '/usr/local/lib/python3.13/dist-packages/PyInstaller/hooks/pre_safe_import_module'
|
||||
13199 INFO: Processing standard module hook 'hook-platformdirs.py' from '/usr/local/lib/python3.13/dist-packages/_pyinstaller_hooks_contrib/stdhooks'
|
||||
14917 INFO: Processing standard module hook 'hook-jinja2.py' from '/usr/local/lib/python3.13/dist-packages/_pyinstaller_hooks_contrib/stdhooks'
|
||||
15284 INFO: Processing pre-safe-import-module hook 'hook-importlib_metadata.py' from '/usr/local/lib/python3.13/dist-packages/PyInstaller/hooks/pre_safe_import_module'
|
||||
15285 INFO: Setuptools: 'importlib_metadata' appears to be a setuptools-vendored copy - creating alias to 'setuptools._vendor.importlib_metadata'!
|
||||
15289 INFO: Processing standard module hook 'hook-setuptools.py' from '/usr/local/lib/python3.13/dist-packages/PyInstaller/hooks'
|
||||
15296 INFO: Processing pre-safe-import-module hook 'hook-distutils.py' from '/usr/local/lib/python3.13/dist-packages/PyInstaller/hooks/pre_safe_import_module'
|
||||
15314 INFO: Processing pre-safe-import-module hook 'hook-jaraco.py' from '/usr/local/lib/python3.13/dist-packages/PyInstaller/hooks/pre_safe_import_module'
|
||||
15314 INFO: Setuptools: 'jaraco' appears to be a partial setuptools-vendored copy - extending search paths to ['/usr/lib/python3/dist-packages/jaraco', '/usr/lib/python3/dist-packages/setuptools/_vendor/jaraco']!
|
||||
15315 INFO: Processing pre-safe-import-module hook 'hook-jaraco.functools.py' from '/usr/local/lib/python3.13/dist-packages/PyInstaller/hooks/pre_safe_import_module'
|
||||
15320 INFO: Processing pre-safe-import-module hook 'hook-more_itertools.py' from '/usr/local/lib/python3.13/dist-packages/PyInstaller/hooks/pre_safe_import_module'
|
||||
15459 INFO: Processing pre-safe-import-module hook 'hook-packaging.py' from '/usr/local/lib/python3.13/dist-packages/PyInstaller/hooks/pre_safe_import_module'
|
||||
15575 INFO: Processing pre-safe-import-module hook 'hook-jaraco.text.py' from '/usr/local/lib/python3.13/dist-packages/PyInstaller/hooks/pre_safe_import_module'
|
||||
15581 INFO: Processing standard module hook 'hook-jaraco.text.py' from '/usr/local/lib/python3.13/dist-packages/_pyinstaller_hooks_contrib/stdhooks'
|
||||
15614 INFO: Processing pre-safe-import-module hook 'hook-importlib_resources.py' from '/usr/local/lib/python3.13/dist-packages/PyInstaller/hooks/pre_safe_import_module'
|
||||
15615 INFO: Processing pre-safe-import-module hook 'hook-jaraco.context.py' from '/usr/local/lib/python3.13/dist-packages/PyInstaller/hooks/pre_safe_import_module'
|
||||
15620 INFO: Processing pre-safe-import-module hook 'hook-backports.py' from '/usr/local/lib/python3.13/dist-packages/PyInstaller/hooks/pre_safe_import_module'
|
||||
15620 INFO: Setuptools: 'backports' appears to be a full setuptools-vendored copy - creating alias to 'setuptools._vendor.backports'!
|
||||
15843 INFO: Processing pre-safe-import-module hook 'hook-tomli.py' from '/usr/local/lib/python3.13/dist-packages/PyInstaller/hooks/pre_safe_import_module'
|
||||
15843 INFO: Setuptools: 'tomli' appears to be a setuptools-vendored copy - creating alias to 'setuptools._vendor.tomli'!
|
||||
16138 INFO: Processing standard module hook 'hook-pkg_resources.py' from '/usr/local/lib/python3.13/dist-packages/PyInstaller/hooks'
|
||||
16316 INFO: Processing pre-safe-import-module hook 'hook-wheel.py' from '/usr/local/lib/python3.13/dist-packages/PyInstaller/hooks/pre_safe_import_module'
|
||||
16376 INFO: Processing standard module hook 'hook-setuptools._vendor.importlib_metadata.py' from '/usr/local/lib/python3.13/dist-packages/PyInstaller/hooks'
|
||||
16378 INFO: Processing pre-safe-import-module hook 'hook-zipp.py' from '/usr/local/lib/python3.13/dist-packages/PyInstaller/hooks/pre_safe_import_module'
|
||||
16414 INFO: Analyzing hidden import 'ui.handlers'
|
||||
16414 INFO: Analyzing hidden import 'ui.services'
|
||||
16415 INFO: Analyzing hidden import 'ui.services.config_manager'
|
||||
16416 INFO: Analyzing hidden import 'ui.services.config_service'
|
||||
16417 INFO: Processing module hooks (post-graph stage)...
|
||||
16716 WARNING: Hidden import "charset_normalizer.md__mypyc" not found!
|
||||
18203 INFO: Performing binary vs. data reclassification (622 entries)
|
||||
18209 INFO: Looking for ctypes DLLs
|
||||
18283 WARNING: Library shell32 required via ctypes not found
|
||||
18292 WARNING: Library ole32 required via ctypes not found
|
||||
18321 INFO: Analyzing run-time hooks ...
|
||||
18329 INFO: Including run-time hook 'pyi_rth_inspect.py' from '/usr/local/lib/python3.13/dist-packages/PyInstaller/hooks/rthooks'
|
||||
18331 INFO: Including run-time hook 'pyi_rth_multiprocessing.py' from '/usr/local/lib/python3.13/dist-packages/PyInstaller/hooks/rthooks'
|
||||
18333 INFO: Including run-time hook 'pyi_rth_pkgutil.py' from '/usr/local/lib/python3.13/dist-packages/PyInstaller/hooks/rthooks'
|
||||
18334 INFO: Including run-time hook 'pyi_rth_setuptools.py' from '/usr/local/lib/python3.13/dist-packages/PyInstaller/hooks/rthooks'
|
||||
18335 INFO: Including run-time hook 'pyi_rth_pkgres.py' from '/usr/local/lib/python3.13/dist-packages/PyInstaller/hooks/rthooks'
|
||||
18371 INFO: Creating base_library.zip...
|
||||
18384 INFO: Looking for dynamic libraries
|
||||
18673 INFO: Warnings written to /home/program/TrulyMEM-TrueHumanMEM/build/trulymem/warn-trulymem.txt
|
||||
18783 INFO: Graph cross-reference written to /home/program/TrulyMEM-TrueHumanMEM/build/trulymem/xref-trulymem.html
|
||||
18816 INFO: checking PYZ
|
||||
18816 INFO: Building PYZ because PYZ-00.toc is non existent
|
||||
18816 INFO: Building PYZ (ZlibArchive) /home/program/TrulyMEM-TrueHumanMEM/build/trulymem/PYZ-00.pyz
|
||||
19768 INFO: Building PYZ (ZlibArchive) /home/program/TrulyMEM-TrueHumanMEM/build/trulymem/PYZ-00.pyz completed successfully.
|
||||
19790 WARNING: Ignoring icon; supported only on Windows and macOS!
|
||||
19801 INFO: checking PKG
|
||||
19801 INFO: Building PKG because PKG-00.toc is non existent
|
||||
19801 INFO: Building PKG (CArchive) TrulyMEM.pkg
|
||||
24763 INFO: Building PKG (CArchive) TrulyMEM.pkg completed successfully.
|
||||
24768 INFO: Bootloader /usr/local/lib/python3.13/dist-packages/PyInstaller/bootloader/Linux-64bit-intel/run
|
||||
24768 INFO: checking EXE
|
||||
24768 INFO: Building EXE because EXE-00.toc is non existent
|
||||
24768 INFO: Building EXE from EXE-00.toc
|
||||
24768 INFO: Copying bootloader EXE to /home/program/TrulyMEM-TrueHumanMEM/dist/TrulyMEM
|
||||
24768 INFO: Appending PKG archive to custom ELF section in EXE
|
||||
24825 INFO: Building EXE from EXE-00.toc completed successfully.
|
||||
24830 INFO: Build complete! The results are available in: /home/program/TrulyMEM-TrueHumanMEM/dist
|
||||
================================
|
||||
===== Build Complete =====
|
||||
Binary: dist/TrulyMEM
|
||||
total 35848
|
||||
drwxr-xr-x 2 root root 4096 Apr 30 07:06 .
|
||||
drwxr-xr-x 15 root root 4096 Apr 30 07:05 ..
|
||||
-rwxr-xr-x 1 root root 36698096 Apr 30 07:06 TrulyMEM
|
||||
Build finished successfully!
|
||||
32
code-linter.json5
Normal file
32
code-linter.json5
Normal file
@ -0,0 +1,32 @@
|
||||
{
|
||||
"files": [
|
||||
"**/*.ets"
|
||||
],
|
||||
"ignore": [
|
||||
"**/src/ohosTest/**/*",
|
||||
"**/src/test/**/*",
|
||||
"**/src/mock/**/*",
|
||||
"**/node_modules/**/*",
|
||||
"**/oh_modules/**/*",
|
||||
"**/build/**/*",
|
||||
"**/.preview/**/*"
|
||||
],
|
||||
"ruleSet": [
|
||||
"plugin:@performance/recommended",
|
||||
"plugin:@typescript-eslint/recommended"
|
||||
],
|
||||
"rules": {
|
||||
"@security/no-unsafe-aes": "error",
|
||||
"@security/no-unsafe-hash": "error",
|
||||
"@security/no-unsafe-mac": "warn",
|
||||
"@security/no-unsafe-dh": "error",
|
||||
"@security/no-unsafe-dsa": "error",
|
||||
"@security/no-unsafe-ecdsa": "error",
|
||||
"@security/no-unsafe-rsa-encrypt": "error",
|
||||
"@security/no-unsafe-rsa-sign": "error",
|
||||
"@security/no-unsafe-rsa-key": "error",
|
||||
"@security/no-unsafe-dsa-key": "error",
|
||||
"@security/no-unsafe-dh-key": "error",
|
||||
"@security/no-unsafe-3des": "error"
|
||||
}
|
||||
}
|
||||
17
common/BuildProfile.ets
Normal file
17
common/BuildProfile.ets
Normal file
@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Use these variables when you tailor your ArkTS code. They must be of the const type.
|
||||
*/
|
||||
export const HAR_VERSION = '1.0.0';
|
||||
export const BUILD_MODE_NAME = 'debug';
|
||||
export const DEBUG = true;
|
||||
export const TARGET_NAME = 'default';
|
||||
|
||||
/**
|
||||
* BuildProfile Class is used only for compatibility purposes.
|
||||
*/
|
||||
export default class BuildProfile {
|
||||
static readonly HAR_VERSION = HAR_VERSION;
|
||||
static readonly BUILD_MODE_NAME = BUILD_MODE_NAME;
|
||||
static readonly DEBUG = DEBUG;
|
||||
static readonly TARGET_NAME = TARGET_NAME;
|
||||
}
|
||||
24
common/Index.ets
Normal file
24
common/Index.ets
Normal file
@ -0,0 +1,24 @@
|
||||
// ========= Utility Layer =========
|
||||
export { defaultLogger } from './src/main/ets/util/Logger';
|
||||
export { defaultLogger as Logger } from './src/main/ets/util/Logger';
|
||||
|
||||
export { BreakpointType, BreakpointTypes, WidthBreakpoint } from "./src/main/ets/util/BreakpointSystem";
|
||||
|
||||
// ========= Router =========
|
||||
export { PageContext, RouterParam, IPageContext } from "./src/main/ets/routermanager/PageContext";
|
||||
|
||||
// ========= Constants =========
|
||||
export { Constants as TrulyMEMConstants } from "./src/main/ets/constant/TrulyMEMConstants";
|
||||
|
||||
// ========= Model Layer =========
|
||||
export { GraphDatabase, RecallEntity, TimeRangeParams } from "./src/main/ets/model/GraphDatabase";
|
||||
|
||||
// ========= Service Layer =========
|
||||
export { GraphMemoryService, ConnectionItem, NodeDetailInfo } from "./src/main/ets/service/GraphMemoryService";
|
||||
export { AIAgentService, ChatMessage, AgentResponse } from "./src/main/ets/service/AIAgentService";
|
||||
|
||||
// ========= ViewModel Layer =========
|
||||
export { BaseViewModel, VMEvent } from "./src/main/ets/viewmodel/BaseViewModel";
|
||||
|
||||
// ========= Component Layer =========
|
||||
export { ImmersiveTabNavigation } from "./src/main/ets/component/ImmersiveTabNavigation";
|
||||
8
common/build-profile.json5
Normal file
8
common/build-profile.json5
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"apiType": "stageMode",
|
||||
"targets": [
|
||||
{
|
||||
"name": "default"
|
||||
}
|
||||
]
|
||||
}
|
||||
1
common/common
Symbolic link
1
common/common
Symbolic link
@ -0,0 +1 @@
|
||||
/home/program/TrulyMEM-TrueHumanMEM/common
|
||||
6
common/hvigorfile.ts
Normal file
6
common/hvigorfile.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { harTasks } from '@ohos/hvigor-ohos-plugin';
|
||||
|
||||
export default {
|
||||
system: harTasks,
|
||||
plugins: []
|
||||
};
|
||||
9
common/oh-package.json5
Normal file
9
common/oh-package.json5
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "@ohos/common",
|
||||
"version": "1.0.0",
|
||||
"description": "TrulyMEM common module",
|
||||
"main": "Index.ets",
|
||||
"author": "",
|
||||
"license": "",
|
||||
"dependencies": {}
|
||||
}
|
||||
133
common/src/main/ets/component/ImmersiveTabNavigation.ets
Normal file
133
common/src/main/ets/component/ImmersiveTabNavigation.ets
Normal file
@ -0,0 +1,133 @@
|
||||
import { defaultLogger } from '../util/Logger';
|
||||
import { window } from '@kit.ArkUI';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
|
||||
const THEME_COLOR = '#7C4DFF';
|
||||
|
||||
@Component
|
||||
export struct ImmersiveTabNavigation {
|
||||
@State currentIndex: number = 0;
|
||||
@BuilderParam contentBuilder: () => void;
|
||||
onTabChange?: (index: number) => void;
|
||||
|
||||
private windowFocused: boolean = true;
|
||||
private bottomAvoidHeight: number = 0;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@Builder
|
||||
tabBarBuilder(index: number, icon: string, label: string) {
|
||||
Column() {
|
||||
if (this.currentIndex === index && this.windowFocused) {
|
||||
Circle()
|
||||
.width(32)
|
||||
.height(32)
|
||||
.backgroundColor(`${THEME_COLOR}33`)
|
||||
.blur(8)
|
||||
.position({ x: '50%', y: '50%' })
|
||||
.translate({ x: '-50%', y: '-50%' })
|
||||
}
|
||||
|
||||
Text(icon)
|
||||
.fontSize(20)
|
||||
.opacity(this.currentIndex === index ? 1 : 0.5)
|
||||
|
||||
Text(label)
|
||||
.fontSize(10)
|
||||
.fontColor(this.currentIndex === index ? THEME_COLOR : '#999')
|
||||
.fontWeight(this.currentIndex === index ? FontWeight.Bold : FontWeight.Normal)
|
||||
}
|
||||
.width('100%')
|
||||
.height(56)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
}
|
||||
|
||||
build() {
|
||||
Stack() {
|
||||
Column() {
|
||||
this.contentBuilder()
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
|
||||
Column() {
|
||||
Stack() {
|
||||
Column()
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundBlurStyle(BlurStyle.Regular)
|
||||
.borderRadius(24)
|
||||
|
||||
Column()
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor(`${THEME_COLOR}0D`)
|
||||
.borderRadius(24)
|
||||
|
||||
Column()
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.linearGradient({
|
||||
angle: 180,
|
||||
colors: [['rgba(255,255,255,0.15)', 0.0], ['rgba(255,255,255,0.05)', 1.0]]
|
||||
})
|
||||
.borderRadius(24)
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
|
||||
Tabs({ index: this.currentIndex }) {
|
||||
TabContent() {
|
||||
Column() {
|
||||
Blank()
|
||||
}
|
||||
}
|
||||
.tabBar(this.tabBarBuilder(0, '🌌', 'TrulyMEM'))
|
||||
|
||||
TabContent() {
|
||||
Column() {
|
||||
Blank()
|
||||
}
|
||||
}
|
||||
.tabBar(this.tabBarBuilder(1, '⚙', '设置'))
|
||||
}
|
||||
.width('100%')
|
||||
.height(64)
|
||||
.barPosition(BarPosition.End)
|
||||
.onChange((index: number) => {
|
||||
this.triggerTabSwitchFeedback(index);
|
||||
})
|
||||
}
|
||||
.width('92%')
|
||||
.height(72)
|
||||
.alignSelf(ItemAlign.Center)
|
||||
.position({ y: `calc(100% - ${this.bottomAvoidHeight > 0 ? this.bottomAvoidHeight : 16}px - 72px)` })
|
||||
.borderRadius(24)
|
||||
.shadow({
|
||||
radius: 20,
|
||||
offsetY: -4,
|
||||
color: 'rgba(0,0,0,0.15)'
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor('#00000000')
|
||||
}
|
||||
}
|
||||
44
common/src/main/ets/constant/TrulyMEMConstants.ets
Normal file
44
common/src/main/ets/constant/TrulyMEMConstants.ets
Normal file
@ -0,0 +1,44 @@
|
||||
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'))
|
||||
)`;
|
||||
}
|
||||
1084
common/src/main/ets/model/GraphDatabase.ets
Normal file
1084
common/src/main/ets/model/GraphDatabase.ets
Normal file
File diff suppressed because it is too large
Load Diff
936
common/src/main/ets/model/GraphDatabase.ets.bak
Normal file
936
common/src/main/ets/model/GraphDatabase.ets.bak
Normal file
@ -0,0 +1,936 @@
|
||||
import relationalStore from '@ohos.data.relationalStore';
|
||||
import { Context } from '@ohos.abilityAccessCtrl';
|
||||
|
||||
interface NodeNameCacheItem { name: string; type: string; mentions: number; }
|
||||
export interface TimeRangeParams { days: number; }
|
||||
|
||||
interface TripletData {
|
||||
subject: string;
|
||||
relation: string;
|
||||
object: string;
|
||||
}
|
||||
|
||||
interface CriteriaData {
|
||||
subject?: string;
|
||||
target?: string;
|
||||
relation?: string;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
interface NodeData {
|
||||
id: number;
|
||||
label: string;
|
||||
type: string;
|
||||
mentions: number;
|
||||
depth?: number;
|
||||
}
|
||||
|
||||
interface EdgeData {
|
||||
from: number;
|
||||
to: number;
|
||||
label: string;
|
||||
weight: number;
|
||||
depth?: number;
|
||||
sessionId?: string;
|
||||
turnId?: number;
|
||||
}
|
||||
|
||||
interface GraphData {
|
||||
nodes: NodeData[];
|
||||
edges: EdgeData[];
|
||||
}
|
||||
|
||||
export interface RecallEntity {
|
||||
name: string;
|
||||
type: string;
|
||||
mention_count: number;
|
||||
depth?: number;
|
||||
}
|
||||
|
||||
interface RecallRelation {
|
||||
source: string;
|
||||
target: string;
|
||||
type: string;
|
||||
confidence: number;
|
||||
session_id?: string;
|
||||
turn_id?: number;
|
||||
depth?: number;
|
||||
}
|
||||
|
||||
interface RecallResult {
|
||||
entities: RecallEntity[];
|
||||
relations: RecallRelation[];
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface BfsEntity {
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
mentions: number;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
export interface RelationQueryResult {
|
||||
sourceId: number;
|
||||
targetId: number;
|
||||
sourceName: string;
|
||||
targetName: string;
|
||||
type: string;
|
||||
confidence: number;
|
||||
sessionId?: string;
|
||||
turnId?: number;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
interface NodeQueryResult {
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
mentions: number;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
interface CleanupResult {
|
||||
cleaned: number;
|
||||
deleted_relations?: number;
|
||||
deleted_orphans?: number;
|
||||
dry_run?: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface IntrospectResult {
|
||||
entity_count: number;
|
||||
relation_count: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface ArchiveResult {
|
||||
archived: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface SearchResultItem {
|
||||
name: string;
|
||||
type: string;
|
||||
mentions: number;
|
||||
}
|
||||
|
||||
interface ChatMessage {
|
||||
role: string;
|
||||
content: string;
|
||||
session_id?: string;
|
||||
}
|
||||
|
||||
interface SnapshotData {
|
||||
entities: RecallEntity[];
|
||||
relations: RecallRelation[];
|
||||
}
|
||||
|
||||
const STORE_CONFIG: relationalStore.StoreConfig = {
|
||||
name: 'trulymem.db',
|
||||
securityLevel: relationalStore.SecurityLevel.S1
|
||||
};
|
||||
|
||||
export class GraphDatabase {
|
||||
private store?: relationalStore.RdbStore;
|
||||
private context?: Context;
|
||||
|
||||
async init(context: Context): Promise<void> {
|
||||
this.context = context;
|
||||
this.store = await relationalStore.getRdbStore(context, STORE_CONFIG);
|
||||
await this.createTables();
|
||||
}
|
||||
|
||||
private async createTables(): Promise<void> {
|
||||
if (!this.store) return;
|
||||
await this.store.executeSql(`
|
||||
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,
|
||||
updated_at TEXT
|
||||
)
|
||||
`);
|
||||
await this.store.executeSql(`
|
||||
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,
|
||||
session_id TEXT,
|
||||
turn_id INTEGER,
|
||||
created_at TEXT,
|
||||
updated_at TEXT,
|
||||
status TEXT DEFAULT 'active',
|
||||
date_bucket TEXT
|
||||
)
|
||||
`);
|
||||
await this.store.executeSql(`
|
||||
CREATE TABLE IF NOT EXISTS chat_records (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
tools TEXT,
|
||||
created_at TEXT
|
||||
)
|
||||
`);
|
||||
await this.store.executeSql(`CREATE INDEX IF NOT EXISTS idx_node_name ON nodes(name)`);
|
||||
await this.store.executeSql(`CREATE INDEX IF NOT EXISTS idx_node_type ON nodes(type)`);
|
||||
await this.store.executeSql(`CREATE INDEX IF NOT EXISTS idx_rel_source ON relations(subject_id)`);
|
||||
await this.store.executeSql(`CREATE INDEX IF NOT EXISTS idx_rel_target ON relations(object_id)`);
|
||||
await this.store.executeSql(`CREATE INDEX IF NOT EXISTS idx_rel_type ON relations(relation)`);
|
||||
await this.store.executeSql(`CREATE INDEX IF NOT EXISTS idx_rel_status ON relations(status)`);
|
||||
}
|
||||
|
||||
async commit(triplets: TripletData[], entityTypes?: Record<string, string>, sessionId?: string, turnId?: number): Promise<void> {
|
||||
if (!this.store) return;
|
||||
for (const triplet of triplets) {
|
||||
const subjectId: number = await this.upsertNode(triplet.subject, entityTypes?.[triplet.subject]);
|
||||
const objectId: number = await this.upsertNode(triplet.object, entityTypes?.[triplet.object]);
|
||||
const existingId: number = await this.checkDuplicateRelation(subjectId, triplet.relation, objectId);
|
||||
if (existingId > 0) {
|
||||
continue;
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
const dateBucket = now.split('T')[0];
|
||||
const bucket: relationalStore.ValuesBucket = {
|
||||
'subject_id': subjectId,
|
||||
'relation': triplet.relation,
|
||||
'object_id': objectId,
|
||||
'session_id': sessionId || null,
|
||||
'turn_id': turnId || null,
|
||||
'created_at': now,
|
||||
'updated_at': now,
|
||||
'status': 'active',
|
||||
'date_bucket': dateBucket
|
||||
};
|
||||
await this.store.insert('relations', bucket);
|
||||
}
|
||||
}
|
||||
|
||||
private async checkDuplicateRelation(subjectId: number, relation: string, objectId: number): Promise<number> {
|
||||
if (!this.store) return -1;
|
||||
const predicates: relationalStore.RdbPredicates = new relationalStore.RdbPredicates('relations');
|
||||
predicates.equalTo('subject_id', subjectId).and().equalTo('relation', relation).and().equalTo('object_id', objectId).and().equalTo('status', 'active');
|
||||
const resultSet: relationalStore.ResultSet = await this.store.query(predicates, ['id']);
|
||||
if (resultSet.goToFirstRow()) {
|
||||
const id: number = resultSet.getLong(resultSet.getColumnIndex('id'));
|
||||
resultSet.close();
|
||||
return id;
|
||||
}
|
||||
resultSet.close();
|
||||
return -1;
|
||||
}
|
||||
|
||||
private async upsertNode(name: string, entityType?: string): Promise<number> {
|
||||
if (!this.store) return -1;
|
||||
const predicates: relationalStore.RdbPredicates = new relationalStore.RdbPredicates('nodes');
|
||||
predicates.equalTo('name', name);
|
||||
const resultSet: relationalStore.ResultSet = await this.store.query(predicates, ['id', 'mentions']);
|
||||
const now = new Date().toISOString();
|
||||
if (resultSet.goToNextRow()) {
|
||||
const id: number = resultSet.getLong(resultSet.getColumnIndex('id'));
|
||||
const mentions: number = resultSet.getLong(resultSet.getColumnIndex('mentions'));
|
||||
resultSet.close();
|
||||
const updatePredicates: relationalStore.RdbPredicates = new relationalStore.RdbPredicates('nodes');
|
||||
updatePredicates.equalTo('id', id);
|
||||
const bucket: relationalStore.ValuesBucket = {
|
||||
'mentions': mentions + 1,
|
||||
'updated_at': now
|
||||
};
|
||||
await this.store.update(bucket, updatePredicates);
|
||||
return id;
|
||||
}
|
||||
resultSet.close();
|
||||
const bucket: relationalStore.ValuesBucket = {
|
||||
'name': name,
|
||||
'type': entityType || 'concept',
|
||||
'mentions': 1,
|
||||
'created_at': now,
|
||||
'updated_at': now
|
||||
};
|
||||
return await this.store.insert('nodes', bucket);
|
||||
}
|
||||
|
||||
async recall(queryIntent: string, seedEntities?: string[], depth: number = 2, timeRange?: TimeRangeParams, sessionFilter?: string): Promise<RecallResult> {
|
||||
if (!this.store) {
|
||||
return { entities: [], relations: [], message: 'Database not initialized' };
|
||||
}
|
||||
// 计算时间范围过滤
|
||||
let minDateBucket: string | undefined;
|
||||
if (timeRange && timeRange.days && timeRange.days > 0) {
|
||||
const cutoff = new Date();
|
||||
cutoff.setDate(cutoff.getDate() - timeRange.days);
|
||||
minDateBucket = cutoff.toISOString().slice(0, 10).replace(/-/g, '');
|
||||
}
|
||||
const keywords = queryIntent.toLowerCase().replace(/,/g, ' ').split(/\s+/).filter(w => w.trim());
|
||||
const allEntities: BfsEntity[] = [];
|
||||
const entityIds = new Set<number>();
|
||||
let seedEntityIds = new Set<number>();
|
||||
|
||||
if (keywords.length === 0 && (!seedEntities || seedEntities.length === 0)) {
|
||||
const predicates: relationalStore.RdbPredicates = new relationalStore.RdbPredicates('nodes');
|
||||
predicates.orderByDesc('mentions');
|
||||
const resultSet: relationalStore.ResultSet = await this.store.query(predicates, ['id', 'name', 'type', 'mentions']);
|
||||
while (resultSet.goToNextRow() && allEntities.length < 50) {
|
||||
const id = resultSet.getLong(resultSet.getColumnIndex('id'));
|
||||
const name = resultSet.getString(resultSet.getColumnIndex('name'));
|
||||
const type = resultSet.getString(resultSet.getColumnIndex('type'));
|
||||
const mentions = resultSet.getLong(resultSet.getColumnIndex('mentions'));
|
||||
entityIds.add(id);
|
||||
allEntities.push({ id, name, type, mentions, depth: 0 });
|
||||
}
|
||||
resultSet.close();
|
||||
} else {
|
||||
if (seedEntities && seedEntities.length > 0) {
|
||||
for (const seedName of seedEntities) {
|
||||
const predicates: relationalStore.RdbPredicates = new relationalStore.RdbPredicates('nodes');
|
||||
predicates.equalTo('name', seedName);
|
||||
const resultSet: relationalStore.ResultSet = await this.store.query(predicates, ['id', 'name', 'type', 'mentions']);
|
||||
while (resultSet.goToNextRow()) {
|
||||
const id = resultSet.getLong(resultSet.getColumnIndex('id'));
|
||||
if (!entityIds.has(id)) {
|
||||
entityIds.add(id);
|
||||
seedEntityIds.add(id);
|
||||
allEntities.push({
|
||||
id,
|
||||
name: resultSet.getString(resultSet.getColumnIndex('name')),
|
||||
type: resultSet.getString(resultSet.getColumnIndex('type')),
|
||||
mentions: resultSet.getLong(resultSet.getColumnIndex('mentions')),
|
||||
depth: 0
|
||||
});
|
||||
}
|
||||
}
|
||||
resultSet.close();
|
||||
}
|
||||
}
|
||||
for (const keyword of keywords) {
|
||||
const predicates: relationalStore.RdbPredicates = new relationalStore.RdbPredicates('nodes');
|
||||
predicates.like('name', `%${keyword}%`);
|
||||
const resultSet: relationalStore.ResultSet = await this.store.query(predicates, ['id', 'name', 'type', 'mentions']);
|
||||
while (resultSet.goToNextRow()) {
|
||||
const id = resultSet.getLong(resultSet.getColumnIndex('id'));
|
||||
if (!entityIds.has(id)) {
|
||||
entityIds.add(id);
|
||||
allEntities.push({
|
||||
id,
|
||||
name: resultSet.getString(resultSet.getColumnIndex('name')),
|
||||
type: resultSet.getString(resultSet.getColumnIndex('type')),
|
||||
mentions: resultSet.getLong(resultSet.getColumnIndex('mentions')),
|
||||
depth: 0
|
||||
});
|
||||
}
|
||||
}
|
||||
resultSet.close();
|
||||
}
|
||||
}
|
||||
|
||||
const allRelations: RelationQueryResult[] = [];
|
||||
let currentLayerIds = new Set<number>(entityIds);
|
||||
const visitedEntityIds = new Set<number>(entityIds);
|
||||
// 批量预加载所有相关节点名称,减少 N+1 查询
|
||||
const nodeNameCache = new Map<number, NodeNameCacheItem>();
|
||||
|
||||
for (let layer = 0; layer < depth && currentLayerIds.size > 0; layer++) {
|
||||
const currentIds = Array.from(currentLayerIds);
|
||||
const relations = await this.getRelationsForNodes(currentIds, sessionFilter, minDateBucket);
|
||||
const nextLayerIds = new Set<number>();
|
||||
|
||||
for (const rel of relations) {
|
||||
allRelations.push(rel);
|
||||
if (!visitedEntityIds.has(rel.targetId)) {
|
||||
nextLayerIds.add(rel.targetId);
|
||||
}
|
||||
if (rel.targetId !== rel.sourceId && !visitedEntityIds.has(rel.sourceId)) {
|
||||
nextLayerIds.add(rel.sourceId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const newId of nextLayerIds) {
|
||||
if (!visitedEntityIds.has(newId)) {
|
||||
visitedEntityIds.add(newId);
|
||||
// 优先从缓存获取,避免 N+1 查询
|
||||
const cached = nodeNameCache.get(newId);
|
||||
if (cached) {
|
||||
const addedEntity: BfsEntity = {
|
||||
id: newId,
|
||||
name: cached.name,
|
||||
type: cached.type,
|
||||
mentions: cached.mentions,
|
||||
depth: layer + 1
|
||||
};
|
||||
allEntities.push(addedEntity);
|
||||
} else {
|
||||
const nodeData = await this.getNodeById(newId);
|
||||
if (nodeData) {
|
||||
const cacheItem: NodeNameCacheItem = { name: nodeData.name, type: nodeData.type, mentions: nodeData.mentions };
|
||||
nodeNameCache.set(newId, cacheItem);
|
||||
const addedEntity: BfsEntity = {
|
||||
id: nodeData.id,
|
||||
name: nodeData.name,
|
||||
type: nodeData.type,
|
||||
mentions: nodeData.mentions,
|
||||
depth: layer + 1
|
||||
};
|
||||
allEntities.push(addedEntity);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
currentLayerIds = nextLayerIds;
|
||||
}
|
||||
|
||||
const entities: RecallEntity[] = allEntities.map(e => {
|
||||
const entity: RecallEntity = {
|
||||
name: e.name,
|
||||
type: e.type,
|
||||
mention_count: e.mentions,
|
||||
depth: e.depth
|
||||
};
|
||||
return entity;
|
||||
});
|
||||
const relations: RecallRelation[] = allRelations.map(r => {
|
||||
const rel: RecallRelation = {
|
||||
source: r.sourceName,
|
||||
target: r.targetName,
|
||||
type: r.type,
|
||||
confidence: r.confidence,
|
||||
session_id: r.sessionId,
|
||||
turn_id: r.turnId,
|
||||
depth: r.depth
|
||||
};
|
||||
return rel;
|
||||
});
|
||||
|
||||
return {
|
||||
entities,
|
||||
relations,
|
||||
message: `找到 ${entities.length} 个实体, ${relations.length} 条关系`
|
||||
};
|
||||
}
|
||||
|
||||
private async getNodeById(id: number): Promise<NodeQueryResult | null> {
|
||||
if (!this.store) return null;
|
||||
const predicates: relationalStore.RdbPredicates = new relationalStore.RdbPredicates('nodes');
|
||||
predicates.equalTo('id', id);
|
||||
const resultSet: relationalStore.ResultSet = await this.store.query(predicates, ['id', 'name', 'type', 'mentions']);
|
||||
if (resultSet.goToNextRow()) {
|
||||
const node: NodeQueryResult = {
|
||||
id: resultSet.getLong(resultSet.getColumnIndex('id')),
|
||||
name: resultSet.getString(resultSet.getColumnIndex('name')),
|
||||
type: resultSet.getString(resultSet.getColumnIndex('type')),
|
||||
mentions: resultSet.getLong(resultSet.getColumnIndex('mentions')),
|
||||
depth: 0
|
||||
};
|
||||
resultSet.close();
|
||||
return node;
|
||||
}
|
||||
resultSet.close();
|
||||
return null;
|
||||
}
|
||||
|
||||
private async getRelationsForNodes(nodeIds: number[], sessionFilter?: string, minDateBucket?: string): Promise<RelationQueryResult[]> {
|
||||
if (!this.store || nodeIds.length === 0) return [];
|
||||
const relations: RelationQueryResult[] = [];
|
||||
// 批量预加载所有节点名称到缓存,避免 N+1 查询
|
||||
const nodeNameCache = new Map<number, NodeNameCacheItem>();
|
||||
for (const id of nodeIds) {
|
||||
const node = await this.getNodeById(id);
|
||||
if (node) {
|
||||
const cacheItem: NodeNameCacheItem = { name: node.name, type: node.type, mentions: node.mentions };
|
||||
nodeNameCache.set(id, cacheItem);
|
||||
}
|
||||
}
|
||||
for (const nodeId of nodeIds) {
|
||||
const predicates: relationalStore.RdbPredicates = new relationalStore.RdbPredicates('relations');
|
||||
predicates.equalTo('status', 'active').and().equalTo('subject_id', nodeId);
|
||||
if (sessionFilter) {
|
||||
predicates.and().equalTo('session_id', sessionFilter);
|
||||
}
|
||||
if (minDateBucket) {
|
||||
predicates.and().greaterThanOrEqualTo('date_bucket', minDateBucket);
|
||||
}
|
||||
const resultSet: relationalStore.ResultSet = await this.store.query(predicates, ['subject_id', 'object_id', 'relation', 'weight', 'session_id', 'turn_id']);
|
||||
while (resultSet.goToNextRow()) {
|
||||
const sourceId = resultSet.getLong(resultSet.getColumnIndex('subject_id'));
|
||||
const targetId = resultSet.getLong(resultSet.getColumnIndex('object_id'));
|
||||
const sourceNode = nodeNameCache.get(sourceId) || await this.getNodeById(sourceId);
|
||||
const targetNode = nodeNameCache.get(targetId) || await this.getNodeById(targetId);
|
||||
if (sourceNode && targetNode) {
|
||||
relations.push({
|
||||
sourceId,
|
||||
targetId,
|
||||
sourceName: sourceNode.name,
|
||||
targetName: targetNode.name,
|
||||
type: resultSet.getString(resultSet.getColumnIndex('relation')),
|
||||
confidence: resultSet.getDouble(resultSet.getColumnIndex('weight')),
|
||||
sessionId: resultSet.getString(resultSet.getColumnIndex('session_id')),
|
||||
turnId: resultSet.getLong(resultSet.getColumnIndex('turn_id')),
|
||||
depth: 1
|
||||
});
|
||||
}
|
||||
}
|
||||
resultSet.close();
|
||||
|
||||
const predicates2: relationalStore.RdbPredicates = new relationalStore.RdbPredicates('relations');
|
||||
predicates2.equalTo('status', 'active').and().equalTo('object_id', nodeId);
|
||||
if (sessionFilter) {
|
||||
predicates2.and().equalTo('session_id', sessionFilter);
|
||||
}
|
||||
if (minDateBucket) {
|
||||
predicates2.and().greaterThanOrEqualTo('date_bucket', minDateBucket);
|
||||
}
|
||||
const resultSet2: relationalStore.ResultSet = await this.store.query(predicates2, ['subject_id', 'object_id', 'relation', 'weight', 'session_id', 'turn_id']);
|
||||
while (resultSet2.goToNextRow()) {
|
||||
const sourceId = resultSet2.getLong(resultSet2.getColumnIndex('subject_id'));
|
||||
const targetId = resultSet2.getLong(resultSet2.getColumnIndex('object_id'));
|
||||
const sourceNode = nodeNameCache.get(sourceId) || await this.getNodeById(sourceId);
|
||||
const targetNode = nodeNameCache.get(targetId) || await this.getNodeById(targetId);
|
||||
if (sourceNode && targetNode) {
|
||||
relations.push({
|
||||
sourceId,
|
||||
targetId,
|
||||
sourceName: sourceNode.name,
|
||||
targetName: targetNode.name,
|
||||
type: resultSet2.getString(resultSet2.getColumnIndex('relation')),
|
||||
confidence: resultSet2.getDouble(resultSet2.getColumnIndex('weight')),
|
||||
sessionId: resultSet2.getString(resultSet2.getColumnIndex('session_id')),
|
||||
turnId: resultSet2.getLong(resultSet2.getColumnIndex('turn_id')),
|
||||
depth: 1
|
||||
});
|
||||
}
|
||||
}
|
||||
resultSet2.close();
|
||||
}
|
||||
return relations;
|
||||
}
|
||||
|
||||
async search(keyword: string): Promise<SearchResultItem[]> {
|
||||
if (!this.store) return [];
|
||||
const predicates: relationalStore.RdbPredicates = new relationalStore.RdbPredicates('nodes');
|
||||
predicates.like('name', `%${keyword}%`);
|
||||
const resultSet: relationalStore.ResultSet = await this.store.query(predicates, ['name', 'type', 'mentions']);
|
||||
const results: SearchResultItem[] = [];
|
||||
while (resultSet.goToNextRow()) {
|
||||
results.push({
|
||||
name: resultSet.getString(resultSet.getColumnIndex('name')),
|
||||
type: resultSet.getString(resultSet.getColumnIndex('type')),
|
||||
mentions: resultSet.getLong(resultSet.getColumnIndex('mentions'))
|
||||
});
|
||||
}
|
||||
resultSet.close();
|
||||
return results;
|
||||
}
|
||||
|
||||
async purge(criteria: CriteriaData, mode: string = 'soft'): Promise<void> {
|
||||
if (!this.store) return;
|
||||
if (!criteria.subject && !criteria.target && !criteria.relation && !criteria.sessionId) {
|
||||
return;
|
||||
}
|
||||
const predicates: relationalStore.RdbPredicates = new relationalStore.RdbPredicates('relations');
|
||||
let hasCondition = false;
|
||||
|
||||
if (criteria.subject) {
|
||||
const subjectId = await this.getNodeIdByName(criteria.subject);
|
||||
if (subjectId > 0) {
|
||||
predicates.equalTo('subject_id', subjectId);
|
||||
hasCondition = true;
|
||||
}
|
||||
}
|
||||
if (criteria.target) {
|
||||
const targetId = await this.getNodeIdByName(criteria.target);
|
||||
if (targetId > 0) {
|
||||
if (hasCondition) {
|
||||
predicates.and();
|
||||
}
|
||||
predicates.equalTo('object_id', targetId);
|
||||
hasCondition = true;
|
||||
}
|
||||
}
|
||||
if (criteria.relation) {
|
||||
if (hasCondition) {
|
||||
predicates.and();
|
||||
}
|
||||
predicates.equalTo('relation', criteria.relation);
|
||||
hasCondition = true;
|
||||
}
|
||||
if (criteria.sessionId) {
|
||||
if (hasCondition) {
|
||||
predicates.and();
|
||||
}
|
||||
predicates.equalTo('session_id', criteria.sessionId);
|
||||
hasCondition = true;
|
||||
}
|
||||
|
||||
if (hasCondition) {
|
||||
if (mode === 'soft') {
|
||||
const bucket: relationalStore.ValuesBucket = {
|
||||
'status': 'deleted',
|
||||
'updated_at': new Date().toISOString()
|
||||
};
|
||||
await this.store.update(bucket, predicates);
|
||||
} else {
|
||||
await this.store.delete(predicates);
|
||||
}
|
||||
}
|
||||
await this.removeOrphanNodes();
|
||||
}
|
||||
|
||||
/**
|
||||
* 记忆图谱 — 在指定时间范围内查询关系和节点
|
||||
* 对应 tools.memory_graph
|
||||
*/
|
||||
async graph(timeRange: TimeRangeParams, sessionFilter?: string): Promise<GraphData> {
|
||||
if (!this.store) return { nodes: [], edges: [] };
|
||||
const predicates: relationalStore.RdbPredicates = new relationalStore.RdbPredicates('relations');
|
||||
predicates.equalTo('status', 'active');
|
||||
if (sessionFilter) {
|
||||
predicates.and().equalTo('session_id', sessionFilter);
|
||||
}
|
||||
if (timeRange && timeRange.days && timeRange.days > 0) {
|
||||
const cutoff = new Date();
|
||||
cutoff.setDate(cutoff.getDate() - timeRange.days);
|
||||
const minDateBucket = cutoff.toISOString().slice(0, 10).replace(/-/g, '');
|
||||
predicates.and().greaterThanOrEqualTo('date_bucket', minDateBucket);
|
||||
}
|
||||
const resultSet: relationalStore.ResultSet = await this.store.query(predicates, ['subject_id', 'object_id', 'relation', 'weight', 'session_id', 'turn_id']);
|
||||
const nodeIds = new Set<number>();
|
||||
const edges: EdgeData[] = [];
|
||||
while (resultSet.goToNextRow()) {
|
||||
const sourceId = resultSet.getLong(resultSet.getColumnIndex('subject_id'));
|
||||
const targetId = resultSet.getLong(resultSet.getColumnIndex('object_id'));
|
||||
nodeIds.add(sourceId);
|
||||
nodeIds.add(targetId);
|
||||
const edge: EdgeData = {
|
||||
from: sourceId,
|
||||
to: targetId,
|
||||
label: resultSet.getString(resultSet.getColumnIndex('relation')),
|
||||
weight: resultSet.getDouble(resultSet.getColumnIndex('weight')),
|
||||
sessionId: resultSet.getString(resultSet.getColumnIndex('session_id')),
|
||||
turnId: resultSet.getLong(resultSet.getColumnIndex('turn_id'))
|
||||
};
|
||||
edges.push(edge);
|
||||
}
|
||||
resultSet.close();
|
||||
const nodes: NodeData[] = [];
|
||||
for (const id of nodeIds) {
|
||||
const node = await this.getNodeById(id);
|
||||
if (node) {
|
||||
const nodeData: NodeData = { id: node.id, label: node.name, type: node.type, mentions: node.mentions };
|
||||
nodes.push(nodeData);
|
||||
}
|
||||
}
|
||||
const result: GraphData = { nodes, edges };
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 记忆快照 — 在指定时间范围内查询实体和关系
|
||||
* 对应 tools.memory_snapshot
|
||||
*/
|
||||
async snapshot(timeRange: TimeRangeParams, sessionFilter?: string): Promise<SnapshotData> {
|
||||
if (!this.store) return { entities: [], relations: [] };
|
||||
const predicates: relationalStore.RdbPredicates = new relationalStore.RdbPredicates('relations');
|
||||
predicates.equalTo('status', 'active');
|
||||
if (sessionFilter) {
|
||||
predicates.and().equalTo('session_id', sessionFilter);
|
||||
}
|
||||
if (timeRange && timeRange.days && timeRange.days > 0) {
|
||||
const cutoff = new Date();
|
||||
cutoff.setDate(cutoff.getDate() - timeRange.days);
|
||||
const minDateBucket = cutoff.toISOString().slice(0, 10).replace(/-/g, '');
|
||||
predicates.and().greaterThanOrEqualTo('date_bucket', minDateBucket);
|
||||
}
|
||||
const resultSet: relationalStore.ResultSet = await this.store.query(predicates, ['subject_id', 'object_id', 'relation', 'weight', 'session_id', 'turn_id']);
|
||||
const nodeIds = new Set<number>();
|
||||
const relations: RecallRelation[] = [];
|
||||
while (resultSet.goToNextRow()) {
|
||||
const sourceId = resultSet.getLong(resultSet.getColumnIndex('subject_id'));
|
||||
const targetId = resultSet.getLong(resultSet.getColumnIndex('object_id'));
|
||||
nodeIds.add(sourceId);
|
||||
nodeIds.add(targetId);
|
||||
const sourceNode = await this.getNodeById(sourceId);
|
||||
const targetNode = await this.getNodeById(targetId);
|
||||
if (sourceNode && targetNode) {
|
||||
const rel: RecallRelation = {
|
||||
source: sourceNode.name,
|
||||
target: targetNode.name,
|
||||
type: resultSet.getString(resultSet.getColumnIndex('relation')),
|
||||
confidence: resultSet.getDouble(resultSet.getColumnIndex('weight')),
|
||||
session_id: resultSet.getString(resultSet.getColumnIndex('session_id')),
|
||||
turn_id: resultSet.getLong(resultSet.getColumnIndex('turn_id'))
|
||||
};
|
||||
relations.push(rel);
|
||||
}
|
||||
}
|
||||
resultSet.close();
|
||||
const entities: RecallEntity[] = [];
|
||||
for (const id of nodeIds) {
|
||||
const node = await this.getNodeById(id);
|
||||
if (node) {
|
||||
const recallEntity: RecallEntity = { name: node.name, type: node.type, mention_count: node.mentions };
|
||||
entities.push(recallEntity);
|
||||
}
|
||||
}
|
||||
const snapResult: SnapshotData = { entities, relations };
|
||||
return snapResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询已归档的记忆
|
||||
* 对应 tools.memory_query_archived
|
||||
*/
|
||||
async queryArchived(days?: number, keyword?: string): Promise<RelationQueryResult[]> {
|
||||
if (!this.store) return [];
|
||||
const predicates: relationalStore.RdbPredicates = new relationalStore.RdbPredicates('relations');
|
||||
predicates.equalTo('status', 'archived');
|
||||
if (keyword) {
|
||||
predicates.and().like('relation', '%' + keyword + '%');
|
||||
}
|
||||
if (days && days > 0) {
|
||||
const cutoff = new Date();
|
||||
cutoff.setDate(cutoff.getDate() - days);
|
||||
const maxDateBucket = cutoff.toISOString().slice(0, 10).replace(/-/g, '');
|
||||
predicates.and().lessThanOrEqualTo('date_bucket', maxDateBucket);
|
||||
}
|
||||
const resultSet: relationalStore.ResultSet = await this.store.query(predicates, ['subject_id', 'object_id', 'relation', 'weight', 'session_id', 'turn_id']);
|
||||
const results: RelationQueryResult[] = [];
|
||||
while (resultSet.goToNextRow()) {
|
||||
const sourceId = resultSet.getLong(resultSet.getColumnIndex('subject_id'));
|
||||
const targetId = resultSet.getLong(resultSet.getColumnIndex('object_id'));
|
||||
const sourceNode = await this.getNodeById(sourceId);
|
||||
const targetNode = await this.getNodeById(targetId);
|
||||
if (sourceNode && targetNode) {
|
||||
const queryResult: RelationQueryResult = {
|
||||
sourceId: sourceId,
|
||||
targetId: targetId,
|
||||
sourceName: sourceNode.name,
|
||||
targetName: targetNode.name,
|
||||
type: resultSet.getString(resultSet.getColumnIndex('relation')),
|
||||
confidence: resultSet.getDouble(resultSet.getColumnIndex('weight')),
|
||||
sessionId: resultSet.getString(resultSet.getColumnIndex('session_id')),
|
||||
turnId: resultSet.getLong(resultSet.getColumnIndex('turn_id')),
|
||||
depth: 0
|
||||
};
|
||||
results.push(queryResult);
|
||||
}
|
||||
}
|
||||
resultSet.close();
|
||||
return results;
|
||||
}
|
||||
|
||||
private async removeOrphanNodes(): Promise<number> {
|
||||
if (!this.store) return 0;
|
||||
let deleted = 0;
|
||||
// 优化:批量查询所有有关系的节点 ID,避免 O(N²) 逐节点检查
|
||||
const activeRelPred: relationalStore.RdbPredicates = new relationalStore.RdbPredicates('relations');
|
||||
activeRelPred.equalTo('status', 'active');
|
||||
const relResultSet: relationalStore.ResultSet = await this.store.query(activeRelPred, ['subject_id', 'object_id']);
|
||||
const relatedIds = new Set<number>();
|
||||
while (relResultSet.goToNextRow()) {
|
||||
relatedIds.add(relResultSet.getLong(relResultSet.getColumnIndex('subject_id')));
|
||||
relatedIds.add(relResultSet.getLong(relResultSet.getColumnIndex('object_id')));
|
||||
}
|
||||
relResultSet.close();
|
||||
|
||||
// 查询所有节点,筛选出不在关系中的孤儿节点
|
||||
const nodePred: relationalStore.RdbPredicates = new relationalStore.RdbPredicates('nodes');
|
||||
const nodeResultSet: relationalStore.ResultSet = await this.store.query(nodePred, ['id']);
|
||||
const orphanIds: number[] = [];
|
||||
while (nodeResultSet.goToNextRow()) {
|
||||
const nodeId = nodeResultSet.getLong(nodeResultSet.getColumnIndex('id'));
|
||||
if (!relatedIds.has(nodeId)) {
|
||||
orphanIds.push(nodeId);
|
||||
}
|
||||
}
|
||||
nodeResultSet.close();
|
||||
|
||||
// 批量删除孤儿节点
|
||||
for (const orphanId of orphanIds) {
|
||||
const deletePred: relationalStore.RdbPredicates = new relationalStore.RdbPredicates('nodes');
|
||||
deletePred.equalTo('id', orphanId);
|
||||
await this.store.delete(deletePred);
|
||||
deleted++;
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
private async getNodeIdByName(name: string): Promise<number> {
|
||||
if (!this.store) return -1;
|
||||
const predicates: relationalStore.RdbPredicates = new relationalStore.RdbPredicates('nodes');
|
||||
predicates.equalTo('name', name);
|
||||
const resultSet: relationalStore.ResultSet = await this.store.query(predicates, ['id']);
|
||||
if (resultSet.goToNextRow()) {
|
||||
const id = resultSet.getLong(resultSet.getColumnIndex('id'));
|
||||
resultSet.close();
|
||||
return id;
|
||||
}
|
||||
resultSet.close();
|
||||
return -1;
|
||||
}
|
||||
|
||||
async introspect(): Promise<IntrospectResult> {
|
||||
if (!this.store) return { entity_count: 0, relation_count: 0, message: 'Database not initialized' };
|
||||
const nodePredicates: relationalStore.RdbPredicates = new relationalStore.RdbPredicates('nodes');
|
||||
const nodeResultSet = await this.store.query(nodePredicates, ['id']);
|
||||
let entityCount = 0;
|
||||
while (nodeResultSet.goToNextRow()) {
|
||||
entityCount++;
|
||||
}
|
||||
nodeResultSet.close();
|
||||
|
||||
const relPredicates: relationalStore.RdbPredicates = new relationalStore.RdbPredicates('relations');
|
||||
relPredicates.equalTo('status', 'active');
|
||||
const relResultSet = await this.store.query(relPredicates, ['id']);
|
||||
let relationCount = 0;
|
||||
while (relResultSet.goToNextRow()) {
|
||||
relationCount++;
|
||||
}
|
||||
relResultSet.close();
|
||||
|
||||
return {
|
||||
entity_count: entityCount,
|
||||
relation_count: relationCount,
|
||||
message: `数据库包含 ${entityCount} 个实体, ${relationCount} 条关系`
|
||||
};
|
||||
}
|
||||
|
||||
async archive(days: number): Promise<ArchiveResult> {
|
||||
if (!this.store) return { archived: 0, message: 'Database not initialized' };
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setDate(cutoffDate.getDate() - days);
|
||||
const cutoffStr = cutoffDate.toISOString();
|
||||
|
||||
const predicates: relationalStore.RdbPredicates = new relationalStore.RdbPredicates('relations');
|
||||
predicates.equalTo('status', 'active').and().lessThan('created_at', cutoffStr);
|
||||
const bucket: relationalStore.ValuesBucket = {
|
||||
'status': 'archived',
|
||||
'updated_at': new Date().toISOString()
|
||||
};
|
||||
const count = await this.store.update(bucket, predicates);
|
||||
|
||||
return {
|
||||
archived: count,
|
||||
message: `归档了 ${count} 条关系`
|
||||
};
|
||||
}
|
||||
|
||||
async cleanup(dryRun: boolean): Promise<CleanupResult> {
|
||||
if (!this.store) return { cleaned: 0, message: 'Database not initialized' };
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setDate(cutoffDate.getDate() - 90);
|
||||
const cutoffStr = cutoffDate.toISOString();
|
||||
|
||||
const predicates: relationalStore.RdbPredicates = new relationalStore.RdbPredicates('relations');
|
||||
predicates.equalTo('status', 'deleted').and().lessThan('updated_at', cutoffStr);
|
||||
|
||||
let deleted = 0;
|
||||
if (dryRun) {
|
||||
const resultSet = await this.store.query(predicates, ['id']);
|
||||
while (resultSet.goToNextRow()) {
|
||||
deleted++;
|
||||
}
|
||||
resultSet.close();
|
||||
} else {
|
||||
deleted = await this.store.delete(predicates);
|
||||
const orphanCount = await this.removeOrphanNodes();
|
||||
return {
|
||||
cleaned: deleted + orphanCount,
|
||||
deleted_relations: deleted,
|
||||
deleted_orphans: orphanCount,
|
||||
dry_run: false,
|
||||
message: `删除了 ${deleted} 条关系, ${orphanCount} 个孤立实体`
|
||||
} as CleanupResult;
|
||||
}
|
||||
|
||||
return {
|
||||
cleaned: deleted,
|
||||
deleted_relations: deleted,
|
||||
dry_run: true,
|
||||
message: `将删除 ${deleted} 条关系`
|
||||
} as CleanupResult;
|
||||
}
|
||||
|
||||
async saveChatMessage(role: string, content: string, tools?: string, sessionId?: string): Promise<void> {
|
||||
if (!this.store) return;
|
||||
const bucket: relationalStore.ValuesBucket = {
|
||||
'session_id': sessionId || null,
|
||||
'role': role,
|
||||
'content': content,
|
||||
'tools': tools || null,
|
||||
'created_at': new Date().toISOString()
|
||||
};
|
||||
await this.store.insert('chat_records', bucket);
|
||||
}
|
||||
|
||||
async getChatHistory(limit?: number, sessionId?: string): Promise<ChatMessage[]> {
|
||||
if (!this.store) return [];
|
||||
const predicates: relationalStore.RdbPredicates = new relationalStore.RdbPredicates('chat_records');
|
||||
if (sessionId) {
|
||||
predicates.equalTo('session_id', sessionId);
|
||||
}
|
||||
predicates.orderByDesc('created_at');
|
||||
if (limit) {
|
||||
predicates.limitAs(limit);
|
||||
}
|
||||
const resultSet: relationalStore.ResultSet = await this.store.query(predicates, ['role', 'content', 'session_id']);
|
||||
const messages: ChatMessage[] = [];
|
||||
while (resultSet.goToNextRow()) {
|
||||
const msg: ChatMessage = {
|
||||
role: resultSet.getString(resultSet.getColumnIndex('role')),
|
||||
content: resultSet.getString(resultSet.getColumnIndex('content')),
|
||||
session_id: resultSet.getString(resultSet.getColumnIndex('session_id'))
|
||||
};
|
||||
messages.push(msg);
|
||||
}
|
||||
resultSet.close();
|
||||
return messages.reverse();
|
||||
}
|
||||
|
||||
async clearChatHistory(): Promise<void> {
|
||||
if (!this.store) return;
|
||||
const predicates: relationalStore.RdbPredicates = new relationalStore.RdbPredicates('chat_records');
|
||||
await this.store.delete(predicates);
|
||||
}
|
||||
|
||||
private async getAllNodes(): Promise<NodeData[]> {
|
||||
if (!this.store) return [];
|
||||
const predicates: relationalStore.RdbPredicates = new relationalStore.RdbPredicates('nodes');
|
||||
const resultSet: relationalStore.ResultSet = await this.store.query(predicates, ['id', 'name', 'type', 'mentions']);
|
||||
const nodes: NodeData[] = [];
|
||||
while (resultSet.goToNextRow()) {
|
||||
const node: NodeData = {
|
||||
id: resultSet.getLong(resultSet.getColumnIndex('id')),
|
||||
label: resultSet.getString(resultSet.getColumnIndex('name')),
|
||||
type: resultSet.getString(resultSet.getColumnIndex('type')),
|
||||
mentions: resultSet.getLong(resultSet.getColumnIndex('mentions'))
|
||||
};
|
||||
nodes.push(node);
|
||||
}
|
||||
resultSet.close();
|
||||
return nodes;
|
||||
}
|
||||
|
||||
private async getAllEdges(): Promise<EdgeData[]> {
|
||||
if (!this.store) return [];
|
||||
const predicates: relationalStore.RdbPredicates = new relationalStore.RdbPredicates('relations');
|
||||
const resultSet: relationalStore.ResultSet = await this.store.query(predicates, ['subject_id', 'relation', 'object_id', 'weight']);
|
||||
const edges: EdgeData[] = [];
|
||||
while (resultSet.goToNextRow()) {
|
||||
const edge: EdgeData = {
|
||||
from: resultSet.getLong(resultSet.getColumnIndex('subject_id')),
|
||||
to: resultSet.getLong(resultSet.getColumnIndex('object_id')),
|
||||
label: resultSet.getString(resultSet.getColumnIndex('relation')),
|
||||
weight: resultSet.getDouble(resultSet.getColumnIndex('weight'))
|
||||
};
|
||||
edges.push(edge);
|
||||
}
|
||||
resultSet.close();
|
||||
return edges;
|
||||
}
|
||||
}
|
||||
56
common/src/main/ets/routermanager/PageContext.ets
Normal file
56
common/src/main/ets/routermanager/PageContext.ets
Normal file
@ -0,0 +1,56 @@
|
||||
import { defaultLogger } from '../util/Logger';
|
||||
|
||||
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) {
|
||||
defaultLogger.error('replacePage: ' + data.routerName + ' failed. ' + err.code + ' ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
public openPage(data: RouterParam, animated: boolean = true): void {
|
||||
try {
|
||||
this.pathStack.pushPath({ name: data.routerName, param: data.param }, animated);
|
||||
} catch (err) {
|
||||
defaultLogger.error('openPage: ' + data.routerName + ' failed. ' + err.code + ' ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
public popPage(animated: boolean = true): void {
|
||||
try {
|
||||
this.pathStack.pop(animated);
|
||||
} catch (err) {
|
||||
defaultLogger.error('popPage failed. ' + err.code + ' ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
public popPageByIndex(index: number, animated: boolean = true): void {
|
||||
this.pathStack.popToIndex(index, animated);
|
||||
}
|
||||
|
||||
public clear(animated: boolean = true): void {
|
||||
this.pathStack.clear(animated);
|
||||
}
|
||||
}
|
||||
891
common/src/main/ets/service/AIAgentService.ets
Normal file
891
common/src/main/ets/service/AIAgentService.ets
Normal file
@ -0,0 +1,891 @@
|
||||
/**
|
||||
* AIAgentService - AI Agent 服务层
|
||||
* 管理上下文感知的 AI 对话,注入图数据作为上下文,
|
||||
* 解析 AI 返回中的记忆操作,调用 GraphMemoryService 执行
|
||||
* 参考:main 分支 core/graph_client.py
|
||||
*/
|
||||
import http from '@ohos.net.http';
|
||||
import dataPreferences from '@ohos.data.preferences';
|
||||
import { Context } from '@ohos.abilityAccessCtrl';
|
||||
import { GraphMemoryService, EntityInfo, RelationInfo, TaskInfo, MemoryRecallParams, MemoryCommitParams, MemoryPurgeParams, PurgeCriteriaParams, NewRelationParams, PersonaUpdateParams, TaskCreateParams, TaskSetStateParams, TaskDeleteParams, TaskLinkInfoParams, TaskArchiveParams, TaskQueryParams, TripletInput, PersonaQueryResult, TaskQueryResult, MemoryRecallResult } from './GraphMemoryService';
|
||||
import { 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: Context;
|
||||
|
||||
constructor(memoryService: GraphMemoryService, appContext: 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) {
|
||||
const args: Record<string, Object> = JSON.parse(tc.function.arguments);
|
||||
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') : '【新对话】';
|
||||
}
|
||||
}
|
||||
1036
common/src/main/ets/service/GraphMemoryService.ets
Normal file
1036
common/src/main/ets/service/GraphMemoryService.ets
Normal file
File diff suppressed because it is too large
Load Diff
47
common/src/main/ets/util/BreakpointSystem.ets
Normal file
47
common/src/main/ets/util/BreakpointSystem.ets
Normal file
@ -0,0 +1,47 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
31
common/src/main/ets/util/Logger.ets
Normal file
31
common/src/main/ets/util/Logger.ets
Normal file
@ -0,0 +1,31 @@
|
||||
import { hilog } from "@kit.PerformanceAnalysisKit";
|
||||
|
||||
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;
|
||||
52
common/src/main/ets/viewmodel/BaseViewModel.ets
Normal file
52
common/src/main/ets/viewmodel/BaseViewModel.ets
Normal file
@ -0,0 +1,52 @@
|
||||
import { BreakpointType, WidthBreakpoint } from '../util/BreakpointSystem';
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
12
common/src/main/module.json5
Normal file
12
common/src/main/module.json5
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"module": {
|
||||
"name": "common",
|
||||
"type": "har",
|
||||
"description": "TrulyMEM common module",
|
||||
"deviceTypes": [
|
||||
"phone",
|
||||
"tablet",
|
||||
"2in1"
|
||||
]
|
||||
}
|
||||
}
|
||||
1
core/web_config.json
Normal file
1
core/web_config.json
Normal file
@ -0,0 +1 @@
|
||||
{"SECRET_KEY": "3a38a0f46a76673154b491a7b061c38f2f6a55489078ae7b5d34e94e75fc0534"}
|
||||
17
features/chat/BuildProfile.ets
Normal file
17
features/chat/BuildProfile.ets
Normal file
@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Use these variables when you tailor your ArkTS code. They must be of the const type.
|
||||
*/
|
||||
export const HAR_VERSION = '1.0.0';
|
||||
export const BUILD_MODE_NAME = 'debug';
|
||||
export const DEBUG = true;
|
||||
export const TARGET_NAME = 'default';
|
||||
|
||||
/**
|
||||
* BuildProfile Class is used only for compatibility purposes.
|
||||
*/
|
||||
export default class BuildProfile {
|
||||
static readonly HAR_VERSION = HAR_VERSION;
|
||||
static readonly BUILD_MODE_NAME = BUILD_MODE_NAME;
|
||||
static readonly DEBUG = DEBUG;
|
||||
static readonly TARGET_NAME = TARGET_NAME;
|
||||
}
|
||||
1
features/chat/Index.ets
Normal file
1
features/chat/Index.ets
Normal file
@ -0,0 +1 @@
|
||||
export { ChatPage } from './src/main/ets/pages/ChatPage';
|
||||
10
features/chat/build-profile.json5
Normal file
10
features/chat/build-profile.json5
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"apiType": "stageMode",
|
||||
"buildOption": {
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"name": "default"
|
||||
}
|
||||
]
|
||||
}
|
||||
1
features/chat/chat
Symbolic link
1
features/chat/chat
Symbolic link
@ -0,0 +1 @@
|
||||
/home/program/TrulyMEM-TrueHumanMEM/features/chat
|
||||
6
features/chat/hvigorfile.ts
Normal file
6
features/chat/hvigorfile.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { harTasks } from '@ohos/hvigor-ohos-plugin';
|
||||
|
||||
export default {
|
||||
system: harTasks,
|
||||
plugins: []
|
||||
};
|
||||
19
features/chat/oh-package-lock.json5
Normal file
19
features/chat/oh-package-lock.json5
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"meta": {
|
||||
"stableOrder": true,
|
||||
"enableUnifiedLockfile": false
|
||||
},
|
||||
"lockfileVersion": 3,
|
||||
"ATTENTION": "THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.",
|
||||
"specifiers": {
|
||||
"@ohos/common@../../common": "@ohos/common@../../common"
|
||||
},
|
||||
"packages": {
|
||||
"@ohos/common@../../common": {
|
||||
"name": "@ohos/common",
|
||||
"version": "1.0.0",
|
||||
"resolved": "../../common",
|
||||
"registryType": "local"
|
||||
}
|
||||
}
|
||||
}
|
||||
11
features/chat/oh-package.json5
Normal file
11
features/chat/oh-package.json5
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@ohos/chat",
|
||||
"version": "1.0.0",
|
||||
"description": "TrulyMEM chat feature module",
|
||||
"main": "Index.ets",
|
||||
"author": "",
|
||||
"license": "",
|
||||
"dependencies": {
|
||||
"@ohos/common": "file:../../common"
|
||||
}
|
||||
}
|
||||
1
features/chat/oh_modules/@ohos/common
Symbolic link
1
features/chat/oh_modules/@ohos/common
Symbolic link
@ -0,0 +1 @@
|
||||
../../../../common
|
||||
161
features/chat/src/main/ets/components/ChatComponents.ets
Normal file
161
features/chat/src/main/ets/components/ChatComponents.ets
Normal file
@ -0,0 +1,161 @@
|
||||
import { ChatMessage } from '@ohos/common';
|
||||
|
||||
/**
|
||||
* ChatMessageBubble — 单条聊天消息气泡
|
||||
* 封装消息的角色标识、内容样式、玻璃拟态背景
|
||||
*/
|
||||
@Component
|
||||
export struct ChatMessageBubble {
|
||||
@ObjectLink msg: ChatMessage;
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
// 角色标识
|
||||
Text(this.msg.role === 'user' ? '🧑 你' : '🤖 AI')
|
||||
.fontSize(11)
|
||||
.fontColor(this.msg.role === 'user' ? '#7C4DFF' : '#999')
|
||||
.width('100%')
|
||||
|
||||
// 消息内容
|
||||
Text(this.msg.content)
|
||||
.fontSize(15)
|
||||
.width('100%')
|
||||
.margin({ top: 4 })
|
||||
.fontColor('#FFFFFF')
|
||||
}
|
||||
.padding(12)
|
||||
.backgroundColor(this.msg.role === 'user' ? 'rgba(124,77,255,0.15)' : 'rgba(245,245,245,0.1)')
|
||||
.borderRadius(12)
|
||||
.border({
|
||||
width: 1,
|
||||
color: this.msg.role === 'user' ? 'rgba(124,77,255,0.3)' : 'rgba(255,255,255,0.1)'
|
||||
})
|
||||
.backgroundBlurStyle(BlurStyle.Thin)
|
||||
.margin({ left: 8, right: 8, bottom: 8 })
|
||||
.width('100%')
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ThinkingIndicator — AI 思考中指示器
|
||||
* 玻璃拟态加载动画 + 文字提示
|
||||
*/
|
||||
@Component
|
||||
export struct ThinkingIndicator {
|
||||
build() {
|
||||
Row() {
|
||||
LoadingProgress()
|
||||
.width(20)
|
||||
.height(20)
|
||||
.margin({ right: 8 })
|
||||
.color('#7C4DFF')
|
||||
Text('AI 思考中...')
|
||||
.fontSize(13)
|
||||
.fontColor('#7C4DFF')
|
||||
}
|
||||
.padding(12)
|
||||
.backgroundColor('rgba(124,77,255,0.1)')
|
||||
.borderRadius(12)
|
||||
.border({ width: 1, color: 'rgba(124,77,255,0.2)' })
|
||||
.backgroundBlurStyle(BlurStyle.Thin)
|
||||
.margin({ left: 8, right: 8, bottom: 8 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ToolCallLogPanel — 工具调用日志面板
|
||||
* 橙色风格,显示 Agent 调用的工具链
|
||||
*/
|
||||
@Component
|
||||
export struct ToolCallLogPanel {
|
||||
@Prop logText: string;
|
||||
|
||||
build() {
|
||||
Text(this.logText)
|
||||
.fontSize(10)
|
||||
.fontColor('#FF9800')
|
||||
.backgroundColor('rgba(255,152,0,0.1)')
|
||||
.padding(8)
|
||||
.borderRadius(8)
|
||||
.border({ width: 1, color: 'rgba(255,152,0,0.2)' })
|
||||
.backgroundBlurStyle(BlurStyle.Thin)
|
||||
.margin({ left: 8, right: 8, bottom: 4 })
|
||||
.lineHeight(16)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ChatInputBar — 底部输入栏
|
||||
* TextArea + 发送按钮,主题色边框
|
||||
*/
|
||||
@Component
|
||||
export struct ChatInputBar {
|
||||
@Link inputText: string;
|
||||
@Prop isThinking: boolean;
|
||||
onSend?: () => void;
|
||||
|
||||
build() {
|
||||
Row() {
|
||||
TextArea({ text: this.inputText, placeholder: '输入消息...' })
|
||||
.layoutWeight(1)
|
||||
.onChange((v: string) => { this.inputText = v; })
|
||||
.height(40)
|
||||
.backgroundColor('rgba(255,255,255,0.1)')
|
||||
.borderRadius(8)
|
||||
.border({ width: 1, color: 'rgba(124,77,255,0.3)' })
|
||||
|
||||
Button('发送')
|
||||
.enabled(!this.isThinking)
|
||||
.onClick(() => { this.onSend?.(); })
|
||||
.backgroundColor('#7C4DFF')
|
||||
.borderRadius(8)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(8)
|
||||
.backgroundColor('rgba(255,255,255,0.05)')
|
||||
.backgroundBlurStyle(BlurStyle.Regular)
|
||||
.border({
|
||||
width: 1,
|
||||
color: 'rgba(124,77,255,0.2)',
|
||||
style: BorderStyle.Solid
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ChatMessageList — 聊天消息列表容器
|
||||
* 整合消息气泡、思考指示器、工具日志
|
||||
*/
|
||||
@Component
|
||||
export struct ChatMessageList {
|
||||
@Prop messages: ChatMessage[];
|
||||
@Prop isThinking: boolean;
|
||||
@Prop toolCallLog: string;
|
||||
private scrollController: Scroller = new Scroller();
|
||||
|
||||
build() {
|
||||
List() {
|
||||
ForEach(this.messages, (msg: ChatMessage) => {
|
||||
ListItem() {
|
||||
ChatMessageBubble({ msg: msg })
|
||||
}
|
||||
})
|
||||
|
||||
if (this.isThinking) {
|
||||
ListItem() {
|
||||
ThinkingIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
if (this.toolCallLog && !this.isThinking) {
|
||||
ListItem() {
|
||||
ToolCallLogPanel({ logText: this.toolCallLog })
|
||||
}
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.backgroundColor('rgba(0,0,0,0.1)')
|
||||
}
|
||||
}
|
||||
88
features/chat/src/main/ets/pages/ChatPage.ets
Normal file
88
features/chat/src/main/ets/pages/ChatPage.ets
Normal file
@ -0,0 +1,88 @@
|
||||
import { GraphDatabase, GraphMemoryService, AIAgentService, ChatMessage, AgentResponse, Logger } from '@ohos/common';
|
||||
import { ChatMessageList, ChatInputBar } from '../components/ChatComponents';
|
||||
|
||||
@Component
|
||||
export struct ChatPage {
|
||||
@State messages: ChatMessage[] = [];
|
||||
@State inputText: string = '';
|
||||
@Prop db: GraphDatabase;
|
||||
@State toolCallLog: string = '';
|
||||
@State isThinking: boolean = false;
|
||||
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));
|
||||
this.messages = [...this.messages, { role: 'assistant', content: `⚠️ 请求失败: ${err.message || JSON.stringify(err)}` }];
|
||||
} finally {
|
||||
this.isThinking = false;
|
||||
}
|
||||
}
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
ChatMessageList({
|
||||
messages: this.messages,
|
||||
isThinking: this.isThinking,
|
||||
toolCallLog: this.toolCallLog
|
||||
})
|
||||
|
||||
ChatInputBar({
|
||||
inputText: this.inputText,
|
||||
isThinking: this.isThinking,
|
||||
onSend: (): void => { this.sendMessage(); }
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor('rgba(26,27,46,0.95)')
|
||||
}
|
||||
}
|
||||
12
features/chat/src/main/module.json5
Normal file
12
features/chat/src/main/module.json5
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"module": {
|
||||
"name": "chat",
|
||||
"type": "har",
|
||||
"description": "TrulyMEM chat feature module",
|
||||
"deviceTypes": [
|
||||
"phone",
|
||||
"tablet",
|
||||
"2in1"
|
||||
]
|
||||
}
|
||||
}
|
||||
17
features/graph/BuildProfile.ets
Normal file
17
features/graph/BuildProfile.ets
Normal file
@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Use these variables when you tailor your ArkTS code. They must be of the const type.
|
||||
*/
|
||||
export const HAR_VERSION = '1.0.0';
|
||||
export const BUILD_MODE_NAME = 'debug';
|
||||
export const DEBUG = true;
|
||||
export const TARGET_NAME = 'default';
|
||||
|
||||
/**
|
||||
* BuildProfile Class is used only for compatibility purposes.
|
||||
*/
|
||||
export default class BuildProfile {
|
||||
static readonly HAR_VERSION = HAR_VERSION;
|
||||
static readonly BUILD_MODE_NAME = BUILD_MODE_NAME;
|
||||
static readonly DEBUG = DEBUG;
|
||||
static readonly TARGET_NAME = TARGET_NAME;
|
||||
}
|
||||
1
features/graph/Index.ets
Normal file
1
features/graph/Index.ets
Normal file
@ -0,0 +1 @@
|
||||
export { GraphPage } from './src/main/ets/pages/GraphPage';
|
||||
10
features/graph/build-profile.json5
Normal file
10
features/graph/build-profile.json5
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"apiType": "stageMode",
|
||||
"buildOption": {
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"name": "default"
|
||||
}
|
||||
]
|
||||
}
|
||||
1
features/graph/graph
Symbolic link
1
features/graph/graph
Symbolic link
@ -0,0 +1 @@
|
||||
/home/program/TrulyMEM-TrueHumanMEM/features/graph
|
||||
6
features/graph/hvigorfile.ts
Normal file
6
features/graph/hvigorfile.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { harTasks } from '@ohos/hvigor-ohos-plugin';
|
||||
|
||||
export default {
|
||||
system: harTasks,
|
||||
plugins: []
|
||||
};
|
||||
19
features/graph/oh-package-lock.json5
Normal file
19
features/graph/oh-package-lock.json5
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"meta": {
|
||||
"stableOrder": true,
|
||||
"enableUnifiedLockfile": false
|
||||
},
|
||||
"lockfileVersion": 3,
|
||||
"ATTENTION": "THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.",
|
||||
"specifiers": {
|
||||
"@ohos/common@../../common": "@ohos/common@../../common"
|
||||
},
|
||||
"packages": {
|
||||
"@ohos/common@../../common": {
|
||||
"name": "@ohos/common",
|
||||
"version": "1.0.0",
|
||||
"resolved": "../../common",
|
||||
"registryType": "local"
|
||||
}
|
||||
}
|
||||
}
|
||||
11
features/graph/oh-package.json5
Normal file
11
features/graph/oh-package.json5
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@ohos/graph",
|
||||
"version": "1.0.0",
|
||||
"description": "TrulyMEM graph feature module",
|
||||
"main": "Index.ets",
|
||||
"author": "",
|
||||
"license": "",
|
||||
"dependencies": {
|
||||
"@ohos/common": "file:../../common"
|
||||
}
|
||||
}
|
||||
1
features/graph/oh_modules/@ohos/common
Symbolic link
1
features/graph/oh_modules/@ohos/common
Symbolic link
@ -0,0 +1 @@
|
||||
../../../../common
|
||||
251
features/graph/src/main/ets/components/GraphComponents.ets
Normal file
251
features/graph/src/main/ets/components/GraphComponents.ets
Normal file
@ -0,0 +1,251 @@
|
||||
import web_webview from '@ohos.web.webview';
|
||||
import { GraphDatabase, RecallEntity, GraphMemoryService, ConnectionItem, NodeDetailInfo, Logger } from '@ohos/common';
|
||||
|
||||
/**
|
||||
* GraphNodeSearchBar — 图节点搜索栏
|
||||
* 悬浮在 WebView 上方的搜索输入框
|
||||
*/
|
||||
@Component
|
||||
export struct GraphNodeSearchBar {
|
||||
@Link searchText: string;
|
||||
onSearchInput?: (value: string) => void;
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
TextInput({ placeholder: '搜索节点...', text: this.searchText })
|
||||
.width('80%')
|
||||
.height(40)
|
||||
.backgroundColor('rgba(10, 10, 26, 0.8)')
|
||||
.fontColor('#ffffff')
|
||||
.placeholderColor('#666688')
|
||||
.borderRadius(8)
|
||||
.border({ width: 1, color: 'rgba(100, 100, 255, 0.3)' })
|
||||
.margin({ top: 20 })
|
||||
.onChange((value: string) => {
|
||||
this.onSearchInput?.(value);
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.position({ x: 0, y: 0 })
|
||||
.zIndex(10)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* NodeDetailPanel — 节点详情浮层
|
||||
* 显示选中节点的名称、类型、提及次数、连接关系
|
||||
*/
|
||||
@Component
|
||||
export struct NodeDetailPanel {
|
||||
@Prop detail: NodeDetailInfo;
|
||||
onClose?: () => void;
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
Column() {
|
||||
Text(this.detail.name)
|
||||
.fontSize(18)
|
||||
.fontColor('#44ff88')
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.margin({ bottom: 10 })
|
||||
|
||||
Text('类型: ' + this.detail.type)
|
||||
.fontSize(14)
|
||||
.fontColor('#aaaacc')
|
||||
|
||||
Text('提及次数: ' + this.detail.mention_count)
|
||||
.fontSize(14)
|
||||
.fontColor('#aaaacc')
|
||||
|
||||
Text('连接数: ' + this.detail.connection_count)
|
||||
.fontSize(14)
|
||||
.fontColor('#aaaacc')
|
||||
|
||||
if (this.detail.connections && this.detail.connections.length > 0) {
|
||||
Text('连接关系:')
|
||||
.fontSize(14)
|
||||
.fontColor('#8888aa')
|
||||
.margin({ top: 10, bottom: 5 })
|
||||
List() {
|
||||
ForEach(this.detail.connections, (conn: ConnectionItem) => {
|
||||
ListItem() {
|
||||
Text(conn.type + ': ' + conn.target_name)
|
||||
.fontSize(12)
|
||||
.fontColor('#aaaacc')
|
||||
}
|
||||
})
|
||||
}
|
||||
.height(100)
|
||||
}
|
||||
|
||||
Button('关闭')
|
||||
.width(80)
|
||||
.height(30)
|
||||
.margin({ top: 15 })
|
||||
.backgroundColor('rgba(100, 100, 255, 0.3)')
|
||||
.fontColor('#ffffff')
|
||||
.onClick(() => {
|
||||
this.onClose?.();
|
||||
})
|
||||
}
|
||||
.padding(20)
|
||||
.backgroundColor('rgba(10, 10, 26, 0.95)')
|
||||
.borderRadius(12)
|
||||
.border({ width: 1, color: 'rgba(100, 100, 255, 0.3)' })
|
||||
.width(300)
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor('rgba(0, 0, 0, 0.5)')
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
.zIndex(20)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GraphWebView — 图可视化 WebView 封装
|
||||
* 包含 WebView 配置、JS Bridge 注册、数据加载回调
|
||||
*/
|
||||
@Component
|
||||
export struct GraphWebView {
|
||||
private controller: web_webview.WebviewController = new web_webview.WebviewController();
|
||||
private bridge?: NativeBridge;
|
||||
|
||||
onPageEnd?: () => void;
|
||||
|
||||
getController(): web_webview.WebviewController {
|
||||
return this.controller;
|
||||
}
|
||||
|
||||
setBridge(bridge: NativeBridge): void {
|
||||
this.bridge = bridge;
|
||||
}
|
||||
|
||||
build() {
|
||||
Web({ src: $rawfile('graph.html'), controller: this.controller })
|
||||
.javaScriptAccess(true)
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.zoomAccess(true)
|
||||
.onPageEnd(() => {
|
||||
this.onPageEnd?.();
|
||||
})
|
||||
.javaScriptProxy({
|
||||
object: this.bridge,
|
||||
name: 'nativeBridge',
|
||||
methodList: ['onNodeClick', 'onSearch'],
|
||||
asyncMethodList: ['requestGraphData'],
|
||||
controller: this.controller
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
200
features/graph/src/main/ets/pages/GraphPage.ets
Normal file
200
features/graph/src/main/ets/pages/GraphPage.ets
Normal file
@ -0,0 +1,200 @@
|
||||
/**
|
||||
* GraphPage — 记忆星图页面(重构后)
|
||||
* 使用 WebView 显示 Three.js 3D 图可视化
|
||||
* 子组件:GraphWebView、GraphNodeSearchBar、NodeDetailPanel、GraphDataService、NativeBridge
|
||||
*/
|
||||
import web_webview from '@ohos.web.webview';
|
||||
import {
|
||||
GraphDatabase,
|
||||
NodeDetailInfo,
|
||||
Logger,
|
||||
RecallEntity,
|
||||
GraphMemoryService
|
||||
} from '@ohos/common';
|
||||
import {
|
||||
GraphWebView,
|
||||
GraphNodeSearchBar,
|
||||
NodeDetailPanel,
|
||||
GraphDataService,
|
||||
NativeBridge
|
||||
} from '../components/GraphComponents';
|
||||
|
||||
// ========= GraphPage 组件 =========
|
||||
|
||||
interface GraphNodeItem {
|
||||
id: number;
|
||||
label: string;
|
||||
type: string;
|
||||
mentions: number;
|
||||
}
|
||||
|
||||
interface GraphEdgeItem {
|
||||
id: number;
|
||||
source: number;
|
||||
target: number;
|
||||
label: string;
|
||||
relation: string;
|
||||
}
|
||||
|
||||
@Component
|
||||
export struct GraphPage {
|
||||
private controller: web_webview.WebviewController = new web_webview.WebviewController();
|
||||
@Prop db: GraphDatabase;
|
||||
@State nodeCount: number = 0;
|
||||
@State edgeCount: number = 0;
|
||||
@State selectedNodeDetail: NodeDetailInfo | null = null;
|
||||
@State showNodeDetail: boolean = false;
|
||||
@State searchText: string = '';
|
||||
private graphService: GraphMemoryService = new GraphMemoryService(this.db);
|
||||
|
||||
// 初始化桥接对象
|
||||
private bridge: NativeBridge = new NativeBridge(
|
||||
this.controller,
|
||||
(): void => { this.pushGraphDataToWebView(); },
|
||||
(nodeId: number, nodeName: string): void => { this.handleNodeClick(nodeId, nodeName); },
|
||||
(query: string): void => { this.handleSearchFromWeb(query); }
|
||||
);
|
||||
|
||||
/**
|
||||
* 外部触发刷新图数据(聊天写入新记忆后调用)
|
||||
*/
|
||||
public async refreshGraphData(): Promise<void> {
|
||||
await this.pushGraphDataToWebView();
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理节点点击 - 查询详细信息并显示浮层
|
||||
*/
|
||||
private async handleNodeClick(nodeId: number, nodeName: string): Promise<void> {
|
||||
try {
|
||||
const detail: NodeDetailInfo | null = await this.graphService.getNodeDetail(nodeName);
|
||||
if (detail) {
|
||||
this.selectedNodeDetail = detail;
|
||||
this.showNodeDetail = true;
|
||||
}
|
||||
} catch (err) {
|
||||
Logger.error('handleNodeClick error: ' + JSON.stringify(err));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理来自 WebView 的搜索请求
|
||||
*/
|
||||
private handleSearchFromWeb(query: string): void {
|
||||
this.searchText = query;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理搜索输入 - 通知 WebView 过滤
|
||||
*/
|
||||
private onSearchInput(value: string): void {
|
||||
this.searchText = value;
|
||||
const jsCode = `window.dispatchEvent(new MessageEvent('message', { data: { type: 'search_nodes', query: '${value}' } }));`;
|
||||
this.controller.runJavaScript(jsCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭节点详情浮层
|
||||
*/
|
||||
private closeNodeDetail(): void {
|
||||
this.showNodeDetail = false;
|
||||
this.selectedNodeDetail = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从数据库读取全量图数据,推送给 WebView
|
||||
*/
|
||||
private async getAllNodesData(): 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
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private async getAllEdgesData(): 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: number | undefined = nameToId[r.source];
|
||||
const targetId: number | undefined = 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从数据库读取全量图数据,推送给 WebView
|
||||
*/
|
||||
private async pushGraphDataToWebView(): Promise<void> {
|
||||
try {
|
||||
const allNodes: GraphNodeItem[] = await this.getAllNodesData();
|
||||
const allEdges: GraphEdgeItem[] = await this.getAllEdgesData();
|
||||
|
||||
if (this.controller) {
|
||||
const jsCode: string =
|
||||
`window.loadGraphData(${JSON.stringify({ nodes: allNodes, edges: allEdges })});`;
|
||||
this.controller.runJavaScript(jsCode);
|
||||
}
|
||||
|
||||
this.nodeCount = allNodes.length;
|
||||
this.edgeCount = allEdges.length;
|
||||
} catch (err) {
|
||||
Logger.error('pushGraphDataToWebView error: ' + JSON.stringify(err));
|
||||
}
|
||||
}
|
||||
|
||||
build() {
|
||||
Stack() {
|
||||
// WebView 显示 3D 星图
|
||||
Web({ src: $rawfile('graph.html'), controller: this.controller })
|
||||
.javaScriptAccess(true)
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.zoomAccess(true)
|
||||
.onPageEnd(() => {
|
||||
this.pushGraphDataToWebView();
|
||||
})
|
||||
.javaScriptProxy({
|
||||
object: this.bridge,
|
||||
name: 'nativeBridge',
|
||||
methodList: ['onNodeClick', 'onSearch'],
|
||||
asyncMethodList: ['requestGraphData'],
|
||||
controller: this.controller
|
||||
})
|
||||
|
||||
// 搜索框
|
||||
GraphNodeSearchBar({
|
||||
searchText: this.searchText,
|
||||
onSearchInput: (value: string): void => { this.onSearchInput(value); }
|
||||
})
|
||||
|
||||
// 节点详情浮层
|
||||
if (this.showNodeDetail && this.selectedNodeDetail !== null) {
|
||||
NodeDetailPanel({
|
||||
detail: this.selectedNodeDetail,
|
||||
onClose: (): void => { this.closeNodeDetail(); }
|
||||
})
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
}
|
||||
}
|
||||
12
features/graph/src/main/module.json5
Normal file
12
features/graph/src/main/module.json5
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"module": {
|
||||
"name": "graph",
|
||||
"type": "har",
|
||||
"description": "TrulyMEM graph feature module",
|
||||
"deviceTypes": [
|
||||
"phone",
|
||||
"tablet",
|
||||
"2in1"
|
||||
]
|
||||
}
|
||||
}
|
||||
827
features/graph/src/main/resources/rawfile/graph.html
Normal file
827
features/graph/src/main/resources/rawfile/graph.html
Normal file
@ -0,0 +1,827 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>记忆星图 - TrulyMEM</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: 'Courier New', monospace; background: #0a0a1a; color: #ffffff; overflow: hidden; width: 100vw; height: 100vh; }
|
||||
#canvas-container { width: 100%; height: 100%; position: relative; }
|
||||
canvas { display: block; }
|
||||
#stats { position: absolute; top: 20px; left: 20px; background: rgba(10, 10, 26, 0.8); padding: 15px 20px; border-radius: 8px; border: 1px solid rgba(100, 100, 255, 0.3); font-size: 14px; z-index: 100; backdrop-filter: blur(10px); }
|
||||
#stats h3 { margin-bottom: 8px; color: #4488ff; font-size: 16px; }
|
||||
#stats p { margin: 4px 0; color: #aaaacc; }
|
||||
#stats span { color: #ffffff; font-weight: bold; }
|
||||
#node-info { position: absolute; top: 20px; right: 20px; background: rgba(10, 10, 26, 0.9); padding: 15px 20px; border-radius: 8px; border: 1px solid rgba(100, 100, 255, 0.3); font-size: 14px; z-index: 100; display: none; backdrop-filter: blur(10px); max-width: 300px; }
|
||||
#node-info h3 { color: #44ff88; margin-bottom: 8px; font-size: 16px; }
|
||||
#node-info p { margin: 4px 0; color: #aaaacc; }
|
||||
#node-info .label { color: #8888aa; }
|
||||
#loading { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 20px; color: #4488ff; z-index: 200; }
|
||||
#nav { position: absolute; bottom: 30px; left: 50%; transform: translateX(-50%); display: flex; gap: 20px; z-index: 100; }
|
||||
|
||||
/* 搜索框 */
|
||||
#search-box { position: absolute; top: 80px; left: 20px; z-index: 100; }
|
||||
#search-input { background: rgba(10, 10, 26, 0.8); border: 1px solid rgba(100, 100, 255, 0.3); color: #ffffff; padding: 8px 12px; border-radius: 6px; font-family: 'Courier New', monospace; font-size: 14px; width: 200px; outline: none; backdrop-filter: blur(10px); }
|
||||
#search-input::placeholder { color: #666688; }
|
||||
|
||||
/* 类型过滤按钮 */
|
||||
#type-filter { position: absolute; top: 120px; left: 20px; display: flex; gap: 8px; z-index: 100; flex-wrap: wrap; }
|
||||
.type-btn { background: rgba(10, 10, 26, 0.8); border: 1px solid rgba(100, 100, 255, 0.3); color: #aaaacc; padding: 6px 12px; border-radius: 6px; cursor: pointer; font-family: 'Courier New', monospace; font-size: 12px; backdrop-filter: blur(10px); transition: all 0.3s; }
|
||||
.type-btn.active { background: rgba(68, 136, 255, 0.3); border-color: #4488ff; color: #ffffff; }
|
||||
|
||||
/* 缩放控制按钮 */
|
||||
#zoom-controls { position: absolute; bottom: 100px; right: 20px; display: flex; flex-direction: column; gap: 10px; z-index: 100; }
|
||||
.zoom-btn { width: 40px; height: 40px; border-radius: 50%; background: rgba(10, 10, 26, 0.8); border: 1px solid rgba(100, 100, 255, 0.3); color: #ffffff; font-size: 20px; cursor: pointer; display: flex; align-items: center; justify-content: center; backdrop-filter: blur(10px); font-family: 'Courier New', monospace; }
|
||||
|
||||
/* 边标签 */
|
||||
#edge-label { position: absolute; display: none; background: rgba(10, 10, 26, 0.9); color: #44ff88; padding: 4px 8px; border-radius: 4px; font-size: 12px; pointer-events: none; z-index: 150; border: 1px solid rgba(68, 255, 136, 0.3); }
|
||||
.nav-btn { background: rgba(10, 10, 26, 0.8); border: 1px solid rgba(100, 100, 255, 0.3); color: #aaaacc; padding: 12px 24px; border-radius: 8px; cursor: pointer; font-family: 'Courier New', monospace; font-size: 14px; backdrop-filter: blur(10px); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="canvas-container">
|
||||
<div id="loading">正在加载星图数据...</div>
|
||||
<div id="stats">
|
||||
<h3>🌌 记忆星图</h3>
|
||||
<p>节点: <span id="node-count">0</span></p>
|
||||
<p>边: <span id="edge-count">0</span></p>
|
||||
<p>状态: <span id="status">初始化中...</span></p>
|
||||
</div>
|
||||
<div id="node-info">
|
||||
<h3 id="info-name"></h3>
|
||||
<p><span class="label">类型:</span> <span id="info-type"></span></p>
|
||||
<p><span class="label">提及次数:</span> <span id="info-mentions"></span></p>
|
||||
<p><span class="label">连接数:</span> <span id="info-links"></span></p>
|
||||
</div>
|
||||
<div id="nav">
|
||||
<button class="nav-btn active">🌌 星图</button>
|
||||
</div>
|
||||
<div id="search-box">
|
||||
<input type="text" id="search-input" placeholder="搜索节点...">
|
||||
</div>
|
||||
<div id="type-filter">
|
||||
<button class="type-btn active" data-type="全部">全部</button>
|
||||
<button class="type-btn" data-type="Person">Person</button>
|
||||
<button class="type-btn" data-type="Task">Task</button>
|
||||
<button class="type-btn" data-type="AI">AI</button>
|
||||
<button class="type-btn" data-type="Concept">Concept</button>
|
||||
<button class="type-btn" data-type="Object">Object</button>
|
||||
</div>
|
||||
<div id="zoom-controls">
|
||||
<button class="zoom-btn" id="zoom-in">+</button>
|
||||
<button class="zoom-btn" id="zoom-out">-</button>
|
||||
<button class="zoom-btn" id="zoom-reset">R</button>
|
||||
</div>
|
||||
<div id="edge-label"></div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
|
||||
<script>
|
||||
let scene, camera, renderer, controls;
|
||||
let nodes = [], edges = [];
|
||||
let nodeMeshes = [], edgeLines = [];
|
||||
let starField, nebulaParticles;
|
||||
let raycaster, mouse;
|
||||
let hoveredNode = null, selectedNode = null;
|
||||
let animationId;
|
||||
let highlightPulse = 0;
|
||||
let searchTerm = '';
|
||||
let activeTypeFilter = '全部';
|
||||
let isDragging = false;
|
||||
let dragNode = null;
|
||||
let originalPhysicsState = true;
|
||||
let edgeLabelEl = null;
|
||||
let nodePositions = {}; // 存储节点位置用于拖拽
|
||||
|
||||
const typeColors = { 'person': 0x4488ff, 'task': 0xff8844, 'ai': 0xaa44ff, 'concept': 0x44ff88, 'object': 0xff4444 };
|
||||
const defaultColor = 0xcccccc;
|
||||
const edgeColors = { '喜欢': 0xff6b6b, '学习': 0x4ecdc4, '属于': 0x45b7d1, '相关': 0x96ceb4, '使用': 0xfeca57, '创建': 0xff9ff3 };
|
||||
const defaultEdgeColor = 0x444466;
|
||||
|
||||
function init() {
|
||||
scene = new THREE.Scene();
|
||||
scene.fog = new THREE.FogExp2(0x0a0a1a, 0.015);
|
||||
camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 2000);
|
||||
camera.position.set(0, 30, 60);
|
||||
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
renderer.setPixelRatio(window.devicePixelRatio);
|
||||
renderer.setClearColor(0x0a0a1a, 1);
|
||||
document.getElementById('canvas-container').appendChild(renderer.domElement);
|
||||
controls = new THREE.OrbitControls(camera, renderer.domElement);
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.05;
|
||||
const ambientLight = new THREE.AmbientLight(0x444466, 0.6);
|
||||
scene.add(ambientLight);
|
||||
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
|
||||
directionalLight.position.set(50, 100, 50);
|
||||
scene.add(directionalLight);
|
||||
raycaster = new THREE.Raycaster();
|
||||
mouse = new THREE.Vector2();
|
||||
createStarField();
|
||||
createNebula();
|
||||
// 使用 ResizeObserver 监听容器尺寸变化(比 window.resize 更准确)
|
||||
initResizeObserver();
|
||||
renderer.domElement.addEventListener('mousemove', onMouseMove);
|
||||
renderer.domElement.addEventListener('click', onMouseClick);
|
||||
window.addEventListener('message', (event) => {
|
||||
if (event.data.type === 'graph_data') {
|
||||
window.__graphData = event.data.payload;
|
||||
loadGraphData();
|
||||
}
|
||||
if (event.data.type === 'search_nodes') {
|
||||
searchTerm = event.data.query || '';
|
||||
document.getElementById('search-input').value = searchTerm;
|
||||
applyFilters();
|
||||
}
|
||||
if (event.data.type === 'node_detail') {
|
||||
showNodeDetailPanel(event.data.detail);
|
||||
}
|
||||
if (event.data.type === 'highlight_node') {
|
||||
highlightNodeById(event.data.nodeId);
|
||||
}
|
||||
});
|
||||
// 搜索输入框事件
|
||||
const searchInput = document.getElementById('search-input');
|
||||
searchInput.addEventListener('input', (e) => {
|
||||
searchTerm = e.target.value.toLowerCase();
|
||||
applyFilters();
|
||||
// 通知 ArkTS
|
||||
try {
|
||||
if (window.nativeBridge && window.nativeBridge.onSearch) {
|
||||
window.nativeBridge.onSearch(searchTerm);
|
||||
}
|
||||
} catch(e) {}
|
||||
});
|
||||
// 类型过滤按钮事件
|
||||
document.querySelectorAll('.type-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.type-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
activeTypeFilter = btn.dataset.type;
|
||||
applyFilters();
|
||||
});
|
||||
});
|
||||
// 缩放控制按钮
|
||||
document.getElementById('zoom-in').addEventListener('click', () => {
|
||||
camera.position.multiplyScalar(0.8);
|
||||
controls.update();
|
||||
});
|
||||
document.getElementById('zoom-out').addEventListener('click', () => {
|
||||
camera.position.multiplyScalar(1.2);
|
||||
controls.update();
|
||||
});
|
||||
document.getElementById('zoom-reset').addEventListener('click', () => {
|
||||
if (Object.keys(nodePositions).length > 0) {
|
||||
const allPositions = Object.values(nodePositions);
|
||||
let maxDist = 0;
|
||||
allPositions.forEach(pos => { maxDist = Math.max(maxDist, Math.sqrt(pos.x*pos.x + pos.y*pos.y + pos.z*pos.z)); });
|
||||
camera.position.set(maxDist * 2.2, maxDist * 1.5, maxDist * 2.2);
|
||||
controls.target.set(0, 0, 0);
|
||||
controls.update();
|
||||
}
|
||||
});
|
||||
// 边标签元素
|
||||
edgeLabelEl = document.getElementById('edge-label');
|
||||
// 鼠标移动检测边悬停
|
||||
renderer.domElement.addEventListener('mousemove', onEdgeHoverCheck);
|
||||
// 通知 ArkTS 请求图数据
|
||||
try {
|
||||
if (window.nativeBridge && window.nativeBridge.requestGraphData) {
|
||||
window.nativeBridge.requestGraphData();
|
||||
}
|
||||
} catch(e) {}
|
||||
// 触摸事件支持
|
||||
initTouchEvents();
|
||||
// 初始化拖拽功能
|
||||
initDragFunctionality();
|
||||
animate();
|
||||
}
|
||||
|
||||
function createStarField() {
|
||||
const starCount = 3000;
|
||||
const positions = new Float32Array(starCount * 3);
|
||||
const colors = new Float32Array(starCount * 3);
|
||||
for (let i = 0; i < starCount; i++) {
|
||||
const i3 = i * 3;
|
||||
const radius = 400 + Math.random() * 600;
|
||||
const theta = Math.random() * Math.PI * 2;
|
||||
const phi = Math.acos(2 * Math.random() - 1);
|
||||
positions[i3] = radius * Math.sin(phi) * Math.cos(theta);
|
||||
positions[i3 + 1] = radius * Math.sin(phi) * Math.sin(theta);
|
||||
positions[i3 + 2] = radius * Math.cos(phi);
|
||||
const colorChoice = Math.random();
|
||||
if (colorChoice < 0.7) {
|
||||
colors[i3] = 0.8 + Math.random() * 0.2;
|
||||
colors[i3 + 1] = 0.8 + Math.random() * 0.2;
|
||||
colors[i3 + 2] = 1.0;
|
||||
} else {
|
||||
colors[i3] = 1.0;
|
||||
colors[i3 + 1] = 0.9 + Math.random() * 0.1;
|
||||
colors[i3 + 2] = 0.8 + Math.random() * 0.2;
|
||||
}
|
||||
}
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
|
||||
const material = new THREE.PointsMaterial({ size: 1.5, vertexColors: true, transparent: true, opacity: 0.8, sizeAttenuation: true });
|
||||
starField = new THREE.Points(geometry, material);
|
||||
scene.add(starField);
|
||||
}
|
||||
|
||||
function createNebula() {
|
||||
const nebulaCount = 500;
|
||||
const positions = new Float32Array(nebulaCount * 3);
|
||||
const colors = new Float32Array(nebulaCount * 3);
|
||||
for (let i = 0; i < nebulaCount; i++) {
|
||||
const i3 = i * 3;
|
||||
positions[i3] = (Math.random() - 0.5) * 800;
|
||||
positions[i3 + 1] = (Math.random() - 0.5) * 800;
|
||||
positions[i3 + 2] = (Math.random() - 0.5) * 800;
|
||||
const colorChoice = Math.random();
|
||||
if (colorChoice < 0.33) {
|
||||
colors[i3] = 0.5 + Math.random() * 0.3; colors[i3 + 1] = 0.2 + Math.random() * 0.2; colors[i3 + 2] = 0.7 + Math.random() * 0.3;
|
||||
} else if (colorChoice < 0.66) {
|
||||
colors[i3] = 0.2 + Math.random() * 0.2; colors[i3 + 1] = 0.3 + Math.random() * 0.3; colors[i3 + 2] = 0.8 + Math.random() * 0.2;
|
||||
} else {
|
||||
colors[i3] = 0.7 + Math.random() * 0.3; colors[i3 + 1] = 0.2 + Math.random() * 0.2; colors[i3 + 2] = 0.5 + Math.random() * 0.3;
|
||||
}
|
||||
}
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
|
||||
const material = new THREE.PointsMaterial({ size: 8, vertexColors: true, transparent: true, opacity: 0.15, sizeAttenuation: true, blending: THREE.AdditiveBlending });
|
||||
nebulaParticles = new THREE.Points(geometry, material);
|
||||
scene.add(nebulaParticles);
|
||||
}
|
||||
|
||||
window.loadGraphData = function(data) {
|
||||
if (data && data.nodes && data.edges) {
|
||||
nodes = data.nodes.map(n => ({ id: n.id, name: n.label || n.name, type: n.type, mention_count: n.mentions || 1 }));
|
||||
edges = data.edges.map(e => ({ id: e.id, source: e.from || e.source, target: e.to || e.target, relation_type: e.label || e.relation }));
|
||||
document.getElementById('node-count').textContent = nodes.length;
|
||||
document.getElementById('edge-count').textContent = edges.length;
|
||||
document.getElementById('status').textContent = '就绪';
|
||||
createGraphVisualization();
|
||||
document.getElementById('loading').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function createGraphVisualization() {
|
||||
nodeMeshes.forEach(mesh => scene.remove(mesh));
|
||||
edgeLines.forEach(line => scene.remove(line));
|
||||
nodeMeshes = [];
|
||||
edgeLines = [];
|
||||
if (nodes.length === 0) return;
|
||||
const nodeDegrees = {};
|
||||
nodes.forEach(n => nodeDegrees[n.id] = 0);
|
||||
edges.forEach(e => {
|
||||
nodeDegrees[e.source] = (nodeDegrees[e.source] || 0) + 1;
|
||||
nodeDegrees[e.target] = (nodeDegrees[e.target] || 0) + 1;
|
||||
});
|
||||
const positions = {};
|
||||
nodePositions = positions; // 存储供拖拽使用
|
||||
const maxDegree = Math.max(...Object.values(nodeDegrees), 1);
|
||||
nodes.forEach((node, i) => {
|
||||
const angle = (i / nodes.length) * Math.PI * 2;
|
||||
const radius = 15 + (nodeDegrees[node.id] / maxDegree) * 35;
|
||||
positions[node.id] = { x: radius * Math.cos(angle), y: (Math.random() - 0.5) * 10, z: radius * Math.sin(angle) };
|
||||
});
|
||||
for (let iter = 0; iter < 200; iter++) {
|
||||
Object.keys(positions).forEach(id1 => {
|
||||
Object.keys(positions).forEach(id2 => {
|
||||
if (id1 >= id2) return;
|
||||
const pos1 = positions[id1], pos2 = positions[id2];
|
||||
const dx = pos1.x - pos2.x, dy = pos1.y - pos2.y, dz = pos1.z - pos2.z;
|
||||
const dist = Math.sqrt(dx*dx + dy*dy + dz*dz) + 0.1;
|
||||
if (dist < 20) { // 增加排斥距离
|
||||
const force = 0.3 / (dist * dist); // 增加排斥力系数(平方反比)
|
||||
pos1.x += (dx/dist)*force; pos1.y += (dy/dist)*force; pos1.z += (dz/dist)*force;
|
||||
pos2.x -= (dx/dist)*force; pos2.y -= (dy/dist)*force; pos2.z -= (dz/dist)*force;
|
||||
}
|
||||
});
|
||||
});
|
||||
edges.forEach(edge => {
|
||||
const pos1 = positions[edge.source], pos2 = positions[edge.target];
|
||||
if (!pos1 || !pos2) return;
|
||||
const dx = pos2.x - pos1.x, dy = pos2.y - pos1.y, dz = pos2.z - pos1.z;
|
||||
const dist = Math.sqrt(dx*dx + dy*dy + dz*dz) + 0.1;
|
||||
if (dist > 15) {
|
||||
const force = 0.08; // 增加吸引力
|
||||
pos1.x += (dx/dist)*force; pos1.y += (dy/dist)*force; pos1.z += (dz/dist)*force;
|
||||
pos2.x -= (dx/dist)*force; pos2.y -= (dy/dist)*force; pos2.z -= (dz/dist)*force;
|
||||
}
|
||||
});
|
||||
}
|
||||
nodes.forEach(node => {
|
||||
const pos = positions[node.id];
|
||||
if (!pos) return;
|
||||
const radius = 0.4 + Math.min(node.mention_count * 0.15, 1.5);
|
||||
const color = typeColors[node.type] || defaultColor;
|
||||
const geometry = new THREE.SphereGeometry(radius, 16, 12);
|
||||
const material = new THREE.MeshPhongMaterial({ color: color, emissive: color, emissiveIntensity: 0.5 + Math.min(node.mention_count * 0.05, 0.3), shininess: 30, transparent: true, opacity: 0 });
|
||||
const sphere = new THREE.Mesh(geometry, material);
|
||||
sphere.position.set(pos.x, pos.y, pos.z);
|
||||
sphere.userData = { nodeId: node.id, nodeData: node };
|
||||
scene.add(sphere);
|
||||
nodeMeshes.push(sphere);
|
||||
// 淡入动画
|
||||
fadeInObject(sphere, 500);
|
||||
});
|
||||
edges.forEach(edge => {
|
||||
const pos1 = positions[edge.source], pos2 = positions[edge.target];
|
||||
if (!pos1 || !pos2) return;
|
||||
const color = edgeColors[edge.relation_type] || defaultEdgeColor;
|
||||
const geometry = new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(pos1.x, pos1.y, pos1.z), new THREE.Vector3(pos2.x, pos2.y, pos2.z)]);
|
||||
const material = new THREE.LineBasicMaterial({ color: color, transparent: true, opacity: 0, linewidth: 1 });
|
||||
const line = new THREE.Line(geometry, material);
|
||||
line.userData = { edgeId: edge.id, edgeData: edge };
|
||||
scene.add(line);
|
||||
edgeLines.push(line);
|
||||
// 淡入动画
|
||||
fadeInLine(line, 500);
|
||||
});
|
||||
const allPositions = Object.values(positions);
|
||||
if (allPositions.length > 0) {
|
||||
let maxDist = 0;
|
||||
allPositions.forEach(pos => { maxDist = Math.max(maxDist, Math.sqrt(pos.x*pos.x + pos.y*pos.y + pos.z*pos.z)); });
|
||||
camera.position.set(maxDist * 2.2, maxDist * 1.5, maxDist * 2.2);
|
||||
controls.target.set(0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
function onMouseMove(event) {
|
||||
const rect = renderer.domElement.getBoundingClientRect();
|
||||
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
|
||||
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
|
||||
raycaster.setFromCamera(mouse, camera);
|
||||
const intersects = raycaster.intersectObjects(nodeMeshes);
|
||||
if (intersects.length > 0) {
|
||||
const node = intersects[0].object;
|
||||
if (hoveredNode !== node) {
|
||||
if (hoveredNode) hoveredNode.scale.set(1, 1, 1);
|
||||
hoveredNode = node;
|
||||
node.scale.set(1.2, 1.2, 1.2);
|
||||
showNodeInfo(node.userData.nodeData);
|
||||
}
|
||||
} else {
|
||||
if (hoveredNode) { hoveredNode.scale.set(1, 1, 1); hoveredNode = null; hideNodeInfo(); }
|
||||
}
|
||||
}
|
||||
|
||||
function onMouseClick(event) {
|
||||
if (isDragging) return;
|
||||
raycaster.setFromCamera(mouse, camera);
|
||||
const intersects = raycaster.intersectObjects(nodeMeshes);
|
||||
if (intersects.length > 0) {
|
||||
const node = intersects[0].object;
|
||||
if (selectedNode === node) {
|
||||
selectedNode = null;
|
||||
document.getElementById('node-info').style.display = 'none';
|
||||
resetHighlight();
|
||||
} else {
|
||||
selectedNode = node;
|
||||
showNodeInfo(node.userData.nodeData, true);
|
||||
highlightNodeConnections(node.userData.nodeData);
|
||||
// 通知 ArkTS 节点被点击
|
||||
try {
|
||||
if (window.nativeBridge && window.nativeBridge.onNodeClick) {
|
||||
window.nativeBridge.onNodeClick(node.userData.nodeData.id, node.userData.nodeData.name);
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
} else {
|
||||
// 点击空白处恢复
|
||||
selectedNode = null;
|
||||
document.getElementById('node-info').style.display = 'none';
|
||||
resetHighlight();
|
||||
}
|
||||
}
|
||||
|
||||
function showNodeInfo(nodeData, isClick = false) {
|
||||
document.getElementById('info-name').textContent = nodeData.name;
|
||||
document.getElementById('info-type').textContent = nodeData.type;
|
||||
document.getElementById('info-mentions').textContent = nodeData.mention_count;
|
||||
const linkCount = edges.filter(e => e.source === nodeData.id || e.target === nodeData.id).length;
|
||||
document.getElementById('info-links').textContent = linkCount;
|
||||
if (isClick) document.getElementById('node-info').style.display = 'block';
|
||||
}
|
||||
|
||||
function hideNodeInfo() { if (!selectedNode) document.getElementById('node-info').style.display = 'none'; }
|
||||
|
||||
// 淡入动画
|
||||
function fadeInObject(obj, duration) {
|
||||
const startOpacity = 0;
|
||||
const endOpacity = obj.material.opacity !== undefined ? (obj.material.transparent ? obj.material.opacity : 1) : 1;
|
||||
obj.material.opacity = startOpacity;
|
||||
obj.material.transparent = true;
|
||||
const startTime = Date.now();
|
||||
function animateFade() {
|
||||
const elapsed = Date.now() - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
obj.material.opacity = startOpacity + (endOpacity - startOpacity) * progress;
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(animateFade);
|
||||
} else {
|
||||
obj.material.transparent = endOpacity < 1;
|
||||
}
|
||||
}
|
||||
animateFade();
|
||||
}
|
||||
|
||||
function fadeInLine(line, duration) {
|
||||
const startOpacity = 0;
|
||||
const endOpacity = 0.4;
|
||||
line.material.opacity = startOpacity;
|
||||
const startTime = Date.now();
|
||||
function animateFade() {
|
||||
const elapsed = Date.now() - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
line.material.opacity = startOpacity + (endOpacity - startOpacity) * progress;
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(animateFade);
|
||||
}
|
||||
}
|
||||
animateFade();
|
||||
}
|
||||
|
||||
// 通过节点ID高亮节点(供ArkTS调用)
|
||||
function highlightNodeById(nodeId) {
|
||||
const mesh = nodeMeshes.find(m => m.userData.nodeData.id === nodeId);
|
||||
if (mesh) {
|
||||
selectedNode = mesh;
|
||||
showNodeInfo(mesh.userData.nodeData, true);
|
||||
highlightNodeConnections(mesh.userData.nodeData);
|
||||
}
|
||||
}
|
||||
|
||||
// 显示节点详情面板(供ArkTS调用)
|
||||
function showNodeDetailPanel(detail) {
|
||||
// 更新node-info面板显示详细信息
|
||||
document.getElementById('info-name').textContent = detail.name;
|
||||
document.getElementById('info-type').textContent = detail.type;
|
||||
document.getElementById('info-mentions').textContent = detail.mention_count;
|
||||
document.getElementById('info-links').textContent = detail.connection_count;
|
||||
document.getElementById('node-info').style.display = 'block';
|
||||
|
||||
// 如果有连接信息,添加到面板
|
||||
let detailHtml = `<h3>${detail.name}</h3>`;
|
||||
detailHtml += `<p><span class="label">类型:</span> ${detail.type}</p>`;
|
||||
detailHtml += `<p><span class="label">提及次数:</span> ${detail.mention_count}</p>`;
|
||||
detailHtml += `<p><span class="label">连接数:</span> ${detail.connection_count}</p>`;
|
||||
if (detail.connections && detail.connections.length > 0) {
|
||||
detailHtml += `<p><span class="label">连接关系:</span></p><ul style="margin-left: 15px; font-size: 12px;">`;
|
||||
detail.connections.forEach(conn => {
|
||||
detailHtml += `<li>${conn.type}: ${conn.target_name}</li>`;
|
||||
});
|
||||
detailHtml += `</ul>`;
|
||||
}
|
||||
document.getElementById('node-info').innerHTML = detailHtml;
|
||||
}
|
||||
|
||||
// 节点拖拽功能
|
||||
function initDragFunctionality() {
|
||||
let dragStartPos = { x: 0, y: 0 };
|
||||
|
||||
renderer.domElement.addEventListener('mousedown', (event) => {
|
||||
raycaster.setFromCamera(mouse, camera);
|
||||
const intersects = raycaster.intersectObjects(nodeMeshes);
|
||||
if (intersects.length > 0) {
|
||||
isDragging = false;
|
||||
dragNode = intersects[0].object;
|
||||
dragStartPos = { x: event.clientX, y: event.clientY };
|
||||
originalPhysicsState = true; // 暂停物理模拟
|
||||
}
|
||||
});
|
||||
|
||||
renderer.domElement.addEventListener('mousemove', (event) => {
|
||||
if (dragNode) {
|
||||
const dx = event.clientX - dragStartPos.x;
|
||||
const dy = event.clientY - dragStartPos.y;
|
||||
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) {
|
||||
isDragging = true;
|
||||
}
|
||||
if (isDragging) {
|
||||
// 将屏幕坐标转换为3D空间
|
||||
const rect = renderer.domElement.getBoundingClientRect();
|
||||
const mouseX = ((event.clientX - rect.left) / rect.width) * 2 - 1;
|
||||
const mouseY = -((event.clientY - rect.top) / rect.height) * 2 + 1;
|
||||
const vector = new THREE.Vector3(mouseX, mouseY, 0.5);
|
||||
vector.unproject(camera);
|
||||
const dir = vector.sub(camera.position).normalize();
|
||||
const distance = -camera.position.z / dir.z;
|
||||
const newPos = camera.position.clone().add(dir.multiplyScalar(distance));
|
||||
dragNode.position.copy(newPos);
|
||||
// 更新存储的位置
|
||||
if (nodePositions[dragNode.userData.nodeId]) {
|
||||
nodePositions[dragNode.userData.nodeId] = { x: newPos.x, y: newPos.y, z: newPos.z };
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
renderer.domElement.addEventListener('mouseup', () => {
|
||||
if (dragNode) {
|
||||
// 恢复物理模拟
|
||||
dragNode = null;
|
||||
isDragging = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Touch事件支持
|
||||
function initTouchEvents() {
|
||||
let touchStart = null;
|
||||
let touchStartDistance = 0;
|
||||
let touchStartPos = { x: 0, y: 0 };
|
||||
let isTouchDrag = false;
|
||||
|
||||
renderer.domElement.addEventListener('touchstart', (event) => {
|
||||
event.preventDefault();
|
||||
if (event.touches.length === 1) {
|
||||
const touch = event.touches[0];
|
||||
touchStartPos = { x: touch.clientX, y: touch.clientY };
|
||||
isTouchDrag = false;
|
||||
|
||||
// 模拟鼠标事件用于射线检测
|
||||
const rect = renderer.domElement.getBoundingClientRect();
|
||||
mouse.x = ((touch.clientX - rect.left) / rect.width) * 2 - 1;
|
||||
mouse.y = -((touch.clientY - rect.top) / rect.height) * 2 + 1;
|
||||
|
||||
raycaster.setFromCamera(mouse, camera);
|
||||
const intersects = raycaster.intersectObjects(nodeMeshes);
|
||||
if (intersects.length > 0) {
|
||||
const node = intersects[0].object;
|
||||
if (selectedNode === node) {
|
||||
selectedNode = null;
|
||||
document.getElementById('node-info').style.display = 'none';
|
||||
resetHighlight();
|
||||
} else {
|
||||
selectedNode = node;
|
||||
showNodeInfo(node.userData.nodeData, true);
|
||||
highlightNodeConnections(node.userData.nodeData);
|
||||
try {
|
||||
if (window.nativeBridge && window.nativeBridge.onNodeClick) {
|
||||
window.nativeBridge.onNodeClick(node.userData.nodeData.id, node.userData.nodeData.name);
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
}
|
||||
} else if (event.touches.length === 2) {
|
||||
// 双指缩放
|
||||
const dx = event.touches[0].clientX - event.touches[1].clientX;
|
||||
const dy = event.touches[0].clientY - event.touches[1].clientY;
|
||||
touchStartDistance = Math.sqrt(dx*dx + dy*dy);
|
||||
}
|
||||
}, { passive: false });
|
||||
|
||||
renderer.domElement.addEventListener('touchmove', (event) => {
|
||||
event.preventDefault();
|
||||
if (event.touches.length === 1 && controls) {
|
||||
const touch = event.touches[0];
|
||||
const dx = touch.clientX - touchStartPos.x;
|
||||
const dy = touch.clientY - touchStartPos.y;
|
||||
if (Math.abs(dx) > 5 || Math.abs(dy) > 5) {
|
||||
isTouchDrag = true;
|
||||
}
|
||||
// 模拟OrbitControls的鼠标移动
|
||||
if (isTouchDrag) {
|
||||
const rotateSpeed = 0.005;
|
||||
controls.rotateLeft(-dx * rotateSpeed);
|
||||
controls.rotateUp(-dy * rotateSpeed);
|
||||
controls.update();
|
||||
touchStartPos = { x: touch.clientX, y: touch.clientY };
|
||||
}
|
||||
} else if (event.touches.length === 2 && controls) {
|
||||
// 双指缩放
|
||||
const dx = event.touches[0].clientX - event.touches[1].clientX;
|
||||
const dy = event.touches[0].clientY - event.touches[1].clientY;
|
||||
const distance = Math.sqrt(dx*dx + dy*dy);
|
||||
const scale = touchStartDistance / distance;
|
||||
camera.position.multiplyScalar(scale);
|
||||
controls.update();
|
||||
touchStartDistance = distance;
|
||||
}
|
||||
}, { passive: false });
|
||||
|
||||
renderer.domElement.addEventListener('touchend', (event) => {
|
||||
if (event.touches.length === 0) {
|
||||
isTouchDrag = false;
|
||||
}
|
||||
}, { passive: false });
|
||||
}
|
||||
|
||||
// 搜索和类型过滤
|
||||
function applyFilters() {
|
||||
nodeMeshes.forEach(mesh => {
|
||||
const nodeData = mesh.userData.nodeData;
|
||||
const nameMatch = nodeData.name.toLowerCase().includes(searchTerm);
|
||||
const typeMatch = activeTypeFilter === '全部' || nodeData.type === activeTypeFilter;
|
||||
|
||||
if (nameMatch && typeMatch) {
|
||||
mesh.material.transparent = false;
|
||||
mesh.material.opacity = 1;
|
||||
mesh.scale.setScalar(1);
|
||||
} else {
|
||||
mesh.material.transparent = true;
|
||||
mesh.material.opacity = 0.2;
|
||||
mesh.scale.setScalar(0.8);
|
||||
}
|
||||
});
|
||||
// 同时过滤边
|
||||
edgeLines.forEach(line => {
|
||||
const edgeData = line.userData.edgeData;
|
||||
const sourceNode = nodes.find(n => n.id === edgeData.source);
|
||||
const targetNode = nodes.find(n => n.id === edgeData.target);
|
||||
const sourceMatch = sourceNode && sourceNode.name.toLowerCase().includes(searchTerm) && (activeTypeFilter === '全部' || sourceNode.type === activeTypeFilter);
|
||||
const targetMatch = targetNode && targetNode.name.toLowerCase().includes(searchTerm) && (activeTypeFilter === '全部' || targetNode.type === activeTypeFilter);
|
||||
|
||||
if ((sourceMatch || targetMatch) && searchTerm === '' && activeTypeFilter === '全部') {
|
||||
line.material.transparent = true;
|
||||
line.material.opacity = 0.4;
|
||||
} else if (sourceMatch || targetMatch) {
|
||||
line.material.transparent = true;
|
||||
line.material.opacity = 0.6;
|
||||
} else {
|
||||
line.material.transparent = true;
|
||||
line.material.opacity = 0.1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 连接高亮
|
||||
function highlightNodeConnections(nodeData) {
|
||||
const connectedNodeIds = new Set();
|
||||
const connectedEdgeIds = new Set();
|
||||
|
||||
// 找出所有连接的节点和边
|
||||
edges.forEach(edge => {
|
||||
if (edge.source === nodeData.id || edge.target === nodeData.id) {
|
||||
connectedNodeIds.add(edge.source);
|
||||
connectedNodeIds.add(edge.target);
|
||||
connectedEdgeIds.add(edge.id);
|
||||
}
|
||||
});
|
||||
|
||||
nodeMeshes.forEach(mesh => {
|
||||
const meshNodeId = mesh.userData.nodeData.id;
|
||||
if (meshNodeId === nodeData.id) {
|
||||
// 选中的节点
|
||||
mesh.material.emissiveIntensity = 1.0;
|
||||
mesh.scale.setScalar(1.5);
|
||||
} else if (connectedNodeIds.has(meshNodeId)) {
|
||||
// 直接连接的节点
|
||||
mesh.material.emissiveIntensity = 0.8;
|
||||
mesh.scale.setScalar(1.3);
|
||||
mesh.material.transparent = false;
|
||||
mesh.material.opacity = 1;
|
||||
} else {
|
||||
// 无关系的节点
|
||||
mesh.material.transparent = true;
|
||||
mesh.material.opacity = 0.15;
|
||||
mesh.material.emissiveIntensity = 0.2;
|
||||
mesh.scale.setScalar(0.9);
|
||||
}
|
||||
});
|
||||
|
||||
edgeLines.forEach(line => {
|
||||
if (connectedEdgeIds.has(line.userData.edgeData.id)) {
|
||||
line.material.transparent = true;
|
||||
line.material.opacity = 0.8;
|
||||
line.material.linewidth = 2;
|
||||
} else {
|
||||
line.material.transparent = true;
|
||||
line.material.opacity = 0.1;
|
||||
line.material.linewidth = 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function resetHighlight() {
|
||||
nodeMeshes.forEach(mesh => {
|
||||
const nodeData = mesh.userData.nodeData;
|
||||
const nameMatch = nodeData.name.toLowerCase().includes(searchTerm);
|
||||
const typeMatch = activeTypeFilter === '全部' || nodeData.type === activeTypeFilter;
|
||||
|
||||
mesh.material.emissiveIntensity = 0.5 + Math.min(nodeData.mention_count * 0.05, 0.3);
|
||||
if (nameMatch && typeMatch) {
|
||||
mesh.material.transparent = false;
|
||||
mesh.material.opacity = 1;
|
||||
mesh.scale.setScalar(1);
|
||||
} else {
|
||||
mesh.material.transparent = true;
|
||||
mesh.material.opacity = 0.2;
|
||||
mesh.scale.setScalar(0.8);
|
||||
}
|
||||
});
|
||||
edgeLines.forEach(line => {
|
||||
line.material.transparent = true;
|
||||
line.material.opacity = 0.4;
|
||||
line.material.linewidth = 1;
|
||||
});
|
||||
}
|
||||
|
||||
// 边标签显示
|
||||
function onEdgeHoverCheck(event) {
|
||||
const rect = renderer.domElement.getBoundingClientRect();
|
||||
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
|
||||
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
|
||||
raycaster.setFromCamera(mouse, camera);
|
||||
|
||||
// 检查边悬停
|
||||
const edgeIntersects = raycaster.intersectObjects(edgeLines);
|
||||
if (edgeIntersects.length > 0) {
|
||||
const edge = edgeIntersects[0].object;
|
||||
const edgeData = edge.userData.edgeData;
|
||||
edgeLabelEl.textContent = edgeData.relation_type || '关系';
|
||||
edgeLabelEl.style.display = 'block';
|
||||
edgeLabelEl.style.left = (event.clientX - rect.left + 10) + 'px';
|
||||
edgeLabelEl.style.top = (event.clientY - rect.top - 10) + 'px';
|
||||
} else {
|
||||
edgeLabelEl.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// ========= ResizeObserver 响应式适配 =========
|
||||
let containerObserver = null;
|
||||
function initResizeObserver() {
|
||||
const container = document.getElementById('canvas-container');
|
||||
if (!container) return;
|
||||
containerObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const { width, height } = entry.contentRect;
|
||||
if (width > 0 && height > 0) {
|
||||
onContainerResize(width, height);
|
||||
}
|
||||
}
|
||||
});
|
||||
containerObserver.observe(container);
|
||||
}
|
||||
|
||||
function onContainerResize(width, height) {
|
||||
if (!camera || !renderer) return;
|
||||
const aspect = width / height;
|
||||
camera.aspect = aspect;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(width, height);
|
||||
|
||||
// 窄屏(<500px)时自动调整UI元素尺寸和位置
|
||||
const isNarrow = width < 500;
|
||||
const stats = document.getElementById('stats');
|
||||
const nodeInfo = document.getElementById('node-info');
|
||||
const searchBox = document.getElementById('search-box');
|
||||
const typeFilter = document.getElementById('type-filter');
|
||||
const searchInput = document.getElementById('search-input');
|
||||
|
||||
if (isNarrow) {
|
||||
if (stats) {
|
||||
stats.style.fontSize = '11px';
|
||||
stats.style.padding = '8px 12px';
|
||||
stats.style.top = '6px';
|
||||
stats.style.left = '6px';
|
||||
}
|
||||
if (nodeInfo) {
|
||||
nodeInfo.style.fontSize = '11px';
|
||||
nodeInfo.style.padding = '8px 12px';
|
||||
nodeInfo.style.maxWidth = '180px';
|
||||
nodeInfo.style.top = '6px';
|
||||
nodeInfo.style.right = '6px';
|
||||
}
|
||||
if (searchBox) { searchBox.style.top = '70px'; searchBox.style.left = '6px'; }
|
||||
if (searchInput) { searchInput.style.width = '140px'; searchInput.style.fontSize = '12px'; }
|
||||
if (typeFilter) { typeFilter.style.top = '108px'; typeFilter.style.left = '6px'; }
|
||||
} else {
|
||||
if (stats) {
|
||||
stats.style.fontSize = '14px';
|
||||
stats.style.padding = '15px 20px';
|
||||
stats.style.top = '20px';
|
||||
stats.style.left = '20px';
|
||||
}
|
||||
if (nodeInfo) {
|
||||
nodeInfo.style.fontSize = '14px';
|
||||
nodeInfo.style.padding = '15px 20px';
|
||||
nodeInfo.style.maxWidth = '300px';
|
||||
nodeInfo.style.top = '20px';
|
||||
nodeInfo.style.right = '20px';
|
||||
}
|
||||
if (searchBox) { searchBox.style.top = '80px'; searchBox.style.left = '20px'; }
|
||||
if (searchInput) { searchInput.style.width = '200px'; searchInput.style.fontSize = '14px'; }
|
||||
if (typeFilter) { typeFilter.style.top = '120px'; typeFilter.style.left = '20px'; }
|
||||
}
|
||||
}
|
||||
|
||||
function animate() {
|
||||
animationId = requestAnimationFrame(animate);
|
||||
const time = Date.now() * 0.001;
|
||||
highlightPulse = (highlightPulse + 0.02) % (Math.PI * 2);
|
||||
controls.update();
|
||||
if (starField) starField.rotation.y += 0.0001;
|
||||
if (hoveredNode) { const pulse = 1 + Math.sin(highlightPulse * 3) * 0.05; hoveredNode.scale.set(pulse * 1.2, pulse * 1.2, pulse * 1.2); }
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
17
features/settings/BuildProfile.ets
Normal file
17
features/settings/BuildProfile.ets
Normal file
@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Use these variables when you tailor your ArkTS code. They must be of the const type.
|
||||
*/
|
||||
export const HAR_VERSION = '1.0.0';
|
||||
export const BUILD_MODE_NAME = 'debug';
|
||||
export const DEBUG = true;
|
||||
export const TARGET_NAME = 'default';
|
||||
|
||||
/**
|
||||
* BuildProfile Class is used only for compatibility purposes.
|
||||
*/
|
||||
export default class BuildProfile {
|
||||
static readonly HAR_VERSION = HAR_VERSION;
|
||||
static readonly BUILD_MODE_NAME = BUILD_MODE_NAME;
|
||||
static readonly DEBUG = DEBUG;
|
||||
static readonly TARGET_NAME = TARGET_NAME;
|
||||
}
|
||||
1
features/settings/Index.ets
Normal file
1
features/settings/Index.ets
Normal file
@ -0,0 +1 @@
|
||||
export { SettingsPage } from './src/main/ets/pages/SettingsPage';
|
||||
10
features/settings/build-profile.json5
Normal file
10
features/settings/build-profile.json5
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"apiType": "stageMode",
|
||||
"buildOption": {
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"name": "default"
|
||||
}
|
||||
]
|
||||
}
|
||||
6
features/settings/hvigorfile.ts
Normal file
6
features/settings/hvigorfile.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { harTasks } from '@ohos/hvigor-ohos-plugin';
|
||||
|
||||
export default {
|
||||
system: harTasks,
|
||||
plugins: []
|
||||
};
|
||||
19
features/settings/oh-package-lock.json5
Normal file
19
features/settings/oh-package-lock.json5
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"meta": {
|
||||
"stableOrder": true,
|
||||
"enableUnifiedLockfile": false
|
||||
},
|
||||
"lockfileVersion": 3,
|
||||
"ATTENTION": "THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.",
|
||||
"specifiers": {
|
||||
"@ohos/common@../../common": "@ohos/common@../../common"
|
||||
},
|
||||
"packages": {
|
||||
"@ohos/common@../../common": {
|
||||
"name": "@ohos/common",
|
||||
"version": "1.0.0",
|
||||
"resolved": "../../common",
|
||||
"registryType": "local"
|
||||
}
|
||||
}
|
||||
}
|
||||
11
features/settings/oh-package.json5
Normal file
11
features/settings/oh-package.json5
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@ohos/settings",
|
||||
"version": "1.0.0",
|
||||
"description": "TrulyMEM settings feature module",
|
||||
"main": "Index.ets",
|
||||
"author": "",
|
||||
"license": "",
|
||||
"dependencies": {
|
||||
"@ohos/common": "file:../../common"
|
||||
}
|
||||
}
|
||||
1
features/settings/oh_modules/@ohos/common
Symbolic link
1
features/settings/oh_modules/@ohos/common
Symbolic link
@ -0,0 +1 @@
|
||||
../../../../common
|
||||
1
features/settings/settings
Symbolic link
1
features/settings/settings
Symbolic link
@ -0,0 +1 @@
|
||||
/home/program/TrulyMEM-TrueHumanMEM/features/settings
|
||||
105
features/settings/src/main/ets/components/SettingsComponents.ets
Normal file
105
features/settings/src/main/ets/components/SettingsComponents.ets
Normal file
@ -0,0 +1,105 @@
|
||||
import dataPreferences from '@ohos.data.preferences';
|
||||
|
||||
/**
|
||||
* SettingsSectionHeader — 设置区块标题
|
||||
* 大标题 + 加粗白色
|
||||
*/
|
||||
@Component
|
||||
export struct SettingsSectionHeader {
|
||||
@Prop title: string;
|
||||
|
||||
build() {
|
||||
Text(this.title)
|
||||
.fontSize(24)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor('#FFFFFF')
|
||||
.margin({ top: 20, bottom: 16 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SettingInputItem — 设置输入项
|
||||
* 标签 + TextInput,统一玻璃拟态风格
|
||||
*/
|
||||
@Component
|
||||
export struct SettingInputItem {
|
||||
@Prop label: string;
|
||||
@Prop placeholder: string;
|
||||
@Link value: string;
|
||||
isPassword?: boolean = false;
|
||||
onValueChange?: (value: string) => void;
|
||||
|
||||
build() {
|
||||
Column() {
|
||||
Text(this.label)
|
||||
.fontSize(14)
|
||||
.fontColor('#FFFFFF')
|
||||
.width('100%')
|
||||
.margin({ bottom: 8 })
|
||||
|
||||
TextInput({ placeholder: this.placeholder, text: this.value })
|
||||
.type(this.isPassword ? InputType.Password : InputType.Normal)
|
||||
.onChange((v: string) => {
|
||||
this.value = v;
|
||||
this.onValueChange?.(v);
|
||||
})
|
||||
.backgroundColor('rgba(255,255,255,0.1)')
|
||||
.borderRadius(8)
|
||||
.border({ width: 1, color: 'rgba(124,77,255,0.3)' })
|
||||
.height(40)
|
||||
}
|
||||
.padding(12)
|
||||
.backgroundColor('rgba(255,255,255,0.05)')
|
||||
.borderRadius(12)
|
||||
.backgroundBlurStyle(BlurStyle.Thin)
|
||||
.margin({ bottom: 12 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GlowBackground — 主题色光晕背景装饰
|
||||
* 用于设置页顶部装饰
|
||||
*/
|
||||
@Component
|
||||
export struct GlowBackground {
|
||||
@Prop color: string = 'rgba(124,77,255,0.15)';
|
||||
@Prop glowSize: number = 200;
|
||||
|
||||
build() {
|
||||
Column()
|
||||
.width(this.glowSize)
|
||||
.height(this.glowSize)
|
||||
.backgroundColor(this.color)
|
||||
.blur(40)
|
||||
.borderRadius(this.glowSize / 2)
|
||||
.position({ x: '10%', y: '20%' })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AppConfigStore — 应用配置存储封装
|
||||
* 封装 Preferences 读写,提供类型安全访问
|
||||
*/
|
||||
export class AppConfigStore {
|
||||
private pref?: dataPreferences.Preferences;
|
||||
private readonly storeName: string = 'trulymem_config';
|
||||
|
||||
async init(ctx: Context): Promise<void> {
|
||||
this.pref = await dataPreferences.getPreferences(ctx, this.storeName);
|
||||
}
|
||||
|
||||
async getString(key: string, defaultValue: string): Promise<string> {
|
||||
return String(await this.pref?.get(key, defaultValue));
|
||||
}
|
||||
|
||||
async setString(key: string, value: string): Promise<void> {
|
||||
await this.pref?.put(key, value);
|
||||
await this.pref?.flush();
|
||||
}
|
||||
|
||||
static async create(ctx: Context): Promise<AppConfigStore> {
|
||||
const store = new AppConfigStore();
|
||||
await store.init(ctx);
|
||||
return store;
|
||||
}
|
||||
}
|
||||
60
features/settings/src/main/ets/pages/SettingsPage.ets
Normal file
60
features/settings/src/main/ets/pages/SettingsPage.ets
Normal file
@ -0,0 +1,60 @@
|
||||
import dataPreferences from '@ohos.data.preferences';
|
||||
import { SettingsSectionHeader, SettingInputItem, GlowBackground, AppConfigStore } from '../components/SettingsComponents';
|
||||
|
||||
@Component
|
||||
export struct SettingsPage {
|
||||
@State baseUrl: string = '';
|
||||
@State model: string = '';
|
||||
@State apiKey: string = '';
|
||||
private store: AppConfigStore = new AppConfigStore();
|
||||
|
||||
async aboutToAppear() {
|
||||
const ctx = getContext(this);
|
||||
await this.store.init(ctx);
|
||||
this.baseUrl = await this.store.getString('base_url', 'https://api.deepseek.com');
|
||||
this.model = await this.store.getString('model', 'deepseek-chat');
|
||||
this.apiKey = await this.store.getString('api_key', '');
|
||||
}
|
||||
|
||||
private async saveConfig(key: string, value: string): Promise<void> {
|
||||
await this.store.setString(key, value);
|
||||
}
|
||||
|
||||
build() {
|
||||
Stack() {
|
||||
GlowBackground()
|
||||
|
||||
Column() {
|
||||
SettingsSectionHeader({ title: 'API 配置' })
|
||||
|
||||
SettingInputItem({
|
||||
label: 'Base URL',
|
||||
placeholder: 'https://api.deepseek.com',
|
||||
value: this.baseUrl,
|
||||
onValueChange: (v: string): void => { this.saveConfig('base_url', v); }
|
||||
})
|
||||
|
||||
SettingInputItem({
|
||||
label: 'Model ID',
|
||||
placeholder: 'deepseek-chat',
|
||||
value: this.model,
|
||||
onValueChange: (v: string): void => { this.saveConfig('model', v); }
|
||||
})
|
||||
|
||||
SettingInputItem({
|
||||
label: 'API Key',
|
||||
placeholder: 'sk-...',
|
||||
value: this.apiKey,
|
||||
isPassword: true,
|
||||
onValueChange: (v: string): void => { this.saveConfig('api_key', v); }
|
||||
})
|
||||
}
|
||||
.padding(16)
|
||||
.width('100%')
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor('rgba(26,27,46,0.95)')
|
||||
.backgroundBlurStyle(BlurStyle.Regular)
|
||||
}
|
||||
}
|
||||
12
features/settings/src/main/module.json5
Normal file
12
features/settings/src/main/module.json5
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"module": {
|
||||
"name": "settings",
|
||||
"type": "har",
|
||||
"description": "TrulyMEM settings feature module",
|
||||
"deviceTypes": [
|
||||
"phone",
|
||||
"tablet",
|
||||
"2in1"
|
||||
]
|
||||
}
|
||||
}
|
||||
221
full_output.log
Normal file
221
full_output.log
Normal file
@ -0,0 +1,221 @@
|
||||
Reading package lists...
|
||||
Building dependency tree...
|
||||
Reading state information...
|
||||
python3 is already the newest version (3.13.9-3).
|
||||
python3 set to manually installed.
|
||||
python3-pip is already the newest version (26.0.1+dfsg-1).
|
||||
You might want to run 'apt --fix-broken install' to correct these.
|
||||
The following packages have unmet dependencies:
|
||||
libxfont2 : Depends: libfontenc1 (>= 1:1.1.8) but 1:1.1.4-1 is to be installed
|
||||
python3-venv : Depends: python3.13-venv (>= 3.13.5-1~) but it is not going to be installed
|
||||
Depends: python3 (= 3.13.5-1) but 3.13.9-3 is to be installed
|
||||
xserver-common : Depends: x11-xkb-utils but it is not going to be installed
|
||||
Recommends: xfonts-base but it is not going to be installed
|
||||
E: Unmet dependencies. Try 'apt --fix-broken install' with no packages (or specify a solution).
|
||||
|
||||
WARNING: apt does not have a stable CLI interface. Use with caution in scripts.
|
||||
|
||||
Reading package lists...
|
||||
Building dependency tree...
|
||||
Reading state information...
|
||||
Correcting dependencies... Done
|
||||
Solving dependencies...
|
||||
Upgrading:
|
||||
libfontenc1 libxt-dev
|
||||
|
||||
Installing dependencies:
|
||||
libxt6t64 x11-xkb-utils
|
||||
|
||||
REMOVING:
|
||||
libxt6
|
||||
|
||||
apt-listchanges: Reading changelogs...
|
||||
dpkg-preconfigure: unable to re-open stdin: No such file or directory
|
||||
Summary:
|
||||
Upgrading: 2, Installing: 2, Removing: 1, Not Upgrading: 708
|
||||
Download size: 0 B / 778 kB
|
||||
Space needed: 526 kB / 419 GB available
|
||||
|
||||
(Reading database…
|
||||
(Reading database… 5%
|
||||
(Reading database… 10%
|
||||
(Reading database… 15%
|
||||
(Reading database… 20%
|
||||
(Reading database… 25%
|
||||
(Reading database… 30%
|
||||
(Reading database… 35%
|
||||
(Reading database… 40%
|
||||
(Reading database… 45%
|
||||
(Reading database… 50%
|
||||
(Reading database… 55%
|
||||
(Reading database… 60%
|
||||
(Reading database… 65%
|
||||
(Reading database… 70%
|
||||
(Reading database… 75%
|
||||
(Reading database… 80%
|
||||
(Reading database… 85%
|
||||
(Reading database… 90%
|
||||
(Reading database… 95%
|
||||
(Reading database… 100%
|
||||
(Reading database… 103500 files and directories currently installed.)
|
||||
Preparing to unpack …/libxt-dev_1%3a1.2.1-1.2+b2_amd64.deb…
|
||||
Unpacking libxt-dev:amd64 (1:1.2.1-1.2+b2) over (1:1.2.1-1.1)…
|
||||
dpkg: libxt6:amd64: dependency problems, but removing anyway as you requested:
|
||||
x11-xserver-utils depends on libxt6.
|
||||
x11-utils depends on libxt6 (>= 1:1.1.0).
|
||||
libxmu6:amd64 depends on libxt6.
|
||||
libxaw7:amd64 depends on libxt6.
|
||||
libgs10:amd64 depends on libxt6.
|
||||
|
||||
(Reading database…
|
||||
(Reading database… 5%
|
||||
(Reading database… 10%
|
||||
(Reading database… 15%
|
||||
(Reading database… 20%
|
||||
(Reading database… 25%
|
||||
(Reading database… 30%
|
||||
(Reading database… 35%
|
||||
(Reading database… 40%
|
||||
(Reading database… 45%
|
||||
(Reading database… 50%
|
||||
(Reading database… 55%
|
||||
(Reading database… 60%
|
||||
(Reading database… 65%
|
||||
(Reading database… 70%
|
||||
(Reading database… 75%
|
||||
(Reading database… 80%
|
||||
(Reading database… 85%
|
||||
(Reading database… 90%
|
||||
(Reading database… 95%
|
||||
(Reading database… 100%
|
||||
(Reading database… 103501 files and directories currently installed.)
|
||||
Removing libxt6:amd64 (1:1.2.1-1.1)…
|
||||
Selecting previously unselected package libxt6t64:amd64.
|
||||
(Reading database…
|
||||
(Reading database… 5%
|
||||
(Reading database… 10%
|
||||
(Reading database… 15%
|
||||
(Reading database… 20%
|
||||
(Reading database… 25%
|
||||
(Reading database… 30%
|
||||
(Reading database… 35%
|
||||
(Reading database… 40%
|
||||
(Reading database… 45%
|
||||
(Reading database… 50%
|
||||
(Reading database… 55%
|
||||
(Reading database… 60%
|
||||
(Reading database… 65%
|
||||
(Reading database… 70%
|
||||
(Reading database… 75%
|
||||
(Reading database… 80%
|
||||
(Reading database… 85%
|
||||
(Reading database… 90%
|
||||
(Reading database… 95%
|
||||
(Reading database… 100%
|
||||
(Reading database… 103495 files and directories currently installed.)
|
||||
Preparing to unpack …/libxt6t64_1%3a1.2.1-1.2+b2_amd64.deb…
|
||||
Unpacking libxt6t64:amd64 (1:1.2.1-1.2+b2)…
|
||||
Preparing to unpack …/libfontenc1_1%3a1.1.8-1+b2_amd64.deb…
|
||||
Unpacking libfontenc1:amd64 (1:1.1.8-1+b2) over (1:1.1.4-1)…
|
||||
Selecting previously unselected package x11-xkb-utils.
|
||||
Preparing to unpack …/x11-xkb-utils_7.7+9_amd64.deb…
|
||||
Unpacking x11-xkb-utils (7.7+9)…
|
||||
Setting up libfontenc1:amd64 (1:1.1.8-1+b2)…
|
||||
Setting up libxt6t64:amd64 (1:1.2.1-1.2+b2)…
|
||||
Setting up x11-xkb-utils (7.7+9)…
|
||||
Setting up libxt-dev:amd64 (1:1.2.1-1.2+b2)…
|
||||
Processing triggers for man-db (2.11.2-2)…
|
||||
Processing triggers for libc-bin (2.42-14)…
|
||||
needrestart is being skipped since dpkg has failed
|
||||
Reading package lists...
|
||||
Building dependency tree...
|
||||
Reading state information...
|
||||
Solving dependencies...
|
||||
Some packages could not be installed. This may mean that you have
|
||||
requested an impossible situation or if you are using the unstable
|
||||
distribution that some required packages have not yet been created
|
||||
or been moved out of Incoming.
|
||||
The following information may help to resolve the situation:
|
||||
|
||||
The following packages have unmet dependencies:
|
||||
python3-venv : Depends: python3.13-venv (>= 3.13.5-1~) but it is not going to be installed
|
||||
Depends: python3 (= 3.13.5-1) but 3.13.9-3 is to be installed
|
||||
E: Unable to satisfy dependencies. Reached two conflicting assignments:
|
||||
1. python3-venv:amd64=3.13.5-1 is selected for install
|
||||
2. python3-venv:amd64=3.13.5-1 Depends python3 (= 3.13.5-1)
|
||||
but none of the choices are installable:
|
||||
- python3:amd64=3.13.5-1 is not selected for install
|
||||
===== Building TrulyMEM for Linux =====
|
||||
Project root: /home/program/TrulyMEM-TrueHumanMEM
|
||||
The virtual environment was not created successfully because ensurepip is not
|
||||
available. On Debian/Ubuntu systems, you need to install the python3-venv
|
||||
package using the following command.
|
||||
|
||||
apt install python3.13-venv
|
||||
|
||||
You may need to use sudo with that command. After installing the python3-venv
|
||||
package, recreate your virtual environment.
|
||||
|
||||
Failing command: /home/program/TrulyMEM-TrueHumanMEM/.venv_build/bin/python3
|
||||
|
||||
Warning: venv creation failed, falling back to system Python
|
||||
Cleaning previous builds...
|
||||
================================
|
||||
Building TrulyMEM (TUI + Web embedded)
|
||||
================================
|
||||
31 INFO: PyInstaller: 6.20.0, contrib hooks: 2026.4
|
||||
31 INFO: Python: 3.13.12
|
||||
33 INFO: Platform: Linux-6.1.0-44-amd64-x86_64-with-glibc2.42
|
||||
33 INFO: Python environment: /usr
|
||||
36 INFO: Removing temporary files and cleaning cache in /root/.cache/pyinstaller
|
||||
37 INFO: Module search paths (PYTHONPATH):
|
||||
['/home/program/TrulyMEM-TrueHumanMEM',
|
||||
'/home/program/TrulyMEM-TrueHumanMEM',
|
||||
'/usr/lib/python313.zip',
|
||||
'/usr/lib/python3.13',
|
||||
'/usr/lib/python3.13/lib-dynload',
|
||||
'/usr/local/lib/python3.13/dist-packages',
|
||||
'/usr/lib/python3/dist-packages',
|
||||
'/home/program/TrulyMEM-TrueHumanMEM']
|
||||
158 INFO: Appending 'datas' from .spec
|
||||
158 INFO: checking Analysis
|
||||
158 INFO: Building Analysis because Analysis-00.toc is non existent
|
||||
159 INFO: Looking for Python shared library...
|
||||
166 INFO: Using Python shared library: /usr/lib/x86_64-linux-gnu/libpython3.13.so.1.0
|
||||
166 INFO: Running Analysis Analysis-00.toc
|
||||
166 INFO: Target bytecode optimization level: 0
|
||||
166 INFO: Initializing module dependency graph...
|
||||
166 INFO: Initializing module graph hook caches...
|
||||
170 INFO: Analyzing modules for base_library.zip ...
|
||||
651 INFO: Processing standard module hook 'hook-encodings.py' from '/usr/local/lib/python3.13/dist-packages/PyInstaller/hooks'
|
||||
943 INFO: Processing standard module hook 'hook-heapq.py' from '/usr/local/lib/python3.13/dist-packages/PyInstaller/hooks'
|
||||
1656 INFO: Processing standard module hook 'hook-pickle.py' from '/usr/local/lib/python3.13/dist-packages/PyInstaller/hooks'
|
||||
2496 INFO: Caching module dependency graph...
|
||||
2519 INFO: Analyzing /home/program/TrulyMEM-TrueHumanMEM/trulymem_entry.py
|
||||
2554 INFO: Processing standard module hook 'hook-sqlite3.py' from '/usr/local/lib/python3.13/dist-packages/PyInstaller/hooks'
|
||||
2678 INFO: Processing standard module hook 'hook-platform.py' from '/usr/local/lib/python3.13/dist-packages/PyInstaller/hooks'
|
||||
2702 INFO: Processing standard module hook 'hook-sysconfig.py' from '/usr/local/lib/python3.13/dist-packages/PyInstaller/hooks'
|
||||
2706 INFO: Processing standard module hook 'hook-_ctypes.py' from '/usr/local/lib/python3.13/dist-packages/PyInstaller/hooks'
|
||||
2716 INFO: Processing pre-safe-import-module hook 'hook-typing_extensions.py' from '/usr/local/lib/python3.13/dist-packages/PyInstaller/hooks/pre_safe_import_module'
|
||||
2717 INFO: SetuptoolsInfo: initializing cached setuptools info...
|
||||
4768 INFO: Processing standard module hook 'hook-multiprocessing.util.py' from '/usr/local/lib/python3.13/dist-packages/PyInstaller/hooks'
|
||||
4924 INFO: Processing standard module hook 'hook-xml.py' from '/usr/local/lib/python3.13/dist-packages/PyInstaller/hooks'
|
||||
5338 INFO: Processing standard module hook 'hook-pydantic.py' from '/usr/local/lib/python3.13/dist-packages/_pyinstaller_hooks_contrib/stdhooks'
|
||||
5622 INFO: Processing standard module hook 'hook-rich.py' from '/usr/local/lib/python3.13/dist-packages/_pyinstaller_hooks_contrib/stdhooks'
|
||||
5908 INFO: Processing standard module hook 'hook-pygments.py' from '/usr/local/lib/python3.13/dist-packages/PyInstaller/hooks'
|
||||
6310 INFO: Processing standard module hook 'hook-chardet.py' from '/usr/local/lib/python3.13/dist-packages/_pyinstaller_hooks_contrib/stdhooks'
|
||||
7729 INFO: Processing standard module hook 'hook-zoneinfo.py' from '/usr/local/lib/python3.13/dist-packages/_pyinstaller_hooks_contrib/stdhooks'
|
||||
8919 INFO: Processing standard module hook 'hook-certifi.py' from '/usr/local/lib/python3.13/dist-packages/_pyinstaller_hooks_contrib/stdhooks'
|
||||
8990 INFO: Processing standard module hook 'hook-anyio.py' from '/usr/local/lib/python3.13/dist-packages/_pyinstaller_hooks_contrib/stdhooks'
|
||||
9647 INFO: Processing standard module hook 'hook-difflib.py' from '/usr/local/lib/python3.13/dist-packages/PyInstaller/hooks'
|
||||
10808 INFO: Processing standard module hook 'hook-numpy.py' from '/usr/local/lib/python3.13/dist-packages/PyInstaller/hooks'
|
||||
12163 INFO: Processing standard module hook 'hook-charset_normalizer.py' from '/usr/local/lib/python3.13/dist-packages/_pyinstaller_hooks_contrib/stdhooks'
|
||||
12999 INFO: Processing standard module hook 'hook-pytz.py' from '/usr/local/lib/python3.13/dist-packages/PyInstaller/hooks'
|
||||
13591 INFO: Processing pre-safe-import-module hook 'hook-platformdirs.py' from '/usr/local/lib/python3.13/dist-packages/PyInstaller/hooks/pre_safe_import_module'
|
||||
13598 INFO: Processing standard module hook 'hook-platformdirs.py' from '/usr/local/lib/python3.13/dist-packages/_pyinstaller_hooks_contrib/stdhooks'
|
||||
15377 INFO: Processing standard module hook 'hook-jinja2.py' from '/usr/local/lib/python3.13/dist-packages/_pyinstaller_hooks_contrib/stdhooks'
|
||||
15747 INFO: Processing pre-safe-import-module hook 'hook-importlib_metadata.py' from '/usr/local/lib/python3.13/dist-packages/PyInstaller/hooks/pre_safe_import_module'
|
||||
15747 INFO: Setuptools: 'importlib_metadata' appears to be a setuptools-vendored copy - creating alias to 'setuptools._vendor.importlib_metadata'!
|
||||
15752 INFO: Processing standard module hook 'hook-setuptools.py' from '/usr/local/lib/python3.13/dist-packages/PyInstaller/hooks'
|
||||
15759 INFO: Processing pre-safe-import-module hook 'hook-distutils.py' from '/usr/local/lib/python3.13/dist-packages/PyInstaller/hooks/pre_safe_import_module'
|
||||
15778 INFO: Processing pre-safe-import-module hook 'hook-jaraco.py' from '/usr/local/lib/python3.13/dist-packages/PyInstaller/hooks/pre_safe_import_module'
|
||||
25
generate-debug-cert.sh
Normal file
25
generate-debug-cert.sh
Normal file
@ -0,0 +1,25 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
DIR="/home/program/.harmonyos"
|
||||
mkdir -p "$DIR"
|
||||
cd "$DIR"
|
||||
|
||||
KEYSTORE_PASS="123456"
|
||||
ALIAS="debug"
|
||||
ALIAS_PASS="123456"
|
||||
DNAME="CN=Debug,OU=Debug,O=TrulyMEM,L=Beijing,ST=Beijing,C=CN"
|
||||
|
||||
# 生成私钥
|
||||
openssl ecparam -genkey -name prime256v1 -out private.pem 2>/dev/null
|
||||
|
||||
# 生成 CSR
|
||||
openssl req -new -key private.pem -out cert.csr -subj "$DNAME" 2>/dev/null
|
||||
|
||||
# 自签名证书
|
||||
openssl req -x509 -days 3650 -key private.pem -in cert.csr -out debug.cer 2>/dev/null
|
||||
|
||||
# 创建 PKCS12
|
||||
openssl pkcs12 -export -out debug.p12 -inkey private.pem -in debug.cer -password pass:$KEYSTORE_PASS -name $ALIAS 2>/dev/null
|
||||
|
||||
echo "Debug cert generated at $DIR"
|
||||
ls -la "$DIR"
|
||||
23
hvigor/hvigor-config.json5
Normal file
23
hvigor/hvigor-config.json5
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"modelVersion": "5.0.5",
|
||||
"dependencies": {
|
||||
},
|
||||
"execution": {
|
||||
// "analyze": "normal", /* Define the build analyze mode. Value: [ "normal" | "advanced" | "ultrafine" | false ]. Default: "normal" */
|
||||
// "daemon": true, /* Enable daemon compilation. Value: [ true | false ]. Default: true */
|
||||
// "incremental": true, /* Enable incremental compilation. Value: [ true | false ]. Default: true */
|
||||
// "parallel": true, /* Enable parallel compilation. Value: [ true | false ]. Default: true */
|
||||
// "typeCheck": false, /* Enable typeCheck. Value: [ true | false ]. Default: false */
|
||||
// "optimizationStrategy": "memory" /* Define the optimization strategy. Value: [ "memory" | "performance" ]. Default: "memory" */
|
||||
},
|
||||
"logging": {
|
||||
// "level": "info" /* Define the log level. Value: [ "debug" | "info" | "warn" | "error" ]. Default: "info" */
|
||||
},
|
||||
"debugging": {
|
||||
// "stacktrace": false /* Disable stacktrace compilation. Value: [ true | false ]. Default: false */
|
||||
},
|
||||
"nodeOptions": {
|
||||
// "maxOldSpaceSize": 8192 /* Enable nodeOptions maxOldSpaceSize compilation. Unit M. Used for the daemon process. Default: 8192*/
|
||||
// "exposeGC": true /* Enable to trigger garbage collection explicitly. Default: true*/
|
||||
}
|
||||
}
|
||||
6
hvigorfile.ts
Normal file
6
hvigorfile.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { appTasks } from '@ohos/hvigor-ohos-plugin';
|
||||
|
||||
export default {
|
||||
system: appTasks,
|
||||
plugins: []
|
||||
};
|
||||
2
local.properties
Normal file
2
local.properties
Normal file
@ -0,0 +1,2 @@
|
||||
hwsdk.dir=/home/program/tools/command-line-tools/sdk/default
|
||||
sdk.dir=/home/program/tools/command-line-tools/sdk/default
|
||||
28
oh-package-lock.json5
Normal file
28
oh-package-lock.json5
Normal file
@ -0,0 +1,28 @@
|
||||
{
|
||||
"meta": {
|
||||
"stableOrder": true,
|
||||
"enableUnifiedLockfile": false
|
||||
},
|
||||
"lockfileVersion": 3,
|
||||
"ATTENTION": "THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.",
|
||||
"specifiers": {
|
||||
"@ohos/hamock@1.0.0": "@ohos/hamock@1.0.0",
|
||||
"@ohos/hypium@1.0.24": "@ohos/hypium@1.0.24"
|
||||
},
|
||||
"packages": {
|
||||
"@ohos/hamock@1.0.0": {
|
||||
"name": "@ohos/hamock",
|
||||
"version": "1.0.0",
|
||||
"integrity": "sha512-K6lDPYc6VkKe6ZBNQa9aoG+ZZMiwqfcR/7yAVFSUGIuOAhPvCJAo9+t1fZnpe0dBRBPxj2bxPPbKh69VuyAtDg==",
|
||||
"resolved": "https://ohpm.openharmony.cn/ohpm/@ohos/hamock/-/hamock-1.0.0.har",
|
||||
"registryType": "ohpm"
|
||||
},
|
||||
"@ohos/hypium@1.0.24": {
|
||||
"name": "@ohos/hypium",
|
||||
"version": "1.0.24",
|
||||
"integrity": "sha512-3dCqc+BAR5LqEGG2Vtzi8O3r7ci/3fYU+FWjwvUobbfko7DUnXGOccaror0yYuUhJfXzFK0aZNMGSnXaTwEnbw==",
|
||||
"resolved": "https://ohpm.openharmony.cn/ohpm/@ohos/hypium/-/hypium-1.0.24.har",
|
||||
"registryType": "ohpm"
|
||||
}
|
||||
}
|
||||
}
|
||||
9
oh-package.json5
Normal file
9
oh-package.json5
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"modelVersion": "5.0.5",
|
||||
"description": "TrulyMEM - True Human Memory",
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"@ohos/hypium": "1.0.24",
|
||||
"@ohos/hamock": "1.0.0"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
## 1.0.0
|
||||
- 修复once断言问题
|
||||
## 1.0.0-rc
|
||||
- 提供DevEco Studio预览器场景使能的MockSetup装饰器
|
||||
@ -0,0 +1,177 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
@ -0,0 +1,82 @@
|
||||
# Hamock
|
||||
|
||||
## 简介
|
||||
|
||||
Hamock 是 OpenHarmony 上的模拟框架,提供预览场景的模拟功能。
|
||||
|
||||
## 下载安装
|
||||
|
||||
```bash
|
||||
ohpm install @ohos/hamock
|
||||
```
|
||||
|
||||
OpenHarmony ohpm 环境配置等更多内容,请参考[如何安装 OpenHarmony ohpm 包](https://gitee.com/openharmony-tpc/docs/blob/master/OpenHarmony_har_usage.md)
|
||||
|
||||
## 使用示例
|
||||
|
||||
Hamock 提供了 @MockSetup 用于修饰 Mock 方法,仅支持声明式范式的组件。当开发者预览该组件时,预览运行时将在组件初始化时执行被 @MockSetup 修饰的方法。因此,开发者可以在这个被修饰的方法内重定义组件的方法或重赋值组件的属性,其将在预览时生效。
|
||||
|
||||
> 说明:
|
||||
> @MockSetup 修饰的方法仅在预览场景会自动触发,并先于组件的 aboutToAppear 执行。
|
||||
|
||||
### UI组件的方法
|
||||
|
||||
在 ArkTS 页面代码中引入 Hamock。在目标组件中定义一个方法,并用 @MockSetup 修饰该方法。在这个方法中,使用 MockKit 模拟目标方法。
|
||||
|
||||
```typescript
|
||||
import { MockKit, when, MockSetup } from '@ohos/hamock';
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
struct Index {
|
||||
...
|
||||
@MockSetup
|
||||
randomName() {
|
||||
let mocker: MockKit = new MockKit();
|
||||
let mockfunc: Object = mocker.mockFunc(this, this.method1);
|
||||
// mock 指定的方法在指定入参的返回值
|
||||
when(mockfunc)('test').afterReturn(1);
|
||||
}
|
||||
...
|
||||
// 业务场景调用方法
|
||||
const result: number = this.method1('test'); // in previewer, result = 1
|
||||
}
|
||||
```
|
||||
|
||||
### UI组件的属性
|
||||
|
||||
在 ArkTS 页面代码中引入 Hamock。在目标组件中定义一个方法,并用 @MockSetup 修饰该方法。在这个方法中,对于需要 Mock 的属性,可以重新赋值。
|
||||
|
||||
```typescript
|
||||
import { MockSetup } from '@ohos/hamock';
|
||||
|
||||
@Component
|
||||
struct Person {
|
||||
@Prop species: string;
|
||||
...
|
||||
// 在 @MockSetup 片段中,定义对象属性
|
||||
@MockSetup
|
||||
randomName() {
|
||||
this.species = 'primates';
|
||||
}
|
||||
...
|
||||
// 业务场景调用属性(如果从初始化到调用期间,该属性无变化)
|
||||
const result: string = this.species; // in previewer, result = primates
|
||||
}
|
||||
```
|
||||
|
||||
## 约束与限制
|
||||
|
||||
在下述版本验证通过:
|
||||
|
||||
DevEco Studio: 4.1 (4.1.3.400), SDK: API11 (4.1.0.36)
|
||||
|
||||
MockSetup 仅在 API11 支持。
|
||||
|
||||
## 贡献代码
|
||||
|
||||
使用过程中发现任何问题都可以提[Issue](https://gitee.com/openharmony/testfwk_arkxtest/issues) 给我们,当然,我们也非常欢迎你给我们提[PR](https://gitee.com/openharmony/testfwk_arkxtest/pulls) 。
|
||||
|
||||
## 开源协议
|
||||
|
||||
本项目基于 [Apache License 2.0](https://gitee.com/openharmony/testfwk_arkxtest/blob/master/hamock/LICENSE) ,请自由地享受和参与开源。
|
||||
@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright (c) 2023 Huawei Device Co., Ltd.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
{
|
||||
"apiType": "stageMode",
|
||||
"buildOption": {
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"name": "default"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright (c) 2023 Huawei Device Co., Ltd.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// Script for compiling build behavior. It is built in the build plug-in and cannot be modified currently.
|
||||
export { harTasks } from '@ohos/hvigor-ohos-plugin';
|
||||
@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright (c) 2023 Huawei Device Co., Ltd.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License")
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// Script for compiling build behavior. It is built in the build plug-in and cannot be modified currently.
|
||||
export { harTasks } from '@ohos/hvigor-ohos-plugin';
|
||||
58
oh_modules/.ohpm/@ohos+hamock@1.0.0/oh_modules/@ohos/hamock/index.d.ts
vendored
Normal file
58
oh_modules/.ohpm/@ohos+hamock@1.0.0/oh_modules/@ohos/hamock/index.d.ts
vendored
Normal file
@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright (c) 2023 Huawei Device Co., Ltd.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License")
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export class ArgumentMatchers {
|
||||
static any;
|
||||
static anyString;
|
||||
static anyBoolean;
|
||||
static anyNumber;
|
||||
static anyObj;
|
||||
static anyFunction;
|
||||
static matchRegexs(Regex: RegExp): void
|
||||
}
|
||||
|
||||
declare interface when {
|
||||
afterReturn(value: any): any
|
||||
afterReturnNothing(): undefined
|
||||
afterAction(action: any): any
|
||||
afterThrow(e_msg: string): string
|
||||
(argMatchers?: any): when;
|
||||
}
|
||||
|
||||
export const when: when;
|
||||
|
||||
export interface VerificationMode {
|
||||
times(count: Number): void
|
||||
never(): void
|
||||
once(): void
|
||||
atLeast(count: Number): void
|
||||
atMost(count: Number): void
|
||||
}
|
||||
|
||||
export class MockKit {
|
||||
constructor()
|
||||
mockFunc(obj: Object, func: Function): Function
|
||||
mockObject(obj: Object): Object
|
||||
verify(methodName: String, argsArray: Array<any>): VerificationMode
|
||||
ignoreMock(obj: Object, func: Function): void
|
||||
clear(obj: Object): void
|
||||
clearAll(): void
|
||||
}
|
||||
|
||||
export declare function MockSetup(
|
||||
target: Object,
|
||||
propertyName: string | Symbol,
|
||||
descriptor: TypedPropertyDescriptor<() => void>
|
||||
): void;
|
||||
@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright (c) 2023 Huawei Device Co., Ltd.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License")
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { MockSetup, MockKit, when } from './src/main/mock/MockKit';
|
||||
export { ArgumentMatchers } from './src/main/mock/ArgumentMatchers';
|
||||
@ -0,0 +1,16 @@
|
||||
/*
|
||||
* Copyright (c) 2021-2023 Huawei Device Co., Ltd.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License")
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
export { MockSetup, MockKit, when } from './src/main/mock/MockKit.js';
|
||||
export { ArgumentMatchers } from './src/main/mock/ArgumentMatchers.js';
|
||||
@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright (c) 2021-2023 Huawei Device Co., Ltd.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License")
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { MockSetup, MockKit, when } from './src/main/mock/MockKit.js';
|
||||
export { ArgumentMatchers } from './src/main/mock/ArgumentMatchers.js';
|
||||
@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright (c) 2023 Huawei Device Co., Ltd.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
{
|
||||
name: '@ohos/hamock',
|
||||
version: '1.0.0',
|
||||
description: 'A mock framework for OpenHarmony application.',
|
||||
main: 'index.ets',
|
||||
author: 'huawei',
|
||||
license: 'Apache-2.0',
|
||||
dependencies: {},
|
||||
ohos: {
|
||||
org: 'ohos',
|
||||
},
|
||||
types: 'index.d.ts'
|
||||
}
|
||||
@ -0,0 +1,97 @@
|
||||
/*
|
||||
* Copyright (c) 2022 Huawei Device Co., Ltd.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
export class ArgumentMatchers {
|
||||
constructor() {
|
||||
this.ANY = "<any>";
|
||||
this.ANY_STRING = "<any String>";
|
||||
this.ANY_BOOLEAN = "<any Boolean>";
|
||||
this.ANY_NUMBER = "<any Number>";
|
||||
this.ANY_OBJECT = "<any Object>";
|
||||
this.ANY_FUNCTION = "<any Function>";
|
||||
this.MATCH_REGEXS = "<match regexs>";
|
||||
}
|
||||
static any() {
|
||||
}
|
||||
static anyString() {
|
||||
}
|
||||
static anyBoolean() {
|
||||
}
|
||||
static anyNumber() {
|
||||
}
|
||||
static anyObj() {
|
||||
}
|
||||
static anyFunction() {
|
||||
}
|
||||
static matchRegexs(regex) {
|
||||
if (ArgumentMatchers.isRegExp(regex)) {
|
||||
return regex;
|
||||
}
|
||||
throw Error("not a regex");
|
||||
}
|
||||
static isRegExp(value) {
|
||||
return Object.prototype.toString.call(value) === "[object RegExp]";
|
||||
}
|
||||
matcheReturnKey(...args) {
|
||||
let arg = args[0];
|
||||
let regex = args[1];
|
||||
let stubSetKey = args[2];
|
||||
if (stubSetKey && stubSetKey == this.ANY) {
|
||||
return this.ANY;
|
||||
}
|
||||
if (typeof arg === "string" && !regex) {
|
||||
return this.ANY_STRING;
|
||||
}
|
||||
if (typeof arg === "boolean" && !regex) {
|
||||
return this.ANY_BOOLEAN;
|
||||
}
|
||||
if (typeof arg === "number" && !regex) {
|
||||
return this.ANY_NUMBER;
|
||||
}
|
||||
if (typeof arg === "object" && !regex) {
|
||||
return this.ANY_OBJECT;
|
||||
}
|
||||
if (typeof arg === "function" && !regex) {
|
||||
return this.ANY_FUNCTION;
|
||||
}
|
||||
if (typeof arg === "string" && regex) {
|
||||
return regex.test(arg);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
matcheStubKey(key) {
|
||||
if (key === ArgumentMatchers.any) {
|
||||
return this.ANY;
|
||||
}
|
||||
if (key === ArgumentMatchers.anyString) {
|
||||
return this.ANY_STRING;
|
||||
}
|
||||
if (key === ArgumentMatchers.anyBoolean) {
|
||||
return this.ANY_BOOLEAN;
|
||||
}
|
||||
if (key === ArgumentMatchers.anyNumber) {
|
||||
return this.ANY_NUMBER;
|
||||
}
|
||||
if (key === ArgumentMatchers.anyObj) {
|
||||
return this.ANY_OBJECT;
|
||||
}
|
||||
if (key === ArgumentMatchers.anyFunction) {
|
||||
return this.ANY_FUNCTION;
|
||||
}
|
||||
if (ArgumentMatchers.isRegExp(key)) {
|
||||
return key;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,118 @@
|
||||
/*
|
||||
* Copyright (c) 2022 Huawei Device Co., Ltd.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export class ArgumentMatchers {
|
||||
ANY = "<any>";
|
||||
ANY_STRING = "<any String>";
|
||||
ANY_BOOLEAN = "<any Boolean>";
|
||||
ANY_NUMBER = "<any Number>";
|
||||
ANY_OBJECT = "<any Object>";
|
||||
ANY_FUNCTION = "<any Function>";
|
||||
MATCH_REGEXS = "<match regexs>";
|
||||
|
||||
static any() {
|
||||
}
|
||||
|
||||
static anyString() {
|
||||
}
|
||||
|
||||
static anyBoolean() {
|
||||
}
|
||||
|
||||
static anyNumber() {
|
||||
}
|
||||
|
||||
static anyObj() {
|
||||
}
|
||||
|
||||
static anyFunction() {
|
||||
}
|
||||
|
||||
static matchRegexs(regex: any) {
|
||||
if (ArgumentMatchers.isRegExp(regex)) {
|
||||
return regex;
|
||||
}
|
||||
throw Error("not a regex");
|
||||
}
|
||||
|
||||
static isRegExp(value: string) {
|
||||
return Object.prototype.toString.call(value) === "[object RegExp]";
|
||||
}
|
||||
|
||||
matcheReturnKey(...args: Array<any>) {
|
||||
let arg = args[0];
|
||||
let regex = args[1];
|
||||
let stubSetKey = args[2];
|
||||
|
||||
if (stubSetKey && stubSetKey == this.ANY) {
|
||||
return this.ANY;
|
||||
}
|
||||
|
||||
if (typeof arg === "string" && !regex) {
|
||||
return this.ANY_STRING;
|
||||
}
|
||||
|
||||
if (typeof arg === "boolean" && !regex) {
|
||||
return this.ANY_BOOLEAN;
|
||||
}
|
||||
|
||||
if (typeof arg === "number" && !regex) {
|
||||
return this.ANY_NUMBER;
|
||||
}
|
||||
|
||||
if (typeof arg === "object" && !regex) {
|
||||
return this.ANY_OBJECT;
|
||||
}
|
||||
|
||||
if (typeof arg === "function" && !regex) {
|
||||
return this.ANY_FUNCTION;
|
||||
}
|
||||
|
||||
if (typeof arg === "string" && regex) {
|
||||
return regex.test(arg);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
matcheStubKey(key: any) {
|
||||
|
||||
if (key === ArgumentMatchers.any) {
|
||||
return this.ANY;
|
||||
}
|
||||
|
||||
if (key === ArgumentMatchers.anyString) {
|
||||
return this.ANY_STRING;
|
||||
}
|
||||
if (key === ArgumentMatchers.anyBoolean) {
|
||||
return this.ANY_BOOLEAN;
|
||||
}
|
||||
if (key === ArgumentMatchers.anyNumber) {
|
||||
return this.ANY_NUMBER;
|
||||
}
|
||||
if (key === ArgumentMatchers.anyObj) {
|
||||
return this.ANY_OBJECT;
|
||||
}
|
||||
if (key === ArgumentMatchers.anyFunction) {
|
||||
return this.ANY_FUNCTION;
|
||||
}
|
||||
|
||||
if (ArgumentMatchers.isRegExp(key)) {
|
||||
return key;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright (c) 2022 Huawei Device Co., Ltd.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
class ExtendInterface {
|
||||
constructor(mocker) {
|
||||
this.mocker = mocker;
|
||||
}
|
||||
stub() {
|
||||
this.params = arguments;
|
||||
return this;
|
||||
}
|
||||
stubMockedCall(returnInfo) {
|
||||
this.mocker.stubApply(this, this.params, returnInfo);
|
||||
}
|
||||
afterReturn(value) {
|
||||
this.stubMockedCall(function () {
|
||||
return value;
|
||||
});
|
||||
}
|
||||
afterReturnNothing() {
|
||||
this.stubMockedCall(function () {
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
afterAction(action) {
|
||||
this.stubMockedCall(action);
|
||||
}
|
||||
afterThrow(msg) {
|
||||
this.stubMockedCall(function () {
|
||||
throw msg;
|
||||
});
|
||||
}
|
||||
clear(obj) {
|
||||
this.mocker.clear(obj);
|
||||
}
|
||||
}
|
||||
export default ExtendInterface;
|
||||
@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Copyright (c) 2022 Huawei Device Co., Ltd.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { MockKit } from "./MockKit.js";
|
||||
|
||||
class ExtendInterface {
|
||||
|
||||
private mocker: MockKit
|
||||
private params: any
|
||||
|
||||
constructor(mocker: MockKit) {
|
||||
this.mocker = mocker;
|
||||
}
|
||||
|
||||
stub() {
|
||||
this.params = arguments;
|
||||
return this;
|
||||
}
|
||||
|
||||
stubMockedCall(returnInfo: any) {
|
||||
this.mocker.stubApply(this, this.params, returnInfo);
|
||||
}
|
||||
|
||||
afterReturn(value: any) {
|
||||
this.stubMockedCall(function () {
|
||||
return value;
|
||||
});
|
||||
}
|
||||
|
||||
afterReturnNothing() {
|
||||
this.stubMockedCall(function () {
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
|
||||
afterAction(action: Function) {
|
||||
this.stubMockedCall(action);
|
||||
}
|
||||
|
||||
afterThrow(msg: string) {
|
||||
this.stubMockedCall(function () {
|
||||
throw msg;
|
||||
});
|
||||
}
|
||||
|
||||
clear(obj?: any) {
|
||||
this.mocker.clear(obj);
|
||||
}
|
||||
}
|
||||
|
||||
export default ExtendInterface;
|
||||
@ -0,0 +1,253 @@
|
||||
/*
|
||||
* Copyright (c) 2022-2023 Huawei Device Co., Ltd.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import ExtendInterface from "./ExtendInterface.js";
|
||||
import VerificationMode from "./VerificationMode.js";
|
||||
import { ArgumentMatchers } from "./ArgumentMatchers.js";
|
||||
class MockKit {
|
||||
constructor() {
|
||||
this.mFunctions = [];
|
||||
this.stubs = new Map();
|
||||
this.recordCalls = new Map();
|
||||
this.currentSetKey = new Map();
|
||||
this.mockObj = null;
|
||||
this.recordMockedMethod = new Map();
|
||||
this.mFunctions = [];
|
||||
this.stubs = new Map();
|
||||
this.recordCalls = new Map();
|
||||
this.currentSetKey = new Map();
|
||||
this.mockObj = null;
|
||||
this.recordMockedMethod = new Map();
|
||||
}
|
||||
init() {
|
||||
this.reset();
|
||||
}
|
||||
reset() {
|
||||
this.mFunctions = [];
|
||||
this.stubs = new Map();
|
||||
this.recordCalls = new Map();
|
||||
this.currentSetKey = new Map();
|
||||
this.mockObj = null;
|
||||
this.recordMockedMethod = new Map();
|
||||
}
|
||||
clearAll() {
|
||||
this.reset();
|
||||
}
|
||||
clear(obj) {
|
||||
if (!obj) throw Error("Please enter an object to be cleaned");
|
||||
if (typeof (obj) !== 'object' && typeof (obj) !== 'function') throw new Error('Not a object or static class');
|
||||
this.recordMockedMethod.forEach(function (value, key, map) {
|
||||
if (key) {
|
||||
obj[key] = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
ignoreMock(obj, method) {
|
||||
if (typeof (obj) !== 'object' && typeof (obj) !== 'function') throw new Error('Not a object or static class');
|
||||
if (typeof (method) !== 'function') throw new Error('Not a function');
|
||||
let og = this.recordMockedMethod.get(method.propName);
|
||||
if (og) {
|
||||
obj[method.propName] = og;
|
||||
this.recordMockedMethod.set(method.propName, undefined);
|
||||
}
|
||||
}
|
||||
extend(dest, source) {
|
||||
dest["stub"] = source["stub"];
|
||||
dest["afterReturn"] = source["afterReturn"];
|
||||
dest["afterReturnNothing"] = source["afterReturnNothing"];
|
||||
dest["afterAction"] = source["afterAction"];
|
||||
dest["afterThrow"] = source["afterThrow"];
|
||||
dest["stubMockedCall"] = source["stubMockedCall"];
|
||||
dest["clear"] = source["clear"];
|
||||
return dest;
|
||||
}
|
||||
stubApply(f, params, returnInfo) {
|
||||
let values = this.stubs.get(f);
|
||||
if (!values) {
|
||||
values = new Map();
|
||||
}
|
||||
let key = params[0];
|
||||
if (typeof key === "undefined") {
|
||||
key = "anonymous-mock-" + f.propName;
|
||||
}
|
||||
let matcher = new ArgumentMatchers();
|
||||
if (matcher.matcheStubKey(key)) {
|
||||
key = matcher.matcheStubKey(key);
|
||||
if (key) {
|
||||
this.currentSetKey.set(f, key);
|
||||
}
|
||||
}
|
||||
values.set(key, returnInfo);
|
||||
this.stubs.set(f, values);
|
||||
}
|
||||
getReturnInfo(f, params) {
|
||||
let values = this.stubs.get(f);
|
||||
if (!values) {
|
||||
return undefined;
|
||||
}
|
||||
let retrunKet = params[0];
|
||||
if (typeof retrunKet === "undefined") {
|
||||
retrunKet = "anonymous-mock-" + f.propName;
|
||||
}
|
||||
let stubSetKey = this.currentSetKey.get(f);
|
||||
|
||||
if (stubSetKey && (typeof (retrunKet) !== "undefined")) {
|
||||
retrunKet = stubSetKey;
|
||||
}
|
||||
let matcher = new ArgumentMatchers();
|
||||
if (matcher.matcheReturnKey(params[0], undefined, stubSetKey) && matcher.matcheReturnKey(params[0], undefined, stubSetKey) !== stubSetKey) {
|
||||
retrunKet = params[0];
|
||||
}
|
||||
values.forEach(function (value, key, map) {
|
||||
if (ArgumentMatchers.isRegExp(key) && matcher.matcheReturnKey(params[0], key)) {
|
||||
retrunKet = key;
|
||||
}
|
||||
});
|
||||
return values.get(retrunKet);
|
||||
}
|
||||
findName(obj, value) {
|
||||
let properties = this.findProperties(obj);
|
||||
let name = '';
|
||||
properties.filter((item) => (item !== 'caller' && item !== 'arguments')).forEach(function (va1, idx, array) {
|
||||
if (obj[va1] === value) {
|
||||
name = va1;
|
||||
}
|
||||
});
|
||||
return name;
|
||||
}
|
||||
isFunctionFromPrototype(f, container, propName) {
|
||||
if (container.constructor !== Object && container.constructor.prototype !== container) {
|
||||
return container.constructor.prototype[propName] === f;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
findProperties(obj, ...arg) {
|
||||
function getProperty(new_obj) {
|
||||
if (new_obj.__proto__ === null) {
|
||||
return [];
|
||||
}
|
||||
let properties = Object.getOwnPropertyNames(new_obj);
|
||||
return [...properties, ...getProperty(new_obj.__proto__)];
|
||||
}
|
||||
return getProperty(obj);
|
||||
}
|
||||
recordMethodCall(originalMethod, args) {
|
||||
originalMethod['getName'] = function () {
|
||||
return this.name || this.toString().match(/function\s*([^(]*)\(/)[1];
|
||||
};
|
||||
let name = originalMethod.getName();
|
||||
let arglistString = name + '(' + Array.from(args).toString() + ')';
|
||||
let records = this.recordCalls.get(arglistString);
|
||||
if (!records) {
|
||||
records = 0;
|
||||
}
|
||||
records++;
|
||||
this.recordCalls.set(arglistString, records);
|
||||
}
|
||||
mockFunc(originalObject, originalMethod) {
|
||||
let tmp = this;
|
||||
this.originalMethod = originalMethod;
|
||||
const _this = this;
|
||||
let f = function () {
|
||||
let args = arguments;
|
||||
let action = tmp.getReturnInfo(f, args);
|
||||
if (originalMethod) {
|
||||
tmp.recordMethodCall(originalMethod, args);
|
||||
}
|
||||
if (action) {
|
||||
return action.apply(_this, args);
|
||||
}
|
||||
};
|
||||
f.container = null || originalObject;
|
||||
f.original = originalMethod || null;
|
||||
if (originalObject && originalMethod) {
|
||||
if (typeof (originalMethod) != 'function')
|
||||
throw new Error('Not a function');
|
||||
var name = this.findName(originalObject, originalMethod);
|
||||
originalObject[name] = f;
|
||||
this.recordMockedMethod.set(name, originalMethod);
|
||||
f.propName = name;
|
||||
f.originalFromPrototype = this.isFunctionFromPrototype(f.original, originalObject, f.propName);
|
||||
}
|
||||
f.mocker = this;
|
||||
this.mFunctions.push(f);
|
||||
this.extend(f, new ExtendInterface(this));
|
||||
return f;
|
||||
}
|
||||
verify(methodName, argsArray) {
|
||||
if (!methodName) {
|
||||
throw Error("not a function name");
|
||||
}
|
||||
let a = this.recordCalls.get(methodName + '(' + argsArray.toString() + ')');
|
||||
return new VerificationMode(a ? a : 0);
|
||||
}
|
||||
mockObject(object) {
|
||||
if (!object || typeof object === "string") {
|
||||
throw Error(`this ${object} cannot be mocked`);
|
||||
}
|
||||
const _this = this;
|
||||
let mockedObject = {};
|
||||
let keys = Reflect.ownKeys(object);
|
||||
keys.filter(key => (typeof Reflect.get(object, key)) === 'function')
|
||||
.forEach((key) => {
|
||||
mockedObject[key] = object[key];
|
||||
mockedObject[key] = _this.mockFunc(mockedObject, mockedObject[key]);
|
||||
});
|
||||
return mockedObject;
|
||||
}
|
||||
}
|
||||
function ifMockedFunction(f) {
|
||||
if (Object.prototype.toString.call(f) != "[object Function]" &&
|
||||
Object.prototype.toString.call(f) != "[object AsyncFunction]") {
|
||||
throw Error("not a function");
|
||||
}
|
||||
if (!f.stub) {
|
||||
throw Error("not a mock function");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
function when(f) {
|
||||
if (ifMockedFunction(f)) {
|
||||
return f.stub.bind(f);
|
||||
}
|
||||
}
|
||||
function MockSetup(target, propertyName, descriptor) {
|
||||
const aboutToAppearOrigin = target.aboutToAppear;
|
||||
const setup = descriptor.value;
|
||||
target.aboutToAppear = function (...args) {
|
||||
if (target.__Param) { // copy attributes and params of the original context
|
||||
try {
|
||||
const map = target.__Param;
|
||||
for (const [key, val] of map) {
|
||||
this[key] = val; // 'this' refers to context of current function
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
throw new Error(`Mock setup param error: ${e}`);
|
||||
}
|
||||
}
|
||||
if (setup) { // apply the mock content
|
||||
try {
|
||||
setup.apply(this);
|
||||
}
|
||||
catch (e) {
|
||||
throw new Error(`Mock setup apply error: ${e}`);
|
||||
}
|
||||
}
|
||||
if (aboutToAppearOrigin) { // append to aboutToAppear function of the original context
|
||||
aboutToAppearOrigin.apply(this, args);
|
||||
}
|
||||
};
|
||||
}
|
||||
export { MockSetup, MockKit, when };
|
||||
@ -0,0 +1,294 @@
|
||||
/*
|
||||
* Copyright (c) 2023 Huawei Device Co., Ltd.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import ExtendInterface from "./ExtendInterface.js";
|
||||
import VerificationMode from "./VerificationMode.js";
|
||||
import { ArgumentMatchers } from "./ArgumentMatchers.js";
|
||||
|
||||
interface IFunction extends Function {
|
||||
container: any;
|
||||
original: any;
|
||||
propName: string;
|
||||
originalFromPrototype: boolean
|
||||
mocker: MockKit
|
||||
}
|
||||
|
||||
class MockKit {
|
||||
|
||||
private mFunctions:Array<any> = [];
|
||||
private stubs = new Map();
|
||||
private recordCalls = new Map();
|
||||
private currentSetKey = new Map();
|
||||
private mockObj = null;
|
||||
private recordMockedMethod = new Map();
|
||||
private originalMethod: any;
|
||||
|
||||
constructor() {
|
||||
this.mFunctions = [];
|
||||
this.stubs = new Map();
|
||||
this.recordCalls = new Map();
|
||||
this.currentSetKey = new Map();
|
||||
this.mockObj = null;
|
||||
this.recordMockedMethod = new Map();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.reset();
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.mFunctions = [];
|
||||
this.stubs = new Map()
|
||||
this.recordCalls = new Map();
|
||||
this.currentSetKey = new Map();
|
||||
this.mockObj = null;
|
||||
this.recordMockedMethod = new Map();
|
||||
}
|
||||
|
||||
clearAll() {
|
||||
this.reset();
|
||||
}
|
||||
|
||||
clear(obj: any) {
|
||||
if (!obj) throw Error("Please enter an object to be cleaned");
|
||||
if (typeof (obj) != 'object') throw new Error('Not a object');
|
||||
this.recordMockedMethod.forEach(function (value, key, map) {
|
||||
if (key) {
|
||||
obj[key] = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ignoreMock(obj:any, method: any) {
|
||||
if (typeof (obj) != 'object') throw new Error('Not a object');
|
||||
if (typeof (method) != 'function') throw new Error('Not a function');
|
||||
let og = this.recordMockedMethod.get(method.propName);
|
||||
if (og) {
|
||||
obj[method.propName] = og;
|
||||
this.recordMockedMethod.set(method.propName, undefined);
|
||||
}
|
||||
}
|
||||
|
||||
extend(dest: any, source:any) {
|
||||
dest["stub"] = source["stub"];
|
||||
dest["afterReturn"] = source["afterReturn"];
|
||||
dest["afterReturnNothing"] = source["afterReturnNothing"];
|
||||
dest["afterAction"] = source["afterAction"];
|
||||
dest["afterThrow"] = source["afterThrow"];
|
||||
dest["stubMockedCall"] = source["stubMockedCall"];
|
||||
dest["clear"] = source["clear"];
|
||||
return dest;
|
||||
}
|
||||
|
||||
stubApply(f: any, params:any, returnInfo:any) {
|
||||
let values = this.stubs.get(f);
|
||||
if (!values) {
|
||||
values = new Map();
|
||||
}
|
||||
let key = params[0];
|
||||
if (typeof key == "undefined") {
|
||||
key = "anonymous-mock-" + f.propName;
|
||||
}
|
||||
let matcher = new ArgumentMatchers();
|
||||
if (matcher.matcheStubKey(key)) {
|
||||
key = matcher.matcheStubKey(key);
|
||||
if (key) {
|
||||
this.currentSetKey.set(f, key);
|
||||
}
|
||||
}
|
||||
values.set(key, returnInfo);
|
||||
this.stubs.set(f, values);
|
||||
}
|
||||
|
||||
getReturnInfo(f: any, params:any) {
|
||||
let values = this.stubs.get(f);
|
||||
if (!values) {
|
||||
return undefined;
|
||||
}
|
||||
let retrunKet = params[0];
|
||||
if (typeof retrunKet == "undefined") {
|
||||
retrunKet = "anonymous-mock-" + f.propName;
|
||||
}
|
||||
let stubSetKey = this.currentSetKey.get(f);
|
||||
|
||||
if (stubSetKey && (typeof (retrunKet) != "undefined")) {
|
||||
retrunKet = stubSetKey;
|
||||
}
|
||||
let matcher = new ArgumentMatchers();
|
||||
if (matcher.matcheReturnKey(params[0], undefined, stubSetKey) && matcher.matcheReturnKey(params[0], undefined, stubSetKey) != stubSetKey) {
|
||||
retrunKet = params[0];
|
||||
}
|
||||
|
||||
values.forEach(function (value: any, key: any, map: any) {
|
||||
if (ArgumentMatchers.isRegExp(key) && matcher.matcheReturnKey(params[0], key)) {
|
||||
retrunKet = key;
|
||||
}
|
||||
});
|
||||
|
||||
return values.get(retrunKet);
|
||||
}
|
||||
|
||||
findName(obj: any, value: any) {
|
||||
let properties = this.findProperties(obj);
|
||||
let name = '';
|
||||
properties.filter((item:any) => (item !== 'caller' && item !== 'arguments')).forEach(
|
||||
function (va1:any, idx:any, array:any) {
|
||||
if (obj[va1] === value) {
|
||||
name = va1;
|
||||
}
|
||||
}
|
||||
);
|
||||
return name;
|
||||
}
|
||||
|
||||
isFunctionFromPrototype(f: Function, container:Function, propName: string) {
|
||||
if (container.constructor != Object && container.constructor.prototype !== container) {
|
||||
return container.constructor.prototype[propName] === f;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
findProperties(obj: any, ...arg: Array<any>) {
|
||||
function getProperty(new_obj:any): Array<any> {
|
||||
if (new_obj.__proto__ === null) {
|
||||
return [];
|
||||
}
|
||||
let properties = Object.getOwnPropertyNames(new_obj);
|
||||
return [...properties, ...getProperty(new_obj.__proto__)];
|
||||
}
|
||||
return getProperty(obj);
|
||||
}
|
||||
|
||||
recordMethodCall(originalMethod: any, args: any) {
|
||||
originalMethod['getName'] = function () {
|
||||
return this.name || this.toString().match(/function\s*([^(]*)\(/)[1];
|
||||
}
|
||||
let name = originalMethod.getName();
|
||||
let arglistString = name + '(' + Array.from(args).toString() + ')';
|
||||
let records = this.recordCalls.get(arglistString);
|
||||
if (!records) {
|
||||
records = 0;
|
||||
}
|
||||
records++;
|
||||
this.recordCalls.set(arglistString, records);
|
||||
}
|
||||
|
||||
mockFunc(originalObject:any, originalMethod:any) {
|
||||
let tmp = this;
|
||||
this.originalMethod = originalMethod;
|
||||
const _this = this;
|
||||
let f:any = function () {
|
||||
let args = arguments;
|
||||
let action = tmp.getReturnInfo(f, args);
|
||||
if (originalMethod) {
|
||||
tmp.recordMethodCall(originalMethod, args);
|
||||
}
|
||||
if (action) {
|
||||
return <IFunction> action.apply(_this, args);
|
||||
}
|
||||
};
|
||||
|
||||
f.container = null || originalObject;
|
||||
f.original = originalMethod || null;
|
||||
|
||||
if (originalObject && originalMethod) {
|
||||
if (typeof (originalMethod) != 'function') throw new Error('Not a function');
|
||||
var name = this.findName(originalObject, originalMethod);
|
||||
originalObject[name] = f;
|
||||
this.recordMockedMethod.set(name, originalMethod);
|
||||
f.propName = name;
|
||||
f.originalFromPrototype = this.isFunctionFromPrototype(f.original, originalObject, f.propName);
|
||||
}
|
||||
f.mocker = this;
|
||||
this.mFunctions.push(f);
|
||||
this.extend(f, new ExtendInterface(this));
|
||||
return f;
|
||||
}
|
||||
|
||||
verify(methodName:any, argsArray:any) {
|
||||
if (!methodName) {
|
||||
throw Error("not a function name");
|
||||
}
|
||||
let a = this.recordCalls.get(methodName + '(' + argsArray.toString() + ')');
|
||||
return new VerificationMode(a ? a : 0);
|
||||
}
|
||||
|
||||
mockObject(object: any) {
|
||||
if (!object || typeof object === "string") {
|
||||
throw Error(`this ${object} cannot be mocked`);
|
||||
}
|
||||
const _this = this;
|
||||
let mockedObject:any = {};
|
||||
let keys = Reflect.ownKeys(object);
|
||||
keys.filter(key => (typeof Reflect.get(object, key)) === 'function')
|
||||
.forEach((key:any) => {
|
||||
mockedObject[key] = object[key];
|
||||
mockedObject[key] = _this.mockFunc(mockedObject, mockedObject[key]);
|
||||
});
|
||||
return mockedObject;
|
||||
}
|
||||
}
|
||||
|
||||
function ifMockedFunction(f: any) {
|
||||
if (Object.prototype.toString.call(f) != "[object Function]" &&
|
||||
Object.prototype.toString.call(f) != "[object AsyncFunction]") {
|
||||
throw Error("not a function");
|
||||
}
|
||||
if (!f.stub) {
|
||||
throw Error("not a mock function");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function when(f: any) {
|
||||
if (ifMockedFunction(f)) {
|
||||
return f.stub.bind(f);
|
||||
}
|
||||
}
|
||||
|
||||
function MockSetup(target: Object, propertyName: string | Symbol, descriptor: TypedPropertyDescriptor<() => void>): void {
|
||||
const aboutToAppearOrigin = target.aboutToAppear;
|
||||
const setup = descriptor.value;
|
||||
target.aboutToAppear = function (...args: any[]) {
|
||||
if (target.__Param) { // copy attributes and params of the original context
|
||||
try {
|
||||
const map = target.__Param as Map<string, unknown>;
|
||||
for (const [key, val] of map) {
|
||||
this[key] = val; // 'this' refers to context of current function
|
||||
}
|
||||
} catch (e) {
|
||||
throw new Error(`Mock setup param error: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (setup) { // apply the mock content
|
||||
try {
|
||||
setup.apply(this);
|
||||
} catch (e) {
|
||||
throw new Error(`Mock setup apply error: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (aboutToAppearOrigin) { // append to aboutToAppear function of the original context
|
||||
aboutToAppearOrigin.apply(this, args);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
MockSetup,
|
||||
MockKit,
|
||||
when
|
||||
};
|
||||
@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright (c) 2022-2023 Huawei Device Co., Ltd.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
class VerificationMode {
|
||||
constructor(times) {
|
||||
this.doTimes = times;
|
||||
}
|
||||
times(count) {
|
||||
if (count !== this.doTimes) {
|
||||
throw Error(`expect ${count} actual ${this.doTimes}`);
|
||||
}
|
||||
}
|
||||
never() {
|
||||
if (this.doTimes !== 0) {
|
||||
throw Error(`expect 0 actual ${this.doTimes}`);
|
||||
}
|
||||
}
|
||||
once() {
|
||||
if (this.doTimes !== 1) {
|
||||
throw Error(`expect 1 actual ${this.doTimes}`);
|
||||
}
|
||||
}
|
||||
atLeast(count) {
|
||||
if (count > this.doTimes) {
|
||||
throw Error('failed ' + count + ' greater than the actual execution times of method');
|
||||
}
|
||||
}
|
||||
atMost(count) {
|
||||
if (count < this.doTimes) {
|
||||
throw Error('failed ' + count + ' less than the actual execution times of method');
|
||||
}
|
||||
}
|
||||
}
|
||||
export default VerificationMode;
|
||||
@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright (c) 2022-2023 Huawei Device Co., Ltd.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
class VerificationMode {
|
||||
|
||||
private doTimes: number
|
||||
|
||||
constructor(times: number) {
|
||||
this.doTimes = times;
|
||||
}
|
||||
|
||||
times(count: number) {
|
||||
if(count !== this.doTimes) {
|
||||
throw Error(`expect ${count} actual ${this.doTimes}`);
|
||||
}
|
||||
}
|
||||
|
||||
never() {
|
||||
if (this.doTimes !== 0) {
|
||||
throw Error(`expect 0 actual ${this.doTimes}`);
|
||||
}
|
||||
}
|
||||
|
||||
once() {
|
||||
if (this.doTimes !== 1) {
|
||||
throw Error(`expect 1 actual ${this.doTimes}`);
|
||||
}
|
||||
}
|
||||
|
||||
atLeast(count: number) {
|
||||
if (count > this.doTimes) {
|
||||
throw Error('failed ' + count + ' greater than the actual execution times of method');
|
||||
}
|
||||
}
|
||||
|
||||
atMost(count: number) {
|
||||
if (count < this.doTimes) {
|
||||
throw Error('failed ' + count + ' less than the actual execution times of method');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default VerificationMode;
|
||||
@ -0,0 +1,22 @@
|
||||
{
|
||||
"app": {
|
||||
"bundleName": "com.example.hamock",
|
||||
"debug": true,
|
||||
"versionCode": 1000000,
|
||||
"versionName": "1.0.0",
|
||||
"minAPIVersion": 9,
|
||||
"targetAPIVersion": 9,
|
||||
"apiReleaseType": "Release"
|
||||
},
|
||||
"module": {
|
||||
"name": "hamock",
|
||||
"type": "har",
|
||||
"deviceTypes": [
|
||||
"default",
|
||||
"tablet",
|
||||
"tv",
|
||||
"wearable",
|
||||
"car"
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-04/schema#",
|
||||
"title": "JSON schema for mock-config.json5 file",
|
||||
"definitions": {
|
||||
"sourceRedirection": {
|
||||
"description": "A source redirection for mocked module.",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"source"
|
||||
],
|
||||
"properties": {
|
||||
"source": {
|
||||
"type": "string",
|
||||
"maxLength": 128,
|
||||
"minLength": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"patternProperties": {
|
||||
".+": {
|
||||
"$ref": "#/definitions/sourceRedirection"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Use these variables when you tailor your ArkTS code. They must be of the const type.
|
||||
*/
|
||||
export const HAR_VERSION = '1.0.24';
|
||||
export const BUILD_MODE_NAME = 'debug';
|
||||
export const DEBUG = true;
|
||||
export const TARGET_NAME = 'default';
|
||||
|
||||
/**
|
||||
* BuildProfile Class is used only for compatibility purposes.
|
||||
*/
|
||||
export default class BuildProfile {
|
||||
static readonly HAR_VERSION = HAR_VERSION;
|
||||
static readonly BUILD_MODE_NAME = BUILD_MODE_NAME;
|
||||
static readonly DEBUG = DEBUG;
|
||||
static readonly TARGET_NAME = TARGET_NAME;
|
||||
}
|
||||
@ -0,0 +1,33 @@
|
||||
### 1.0.24
|
||||
- 提示信息优化
|
||||
### 1.0.23
|
||||
- 断言错误提示信息优化
|
||||
### 1.0.22
|
||||
- mock五参数失败问题修复
|
||||
### 1.0.21
|
||||
- mock支持多参数
|
||||
- describe中异步函数抛出日志信息
|
||||
- 修复多测试套时,执行单个测试套会打印其他测试套的日志信息
|
||||
## 1.0.14
|
||||
- 堆栈信息打印到cmd
|
||||
## 1.0.15
|
||||
- 支持获取测试代码的失败堆栈信息
|
||||
- mock代码迁移至harmock包
|
||||
- 适配arkts语法
|
||||
- 修复覆盖率数据容易截断的bug
|
||||
## 1.0.16
|
||||
- 修改覆盖率文件生成功能
|
||||
- 修改静态方法无法ignoreMock函数
|
||||
## 1.0.17
|
||||
- 修改not断言失败提示日志
|
||||
- 自定义错误message信息
|
||||
- 添加xdescribe, xit API功能
|
||||
## 1.0.18
|
||||
- 添加全局变量存储API get set
|
||||
- 自定义断言功能
|
||||
## 1.0.18-rc.0
|
||||
添加框架worker执行能力
|
||||
## 1.0.19
|
||||
规范日志格式
|
||||
# 1.0.20
|
||||
代码告警整改
|
||||
@ -0,0 +1,177 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
@ -0,0 +1,229 @@
|
||||
<div style="text-align: center;font-size: xxx-large" >Hypium</div>
|
||||
<div style="text-align: center">A unit test framework for OpenHarmonyOS application</div>
|
||||
|
||||
## Hypium是什么?
|
||||
***
|
||||
- Hypium是OpenHarmony上的测试框架,提供测试用例编写、执行、结果显示能力,用于OpenHarmony系统应用接口以及应用界面测试。
|
||||
- Hypium结构化模型:hypium工程主要由List.test.js与TestCase.test.js组成。
|
||||
```
|
||||
rootProject // Hypium工程根目录
|
||||
├── moduleA
|
||||
│ ├── src
|
||||
│ ├── main // 被测试应用目录
|
||||
│ ├── ohosTest // 测试用例目录
|
||||
│ ├── js/ets
|
||||
│ └── test
|
||||
│ └── List.test.js // 测试用例加载脚本,ets目录下为.ets后缀
|
||||
│ └── TestCase.test.js // 测试用例脚本,ets目录下为.ets后缀
|
||||
└── moduleB
|
||||
...
|
||||
│ └── List.test.js // 测试用例加载脚本,ets目录下为.ets后缀
|
||||
│ └── TestCase.test.js // 测试用例脚本,ets目录下为.ets后缀
|
||||
```
|
||||
|
||||
## 安装使用
|
||||
|
||||
- 方式一
|
||||
```javascript
|
||||
ohpm i @ohos/hypium
|
||||
```
|
||||
- 方式二
|
||||
***
|
||||
- 在DevEco Studio内使用Hypium
|
||||
- 工程级oh-package.json5内配置:
|
||||
```json
|
||||
"dependencies": {
|
||||
"@ohos/hypium": "1.0.24"
|
||||
}
|
||||
```
|
||||
注:
|
||||
hypium服务于OpenHarmonyOS应用对外接口测试、系统对外接口测试(SDK中接口),完成HAP自动化测试。详细指导:
|
||||
[Deveco Studio](https://developer.harmonyos.com/cn/develop/deveco-studio)
|
||||
|
||||
#### 通用语法
|
||||
|
||||
- 测试用例采用业内通用语法,describe代表一个测试套, it代表一条用例。
|
||||
|
||||
| No. | API | 功能说明 |
|
||||
|-----| ----------------- |------------------------------------------------------------------------|
|
||||
| 1 | describe | 定义一个测试套,支持两个参数:测试套名称和测试套函数。其中测试套函数不能是异步函数 |
|
||||
| 2 | beforeAll | 在测试套内定义一个预置条件,在所有测试用例开始前执行且仅执行一次,支持一个参数:预置动作函数。 |
|
||||
| 3 | beforeEach | 在测试套内定义一个单元预置条件,在每条测试用例开始前执行,执行次数与it定义的测试用例数一致,支持一个参数:预置动作函数。 |
|
||||
| 4 | afterEach | 在测试套内定义一个单元清理条件,在每条测试用例结束后执行,执行次数与it定义的测试用例数一致,支持一个参数:清理动作函数。 |
|
||||
| 5 | afterAll | 在测试套内定义一个清理条件,在所有测试用例结束后执行且仅执行一次,支持一个参数:清理动作函数。 |
|
||||
| 6 | beforeItSpecified | @since1.0.15在测试套内定义一个单元预置条件,仅在指定测试用例开始前执行,支持两个参数:单个用例名称或用例名称数组、预置动作函数。 |
|
||||
| 7 | afterItSpecified | @since1.0.15在测试套内定义一个单元清理条件,仅在指定测试用例结束后执行,支持两个参数:单个用例名称或用例名称数组、清理动作函数 |
|
||||
| 8 | it | 定义一条测试用例,支持三个参数:用例名称,过滤参数和用例函数。 |
|
||||
| 9 | expect | 支持bool类型判断等多种断言方法。 |
|
||||
| 10 | xdescribe | @since1.0.17定义一个跳过的测试套,支持两个参数:测试套名称和测试套函数。 |
|
||||
| 11 | xit | @since1.0.17定义一条跳过的测试用例,支持三个参数:用例名称,过滤参数和用例函数。 | |
|
||||
|
||||
#### 断言库
|
||||
|
||||
- 示例代码:
|
||||
|
||||
```javascript
|
||||
expect(${actualvalue}).assertX(${expectvalue})
|
||||
```
|
||||
|
||||
- 断言功能列表:
|
||||
|
||||
| No. | API | 功能说明 |
|
||||
| :--- | :------------------------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| 1 | assertClose | 检验actualvalue和expectvalue(0)的接近程度是否是expectValue(1) |
|
||||
| 2 | assertContain | 检验actualvalue中是否包含expectvalue |
|
||||
| 3 | assertDeepEquals | @since1.0.4 检验actualvalue和expectvalue(0)是否是同一个对象 |
|
||||
| 4 | assertEqual | 检验actualvalue是否等于expectvalue[0] |
|
||||
| 5 | assertFail | 抛出一个错误 |
|
||||
| 6 | assertFalse | 检验actualvalue是否是false |
|
||||
| 7 | assertTrue | 检验actualvalue是否是true |
|
||||
| 8 | assertInstanceOf | 检验actualvalue是否是expectvalue类型 |
|
||||
| 9 | assertLarger | 检验actualvalue是否大于expectvalue |
|
||||
| 10 | assertLess | 检验actualvalue是否小于expectvalue |
|
||||
| 11 | assertNaN | @since1.0.4 检验actualvalue是否是NaN |
|
||||
| 12 | assertNegUnlimited | @since1.0.4 检验actualvalue是否等于Number.NEGATIVE_INFINITY |
|
||||
| 13 | assertNull | 检验actualvalue是否是null |
|
||||
| 14 | assertPosUnlimited | @since1.0.4 检验actualvalue是否等于Number.POSITIVE_INFINITY |
|
||||
| 15 | assertPromiseIsPending | @since1.0.4 检验actualvalue是否处于Pending状态【actualvalue为promse对象】 |
|
||||
| 16 | assertPromiseIsRejected | @since1.0.4 检验actualvalue是否处于Rejected状态【同15】 |
|
||||
| 17 | assertPromiseIsRejectedWith | @since1.0.4 检验actualvalue是否处于Rejected状态,并且比较执行的结果值【同15】 |
|
||||
| 18 | assertPromiseIsRejectedWithError | @since1.0.4 检验actualvalue是否处于Rejected状态并有异常,同时比较异常的类型和message值【同15】 |
|
||||
| 19 | assertPromiseIsResolved | @since1.0.4 检验actualvalue是否处于Resolved状态【同15】 |
|
||||
| 20 | assertPromiseIsResolvedWith | @since1.0.4 检验actualvalue是否处于Resolved状态,并且比较执行的结果值【同15】 |
|
||||
| 21 | assertThrowError | 检验actualvalue抛出Error内容是否是expectValue |
|
||||
| 22 | assertUndefined | 检验actualvalue是否是undefined |
|
||||
| 23 | not | @since1.0.4 断言结果取反 |
|
||||
| 24 | message | @since1.0.17自定义断言异常信息 |
|
||||
|
||||
示例代码:
|
||||
|
||||
```javascript
|
||||
import { describe, it, expect } from '@ohos/hypium';
|
||||
|
||||
export default async function assertCloseTest() {
|
||||
describe('assertClose', function () {
|
||||
it('assertClose_success', 0, function () {
|
||||
let a = 100;
|
||||
let b = 0.1;
|
||||
expect(a).assertClose(99, b);
|
||||
})
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
#### 公共系统能力
|
||||
|
||||
| No. | API | 功能描述 |
|
||||
| ---- | ------------------------------------------------------- | ------------------------------------------------------------ |
|
||||
| 1 | existKeyword(keyword: string, timeout: number): boolean | @since1.0.3 hilog日志中查找指定字段是否存在,keyword是待查找关键字,timeout为设置的查找时间 |
|
||||
| 2 | actionStart(tag: string): void | @since1.0.3 cmd窗口输出开始tag |
|
||||
| 3 | actionEnd(tag: string): void | @since1.0.3 cmd窗口输出结束tag |
|
||||
|
||||
示例代码:
|
||||
|
||||
```javascript
|
||||
import { describe, it, expect, SysTestKit} from '@ohos/hypium';
|
||||
|
||||
export default function existKeywordTest() {
|
||||
describe('existKeywordTest', function () {
|
||||
it('existKeyword',DEFAULT, async function () {
|
||||
console.info("HelloTest");
|
||||
let isExist = await SysTestKit.existKeyword('HelloTest');
|
||||
console.info('isExist ------>' + isExist);
|
||||
})
|
||||
})
|
||||
}
|
||||
```
|
||||
```javascript
|
||||
import { describe, it, expect, SysTestKit} from '@ohos/hypium';
|
||||
|
||||
export default function actionTest() {
|
||||
describe('actionTest', function () {
|
||||
it('existKeyword',DEFAULT, async function () {
|
||||
let tag = '[MyTest]';
|
||||
SysTestKit.actionStart(tag);
|
||||
//do something
|
||||
SysTestKit.actionEnd(tag);
|
||||
})
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
#### 专项能力
|
||||
|
||||
- 测试用例属性筛选能力:hypium支持根据用例属性筛选执行指定测试用例,使用方式是先在测试用例上标记用例属性后,再在测试应用的启动shell命令后新增" -s ${Key} ${Value}"。
|
||||
|
||||
| Key | 含义说明 | Value取值范围 |
|
||||
| -------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| level | 用例级别 | "0","1","2","3","4", 例如:-s level 1 |
|
||||
| size | 用例粒度 | "small","medium","large", 例如:-s size small |
|
||||
| testType | 用例测试类型 | "function","performance","power","reliability","security","global","compatibility","user","standard","safety","resilience", 例如:-s testType function |
|
||||
|
||||
示例代码
|
||||
|
||||
```javascript
|
||||
import { describe, it, expect, TestType, Size, Level } from '@ohos/hypium';
|
||||
|
||||
export default function attributeTest() {
|
||||
describe('attributeTest', function () {
|
||||
it("testAttributeIt", TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, function () {
|
||||
console.info('Hello Test');
|
||||
})
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
示例命令
|
||||
```shell
|
||||
XX -s level 1 -s size small -s testType function
|
||||
```
|
||||
该命令的作用是:筛选测试应用中同时满足a)用例级别是1 b)用例粒度是small c)用例测试类型是function 三个条件的用例执行。
|
||||
|
||||
- 测试套/测试用例名称筛选能力(测试套与用例名称用“#”号连接,多个用“,”英文逗号分隔)
|
||||
|
||||
| Key | 含义说明 | Value取值范围 |
|
||||
| -------- | ----------------------- | -------------------------------------------------------------------------------------------- |
|
||||
| class | 指定要执行的测试套&用例 | ${describeName}#${itName},${describeName} , 例如:-s class attributeTest#testAttributeIt |
|
||||
| notClass | 指定不执行的测试套&用例 | ${describeName}#${itName},${describeName} , 例如:-s notClass attributeTest#testAttributeIt |
|
||||
|
||||
示例命令
|
||||
```shell
|
||||
XX -s class attributeTest#testAttributeIt,abilityTest#testAbilityIt
|
||||
```
|
||||
该命令的作用是:筛选测试应用中attributeTest测试套下的testAttributeIt测试用例,abilityTest测试套下的testAbilityIt测试用例,只执行这两条用例。
|
||||
|
||||
- 其他能力
|
||||
|
||||
| 能力项 | Key | 含义说明 | Value取值范围 |
|
||||
| ------------ | ------- | ---------------------------- | ---------------------------------------------- |
|
||||
| 随机执行能力 | random | 测试套&测试用例随机执行 | true, 不传参默认为false, 例如:-s random true |
|
||||
| 空跑能力 | dryRun | 显示要执行的测试用例信息全集 | true , 不传参默认为false,例如:-s dryRun true |
|
||||
| 异步超时能力 | timeout | 异步用例执行的超时时间 | 正整数 , 单位ms,例如:-s timeout 5000 |
|
||||
|
||||
##### 约束限制
|
||||
随机执行能力和空跑能力从npm包1.0.3版本开始支持
|
||||
|
||||
#### Mock能力
|
||||
|
||||
##### 约束限制
|
||||
|
||||
单元测试框架Mock能力从npm包[1.0.1版本](https://repo.harmonyos.com/#/cn/application/atomService/@ohos%2Fhypium/v/1.0.1)开始支持
|
||||
|
||||
## 约束
|
||||
|
||||
***
|
||||
本模块首批接口从OpenHarmony SDK API version 8开始支持。
|
||||
|
||||
## Hypium开放能力隐私声明
|
||||
|
||||
- 我们如何收集和使用您的个人信息
|
||||
您在使用集成了Hypium开放能力的测试应用时,Hypium不会处理您的个人信息。
|
||||
- SDK处理的个人信息
|
||||
不涉及。
|
||||
- SDK集成第三方服务声明
|
||||
不涉及。
|
||||
- SDK数据安全保护
|
||||
不涉及。
|
||||
- SDK版本更新声明
|
||||
为了向您提供最新的服务,我们会不时更新Hypium版本。我们强烈建议开发者集成使用最新版本的Hypium。
|
||||
|
||||
@ -0,0 +1,31 @@
|
||||
{
|
||||
"apiType": "stageMode",
|
||||
"buildOption": {
|
||||
},
|
||||
"buildOptionSet": [
|
||||
{
|
||||
"name": "release",
|
||||
"arkOptions": {
|
||||
"obfuscation": {
|
||||
"ruleOptions": {
|
||||
"enable": false,
|
||||
"files": [
|
||||
"./obfuscation-rules.txt"
|
||||
]
|
||||
},
|
||||
"consumerFiles": [
|
||||
"./consumer-rules.txt"
|
||||
]
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
"targets": [
|
||||
{
|
||||
"name": "default"
|
||||
},
|
||||
{
|
||||
"name": "ohosTest"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
import { harTasks } from '@ohos/hvigor-ohos-plugin';
|
||||
|
||||
export default {
|
||||
system: harTasks, /* Built-in plugin of Hvigor. It cannot be modified. */
|
||||
plugins: [] /* Custom plugin to extend the functionality of Hvigor. */
|
||||
}
|
||||
150
oh_modules/.ohpm/@ohos+hypium@1.0.24/oh_modules/@ohos/hypium/index.d.ts
vendored
Normal file
150
oh_modules/.ohpm/@ohos+hypium@1.0.24/oh_modules/@ohos/hypium/index.d.ts
vendored
Normal file
@ -0,0 +1,150 @@
|
||||
/*
|
||||
* Copyright (c) 2021-2024 Huawei Device Co., Ltd.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export const DEFAULT = 0B0000
|
||||
|
||||
export const when: when;
|
||||
|
||||
export enum TestType {
|
||||
FUNCTION = 0B1,
|
||||
PERFORMANCE = 0B1 << 1,
|
||||
POWER = 0B1 << 2,
|
||||
RELIABILITY = 0B1 << 3,
|
||||
SECURITY = 0B1 << 4,
|
||||
GLOBAL = 0B1 << 5,
|
||||
COMPATIBILITY = 0B1 << 6,
|
||||
USER = 0B1 << 7,
|
||||
STANDARD = 0B1 << 8,
|
||||
SAFETY = 0B1 << 9,
|
||||
RESILIENCE = 0B1 << 10
|
||||
}
|
||||
|
||||
export enum Size {
|
||||
SMALLTEST = 0B1 << 16,
|
||||
MEDIUMTEST = 0B1 << 17,
|
||||
LARGETEST = 0B1 << 18
|
||||
}
|
||||
|
||||
export enum Level {
|
||||
LEVEL0 = 0B1 << 24,
|
||||
LEVEL1 = 0B1 << 25,
|
||||
LEVEL2 = 0B1 << 26,
|
||||
LEVEL3 = 0B1 << 27,
|
||||
LEVEL4 = 0B1 << 28
|
||||
}
|
||||
export { xdescribe, xit, describe, it } from './index';
|
||||
|
||||
|
||||
|
||||
export function beforeItSpecified(testCaseNames: Array<string> | string, callback: Function): void
|
||||
|
||||
export function afterItSpecified(testCaseNames: Array<string> | string, callback: Function): void
|
||||
|
||||
export function beforeEach(callback: Function): void
|
||||
|
||||
export function afterEach(callback: Function): void
|
||||
|
||||
export function beforeAll(callback: Function): void
|
||||
|
||||
export function afterAll(callback: Function): void
|
||||
|
||||
|
||||
export interface Assert {
|
||||
assertClose(expectValue: number, precision: number): void
|
||||
assertContain(expectValue: any): void
|
||||
assertEqual(expectValue: any): void
|
||||
assertFail(): void
|
||||
assertFalse(): void
|
||||
assertTrue(): void
|
||||
assertInstanceOf(expectValue: string): void
|
||||
assertLarger(expectValue: number): void
|
||||
assertLess(expectValue: number): void
|
||||
assertNull(): void
|
||||
assertThrowError(expectValue: string | Function): void
|
||||
assertUndefined(): void
|
||||
assertLargerOrEqual(expectValue: number): void
|
||||
assertLessOrEqual(expectValue: number): void
|
||||
assertNaN(): void
|
||||
assertNegUnlimited(): void
|
||||
assertPosUnlimited(): void
|
||||
not(): Assert;
|
||||
assertDeepEquals(expectValue: any): void
|
||||
assertPromiseIsPending(): Promise<void>
|
||||
assertPromiseIsRejected(): Promise<void>
|
||||
assertPromiseIsRejectedWith(expectValue?: any): Promise<void>
|
||||
assertPromiseIsRejectedWithError(...expectValue): Promise<void>
|
||||
assertPromiseIsResolved(): Promise<void>
|
||||
assertPromiseIsResolvedWith(expectValue?: any): Promise<void>
|
||||
message(msg: string): Assert
|
||||
}
|
||||
|
||||
export function expect(actualValue?: any): Assert
|
||||
|
||||
export class ArgumentMatchers {
|
||||
static any;
|
||||
static anyString;
|
||||
static anyBoolean;
|
||||
static anyNumber;
|
||||
static anyObj;
|
||||
static anyFunction;
|
||||
static matchRegexs(Regex: RegExp): void
|
||||
}
|
||||
|
||||
declare interface when {
|
||||
afterReturn(value: any): any
|
||||
afterReturnNothing(): undefined
|
||||
afterAction(action: any): any
|
||||
afterThrow(e_msg: string): string
|
||||
(argMatchers?: any): when;
|
||||
}
|
||||
|
||||
export interface VerificationMode {
|
||||
times(count: Number): void
|
||||
never(): void
|
||||
once(): void
|
||||
atLeast(count: Number): void
|
||||
atMost(count: Number): void
|
||||
}
|
||||
|
||||
export class MockKit {
|
||||
constructor()
|
||||
mockFunc(obj: Object, func: Function): Function
|
||||
mockObject(obj: Object): Object
|
||||
verify(methodName: String, argsArray: Array<any>): VerificationMode
|
||||
ignoreMock(obj: Object, func: Function): void
|
||||
clear(obj: Object): void
|
||||
clearAll(): void
|
||||
}
|
||||
|
||||
export class SysTestKit {
|
||||
static getDescribeName(): string;
|
||||
static getItName(): string;
|
||||
static getItAttribute(): TestType | Size | Level
|
||||
static actionStart(tag: string): void
|
||||
static actionEnd(tag: string): void
|
||||
static existKeyword(keyword: string, timeout?: number): boolean
|
||||
}
|
||||
|
||||
export class Hypium {
|
||||
static setData(data: { [key: string]: any }): void
|
||||
static setTimeConfig(systemTime: any)
|
||||
static hypiumTest(abilityDelegator: any, abilityDelegatorArguments: any, testsuite: Function): void
|
||||
static set(key: string, value: any): void
|
||||
static get(key: string): any
|
||||
static registerAssert(customAssertion: Function): void
|
||||
static unregisterAssert(customAssertion: string | Function): void
|
||||
static hypiumWorkerTest(abilityDelegator: Object, abilityDelegatorArguments: Object, testsuite: Function, workerPort: Object): void;
|
||||
static hypiumInitWorkers(abilityDelegator: Object, scriptURL: string, workerNum: number, params: Object): void;
|
||||
}
|
||||
@ -0,0 +1,137 @@
|
||||
/*
|
||||
* Copyright (c) 2021-2024 Huawei Device Co., Ltd.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import Core from './src/main/core';
|
||||
import {TestType, Size, Level, DEFAULT} from './src/main/Constant';
|
||||
import DataDriver from './src/main/module/config/DataDriver';
|
||||
import ExpectExtend from './src/main/module/assert/ExpectExtend';
|
||||
import OhReport from './src/main/module/report/OhReport';
|
||||
export { xdescribe, xit, describe, it } from './index.ts';
|
||||
|
||||
export declare class Hypium {
|
||||
static setData(data: Object): void
|
||||
static setTimeConfig(systemTime: Object): void
|
||||
static hypiumTest(abilityDelegator: Object, abilityDelegatorArguments: Object, testsuite: Function): void
|
||||
static set(key: string, value: Object): void
|
||||
static get(key: string): Object
|
||||
static registerAssert(customAssertion: Function): void
|
||||
static unregisterAssert(customAssertion: string | Function): void
|
||||
static hypiumWorkerTest(abilityDelegator: Object, abilityDelegatorArguments: Object,
|
||||
testsuite: Function, workerPort: Object): void;
|
||||
static hypiumInitWorkers(abilityDelegator: Object, scriptURL: string, workerNum: number, params: Object): void;
|
||||
}
|
||||
|
||||
export {
|
||||
Core,
|
||||
DataDriver,
|
||||
ExpectExtend,
|
||||
OhReport,
|
||||
TestType,
|
||||
Size,
|
||||
Level,
|
||||
DEFAULT
|
||||
};
|
||||
|
||||
type allExpectType = Object | undefined | null
|
||||
|
||||
export declare function beforeItSpecified(testCaseNames: Array<string> | string, callback: Function): void
|
||||
|
||||
export declare function afterItSpecified(testCaseNames: Array<string> | string, callback: Function): void
|
||||
|
||||
export declare function beforeEach(callback: Function): void
|
||||
|
||||
export declare function afterEach(callback: Function): void
|
||||
|
||||
export declare function beforeAll(callback: Function): void
|
||||
|
||||
export declare function afterAll(callback: Function): void
|
||||
|
||||
export declare interface Assert {
|
||||
assertClose(expectValue: number, precision: number): void
|
||||
assertContain(expectValue: allExpectType): void
|
||||
assertEqual(expectValue: allExpectType): void
|
||||
assertFail(): void
|
||||
assertFalse(): void
|
||||
assertTrue(): void
|
||||
assertInstanceOf(expectValue: string): void
|
||||
assertLarger(expectValue: number): void
|
||||
assertLess(expectValue: number): void
|
||||
assertNull(): void
|
||||
assertThrowError(expectValue: string | Function): void
|
||||
assertUndefined(): void
|
||||
assertLargerOrEqual(expectValue: number):void
|
||||
assertLessOrEqual(expectValue: number):void
|
||||
assertNaN():void
|
||||
assertNegUnlimited(): void
|
||||
assertPosUnlimited(): void
|
||||
not(): Assert;
|
||||
assertDeepEquals(expectValue: allExpectType):void
|
||||
assertPromiseIsPending(): Promise<void>
|
||||
assertPromiseIsRejected(): Promise<void>
|
||||
assertPromiseIsRejectedWith(expectValue?: allExpectType): Promise<void>
|
||||
assertPromiseIsRejectedWithError(...expectValue: allExpectType[]): Promise<void>
|
||||
assertPromiseIsResolved(): Promise<void>
|
||||
assertPromiseIsResolvedWith(expectValue?: allExpectType): Promise<void>
|
||||
message(msg: string): Assert
|
||||
}
|
||||
|
||||
export declare function expect(actualValue?: allExpectType): Assert
|
||||
|
||||
export declare class ArgumentMatchers {
|
||||
public static any: allExpectType;
|
||||
public static anyString: string;
|
||||
public static anyBoolean: Boolean;
|
||||
public static anyNumber: Number;
|
||||
public static anyObj: Object;
|
||||
public static anyFunction: Function;
|
||||
public static matchRegexs(regex: RegExp): void
|
||||
}
|
||||
|
||||
declare interface whenResult {
|
||||
afterReturn: (value: allExpectType) => allExpectType
|
||||
afterReturnNothing: () => undefined
|
||||
afterAction: (action: allExpectType) => allExpectType
|
||||
afterThrow: (e_msg: string) => string
|
||||
}
|
||||
|
||||
export declare function when(f:Function): (...args: (allExpectType | void)[]) => whenResult
|
||||
|
||||
export declare interface VerificationMode {
|
||||
times(count: Number): void
|
||||
never(): void
|
||||
once(): void
|
||||
atLeast(count: Number): void
|
||||
atMost(count: Number): void
|
||||
}
|
||||
|
||||
export declare class MockKit {
|
||||
constructor()
|
||||
mockFunc(obj: Object, func: Function): Function
|
||||
mockObject(obj: Object): Object
|
||||
verify(methodName: String, argsArray: Array<allExpectType>): VerificationMode
|
||||
ignoreMock(obj: Object, func: Function): void
|
||||
clear(obj: Object): void
|
||||
clearAll(): void
|
||||
}
|
||||
|
||||
export declare class SysTestKit {
|
||||
static getDescribeName(): string;
|
||||
static getItName(): string;
|
||||
static getItAttribute(): TestType | Size | Level
|
||||
static actionStart(tag: string): void
|
||||
static actionEnd(tag: string): void
|
||||
static existKeyword(keyword: string, timeout?: number): boolean
|
||||
}
|
||||
|
||||
@ -0,0 +1,261 @@
|
||||
/*
|
||||
* Copyright (c) 2021-2024 Huawei Device Co., Ltd.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License")
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import Core from './src/main/core';
|
||||
import { DEFAULT, TestType, Size, Level, TAG, PrintTag } from './src/main/Constant';
|
||||
import DataDriver from './src/main/module/config/DataDriver';
|
||||
import ExpectExtend from './src/main/module/assert/ExpectExtend';
|
||||
import OhReport from './src/main/module/report/OhReport';
|
||||
import SysTestKit from './src/main/module/kit/SysTestKit';
|
||||
import { describe, beforeAll, beforeEach, afterEach, afterAll, it, expect, beforeItSpecified, afterItSpecified, xdescribe, xit } from './src/main/interface';
|
||||
import { MockKit, when } from './src/main/module/mock/MockKit';
|
||||
import ArgumentMatchers from './src/main/module/mock/ArgumentMatchers';
|
||||
import worker from '@ohos.worker';
|
||||
|
||||
class Hypium {
|
||||
static context = new Map();
|
||||
static setData(data) {
|
||||
const core = Core.getInstance();
|
||||
const dataDriver = new DataDriver({ data });
|
||||
core.addService('dataDriver', dataDriver);
|
||||
}
|
||||
|
||||
static setTimeConfig(systemTime) {
|
||||
SysTestKit.systemTime = systemTime;
|
||||
}
|
||||
|
||||
static set(key, value) {
|
||||
Hypium.context.set(key, value);
|
||||
}
|
||||
|
||||
static get(key) {
|
||||
return Hypium.context.get(key);
|
||||
}
|
||||
|
||||
static hypiumTest(abilityDelegator, abilityDelegatorArguments, testsuite) {
|
||||
const core = Core.getInstance();
|
||||
const expectExtend = new ExpectExtend({
|
||||
'id': 'extend'
|
||||
});
|
||||
core.addService('expect', expectExtend);
|
||||
const ohReport = new OhReport({
|
||||
'delegator': abilityDelegator,
|
||||
'abilityDelegatorArguments': abilityDelegatorArguments
|
||||
});
|
||||
SysTestKit.delegator = abilityDelegator;
|
||||
core.addService('report', ohReport);
|
||||
core.init();
|
||||
core.subscribeEvent('spec', ohReport);
|
||||
core.subscribeEvent('suite', ohReport);
|
||||
core.subscribeEvent('task', ohReport);
|
||||
const configService = core.getDefaultService('config');
|
||||
if (abilityDelegatorArguments !== null) {
|
||||
let testParameters = configService.translateParams(abilityDelegatorArguments.parameters);
|
||||
console.info(`${TAG}parameters:${JSON.stringify(testParameters)}`);
|
||||
configService.setConfig(testParameters);
|
||||
}
|
||||
testsuite();
|
||||
core.execute(abilityDelegator);
|
||||
}
|
||||
static async hypiumInitWorkers(abilityDelegator, scriptURL, workerNum = 8, params) {
|
||||
console.info(`${TAG}, hypiumInitWorkers call,${scriptURL}`);
|
||||
let workerPromiseArray = [];
|
||||
|
||||
// 开始统计时间
|
||||
let startTime = await SysTestKit.getRealTime();
|
||||
for (let i = 0; i < workerNum; i++) {
|
||||
// 创建worker线程
|
||||
const workerPromise = Hypium.createWorkerPromise(scriptURL, i, params);
|
||||
workerPromiseArray.push(workerPromise);
|
||||
}
|
||||
const ret = {total: 0, failure: 0, error: 0, pass: 0, ignore: 0, duration: 0};
|
||||
Promise.all(workerPromiseArray).then(async (items) => {
|
||||
console.info(`${TAG}, all result from workers, ${JSON.stringify(items)}`);
|
||||
let allItemList = new Array();
|
||||
// 统计执行结果
|
||||
Hypium.handleWorkerTestResult(ret, allItemList, items);
|
||||
console.info(`${TAG}, all it result, ${JSON.stringify(allItemList)}`);
|
||||
// 统计用例执行结果
|
||||
const retResult = {total: 0, failure: 0, error: 0, pass: 0, ignore: 0, duration: 0};
|
||||
// 标记用例执行结果
|
||||
Hypium.configWorkerItTestResult(retResult, allItemList);
|
||||
// 打印用例结果
|
||||
Hypium.printWorkerTestResult(abilityDelegator, allItemList);
|
||||
// 用例执行完成统计时间
|
||||
let endTime = await SysTestKit.getRealTime();
|
||||
const taskConsuming = endTime - startTime;
|
||||
const message =
|
||||
`\n${PrintTag.OHOS_REPORT_ALL_RESULT}: stream=Test run: runTimes: ${ret.total},total: ${retResult.total}, Failure: ${retResult.failure}, Error: ${retResult.error}, Pass: ${retResult.pass}, Ignore: ${retResult.ignore}` +
|
||||
`\n${PrintTag.OHOS_REPORT_ALL_CODE}: ${retResult.failure > 0 || retResult.error > 0 ? -1 : 0}` +
|
||||
`\n${PrintTag.OHOS_REPORT_ALL_STATUS}: taskconsuming=${taskConsuming > 0 ? taskConsuming : ret.duration}`;
|
||||
abilityDelegator.printSync(message);
|
||||
console.info(`${TAG}, [end] you worker test`);
|
||||
abilityDelegator.finishTest('you worker test finished!!!', 0, () => {});
|
||||
}).catch((e) => {
|
||||
console.info(`${TAG}, [end] error you worker test, ${JSON.stringify(e)}`);
|
||||
abilityDelegator.finishTest('you worker test error finished!!!', 0, () => {});
|
||||
}).finally(() => {
|
||||
console.info(`${TAG}, all promise finally end`);
|
||||
});
|
||||
}
|
||||
// 创建worker线程
|
||||
static createWorkerPromise(scriptURL, i, params) {
|
||||
console.info(`${TAG}, createWorkerPromiser, ${scriptURL}, ${i}`);
|
||||
const workerPromise = new Promise((resolve, reject) => {
|
||||
const workerInstance = new worker.ThreadWorker(scriptURL, {name: `worker_${i}`});
|
||||
console.info(`${TAG}, send data to worker`);
|
||||
// 发送数据到worker线程中
|
||||
workerInstance.postMessage(params);
|
||||
workerInstance.onmessage = function (e) {
|
||||
let currentThreadName = e.data?.currentThreadName;
|
||||
console.info(`${TAG}, receview data from ${currentThreadName}, ${JSON.stringify(e.data)}`);
|
||||
//
|
||||
resolve(e.data?.summary);
|
||||
console.info(`${TAG}, ${currentThreadName} finish`);
|
||||
workerInstance.terminate();
|
||||
};
|
||||
workerInstance.onerror = function (e) {
|
||||
console.info(`${TAG}, worker error, ${JSON.stringify(e)}`);
|
||||
reject(e);
|
||||
workerInstance.terminate();
|
||||
};
|
||||
workerInstance.onmessageerror = function (e) {
|
||||
console.info(`${TAG}, worker message error, ${JSON.stringify(e)}`);
|
||||
reject(e);
|
||||
workerInstance.terminate();
|
||||
};
|
||||
});
|
||||
return workerPromise;
|
||||
}
|
||||
static handleWorkerTestResult(ret, allItemList, items) {
|
||||
console.info(`${TAG}, handleWorkerTestResult, ${JSON.stringify(items)}`);
|
||||
for (const {total, failure, error, pass, ignore, duration, itItemList} of items) {
|
||||
ret.total += total;
|
||||
ret.failure += failure;
|
||||
ret.error += error;
|
||||
ret.pass += pass;
|
||||
ret.ignore += ignore;
|
||||
ret.duration += duration;
|
||||
Hypium.handleItResult(allItemList, itItemList);
|
||||
}
|
||||
}
|
||||
static handleItResult(allItemList, itItemList) {
|
||||
// 遍历所有的用例结果统计最终结果
|
||||
for (const {currentThreadName, description, result} of itItemList) {
|
||||
let item = allItemList.find((it) => it.description === description);
|
||||
if (item) {
|
||||
let itResult = item.result;
|
||||
// 当在worker中出现一次failure就标记为failure, 出现一次error就标记为error, 所有线程都pass才标记为pass
|
||||
if (itResult === 0) {
|
||||
item.result = result;
|
||||
item.currentThreadName = currentThreadName;
|
||||
}
|
||||
} else {
|
||||
let it = {
|
||||
description: description,
|
||||
currentThreadName: currentThreadName,
|
||||
result: result
|
||||
};
|
||||
allItemList.push(it);
|
||||
}
|
||||
}
|
||||
}
|
||||
static configWorkerItTestResult(retResult, allItemList) {
|
||||
console.info(`${TAG}, configWorkerItTestResult, ${JSON.stringify(allItemList)}`);
|
||||
for (const {currentThreadName, description, result} of allItemList) {
|
||||
console.info(`${TAG}, description, ${description}, result,${result}`);
|
||||
retResult.total ++;
|
||||
if (result === 0) {
|
||||
retResult.pass ++;
|
||||
} else if (result === -1) {
|
||||
retResult.error ++;
|
||||
} else if (result === -2) {
|
||||
retResult.failure ++;
|
||||
} else {
|
||||
retResult.ignore ++;
|
||||
}
|
||||
}
|
||||
}
|
||||
static printWorkerTestResult(abilityDelegator, allItemList) {
|
||||
console.info(`${TAG}, printWorkerTestResult, ${JSON.stringify(allItemList)}`);
|
||||
let index = 1;
|
||||
for (const {currentThreadName, description, result} of allItemList) {
|
||||
console.info(`${TAG}, description print, ${description}, result,${result}`);
|
||||
let itArray = description.split('#');
|
||||
let des;
|
||||
let itName;
|
||||
if (itArray.length > 1) {
|
||||
des = itArray[0];
|
||||
itName = itArray[1];
|
||||
} else if (itArray.length > 1) {
|
||||
des = itArray[0];
|
||||
itName = itArray[0];
|
||||
} else {
|
||||
des = 'undefined';
|
||||
itName = 'undefined';
|
||||
}
|
||||
|
||||
let msg = `\n${PrintTag.OHOS_REPORT_WORKER_STATUS}: class=${des}`;
|
||||
msg += `\n${PrintTag.OHOS_REPORT_WORKER_STATUS}: test=${itName}`;
|
||||
msg += `\n${PrintTag.OHOS_REPORT_WORKER_STATUS}: current=${index}`;
|
||||
msg += `\n${PrintTag.OHOS_REPORT_WORKER_STATUS}: CODE=${result}`;
|
||||
abilityDelegator.printSync(msg);
|
||||
index ++;
|
||||
}
|
||||
}
|
||||
static hypiumWorkerTest(abilityDelegator, abilityDelegatorArguments, testsuite, workerPort) {
|
||||
console.info(`${TAG}, hypiumWorkerTest call`);
|
||||
SysTestKit.workerPort = workerPort;
|
||||
let currentWorkerName = workerPort.name;
|
||||
console.info(`${TAG}, hypiumWorkerTest_currentWorkerName: ${currentWorkerName}`);
|
||||
Hypium.hypiumTest(abilityDelegator, abilityDelegatorArguments, testsuite);
|
||||
|
||||
}
|
||||
|
||||
static registerAssert(customAssertion) {
|
||||
const core = Core.getInstance();
|
||||
const expectService = core.getDefaultService('expect');
|
||||
let matchers = {};
|
||||
matchers[customAssertion.name] = customAssertion;
|
||||
expectService.addMatchers(matchers);
|
||||
expectService.customMatchers.push(customAssertion.name);
|
||||
console.info(`${TAG}success to register the ${customAssertion.name}`);
|
||||
}
|
||||
|
||||
static unregisterAssert(customAssertion) {
|
||||
const core = Core.getInstance();
|
||||
const expectService = core.getDefaultService('expect');
|
||||
let customAssertionName = typeof customAssertion === 'function' ? customAssertion.name : customAssertion;
|
||||
expectService.removeMatchers(customAssertionName);
|
||||
console.info(`${TAG}success to unregister the ${customAssertionName}`);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export {
|
||||
Hypium,
|
||||
Core,
|
||||
DEFAULT,
|
||||
TestType,
|
||||
Size,
|
||||
Level,
|
||||
DataDriver,
|
||||
ExpectExtend,
|
||||
OhReport,
|
||||
SysTestKit,
|
||||
describe, beforeAll, beforeEach, afterEach, afterAll, it, expect, beforeItSpecified, afterItSpecified, xdescribe, xit,
|
||||
MockKit, when,
|
||||
ArgumentMatchers
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user