diff --git a/README.md b/README.md index acef44b6..17adb6aa 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # MemoryBear 让AI拥有如同人类一样的记忆 - +### [安装教程](#memorybear安装教程) ## 项目简介 MemoryBear是红熊AI自主研发的新一代AI记忆系统,其核心突破在于跳出传统知识“静态存储”的局限,以生物大脑认知机制为原型,构建了具备“感知-提炼-关联-遗忘”全生命周期的智能知识处理体系。该系统致力于让机器摆脱“信息堆砌”的困境,实现对知识的深度理解与自主进化,成为人类认知协作的核心伙伴。 @@ -71,6 +71,240 @@ Memory Bear 基于向量的知识记忆非图谱版本,成功在保持高准 Memory Bear 通过集成知识图谱架构,在需要复杂推理和关系感知的任务上进一步释放了潜力。虽然图谱的遍历和推理可能会引入轻微的检索开销,但该版本通过优化图检索策略和决策流,成功将延迟控制在高效范围。更关键的是,基于图谱的 Memory Bear 将总体准确性推至新的高度(75.00 ± 0.20%),在保持准确性的同时,整体指标显著优于其他所有方法,证明了“结构化记忆带来的性能决定性优势”。 image +# MemoryBear安装教程 +## 一、前期准备 + +### 1.环境要求 + +* Node.js 20.19+ 或 22.12+ 前端运行环境 + +* Python 3.12 后端运行环境 + +* PostgreSQL 13+ 主数据库 + +* Neo4j 4.4+ 图数据库(存储知识图谱) + +* Redis 6.0+ 缓存和消息队列 + +## 二、项目获取 + +### 1.获取方式 + +Git克隆(推荐): + +```plain text +git clone https://github.com/SuanmoSuanyangTechnology/MemoryBear.git +``` + +### 2.目录说明 + +diagram + + +## 三、安装步骤 + +### 1.后端API服务启动 + +#### 1.1 安装python依赖 + +```python +# 0.安装依赖管理工具uv +pip install uv + +# 1.终端切换API目录 +cd api + +# 2.安装依赖 +uv sync + +# 3.激活虚拟环境 (Windows) +.venv\Scripts\Activate.ps1 (powershell,在api目录下) +api\.venv\Scripts\activate (powershell,在根目录下) +.venv\Scripts\activate.bat (cmd,在api目录下) + +``` + +#### 1.2 安装必备基础服务(docker镜像) + +使用docker desktop安装所需的docker镜像 + +* **docker desktop安装地址:**https://www.docker.com/products/docker-desktop/ + +* **PostgreSQL** + + **拉取镜像** + + search——select——pull + + image-9 + + +**创建容器** + +image-8 + + +**服务启动成功** + +image + + +* **Neo4j** + +**拉取镜像**,与PostgreSQL一样从docker desktop中拉取镜像 + +**创建容器**,Neo4j 默认需要映射**2 个关键端口**(7474 对应 Browser,7687 对应 Bolt 协议),同时需设置初始密码 + +image-1 + + +**服务成功启动** + +image-2 + + +* **Redis** + +同上 + +#### 1.3 配置环境变量 + +复制 env.example 为 .env 并填写配置 + +```bash +# Neo4j 图数据库 +NEO4J_URI=bolt://localhost:7687 +NEO4J_USERNAME=neo4j +NEO4J_PASSWORD=your-password +# Neo4j Browser访问地址 + +# PostgreSQL 数据库 +DB_HOST=127.0.0.1 +DB_PORT=5432 +DB_USER=postgres +DB_PASSWORD=your-password +DB_NAME=redbear-mem + +# Database Migration Configuration +# Set to true to automatically upgrade database schema on startup +DB_AUTO_UPGRADE=true # 首次启动设为true自动迁移数据库 在空白数据库创建表结构 + +# Redis +REDIS_HOST=127.0.0.1 +REDIS_PORT=6379 +REDIS_DB=1 + +# Celery (使用Redis作为broker) +BROKER_URL=redis://127.0.0.1:6379/0 +RESULT_BACKEND=redis://127.0.0.1:6379/0 + +# JWT密钥 (生成方式: openssl rand -hex 32) +SECRET_KEY=your-secret-key-here +``` + +#### 1.4 PostgreSQL数据库建立 + +通过项目中已有的 alembic 数据库迁移文件,为全新创建的空白 PostgreSQL 数据库创建对应的表结构。 + +**(1)配置数据库连接** + +确认项目中`alembic.ini`文件的`sqlalchemy.url`配置指向你的空白 PostgreSQL 数据库,格式示例: + +``` +sqlalchemy.url = postgresql://用户名:密码@数据库地址:端口/空白数据库名 +``` + +同时检查 migrations`/env.py`中`target_metadata`是否正确关联到 ORM 模型的`metadata`(确保迁移脚本和模型一致) + +**(2)执行迁移文件** + +在API目录执行以下命令,alembic 会自动识别空白数据库,并执行所有未应用的迁移脚本,创建完整表结构: + +```bash +alembic upgrade head +``` + +image-3 + + +通过Navicat查看迁移创建的数据库表结构 + +image-4 + + +#### API服务启动 + +```python +uv run -m app.main +``` + +访问 API 文档:http://localhost:8000/docs + +image-5 + + +### 2.前端web应用启动 + +#### 2.1安装依赖 + +```python +# 切换web目录下 +cd web + +# 下载依赖 +npm install +``` + +#### 2.2 修改API代理配置 + +编辑 web/vite.config.ts,将代理目标改为后端地址 + +```python +proxy: { + '/api': { + target: 'http://127.0.0.1:8000', // 改为后端地址,win用户127.0.0.1 mac用户0.0.0.0 + changeOrigin: true, + }, +} + +``` + +#### 2.3 启动服务 + +```python +# 启动web服务 +npm run dev + +``` + +服务启动会输出可访问的前端界面 + +image-6 + + +image-7 + + +## 四、用户操作 + +step1:项目获取 + +step2:后端API服务启动 + +step3:前端web应用启动 + +step4: 终端输入 curl.exe -X POST http://127.0.0.1:8000/api/setup ,访问接口初始化数据库获得超级管理员账号 + +step5:超级管理员 + +账号:admin@example.com + +密码:admin\_password + +step6:登陆前端页面 + + + ## 许可证 diff --git a/api/README.md b/api/README.md deleted file mode 100644 index ea014bf4..00000000 --- a/api/README.md +++ /dev/null @@ -1,124 +0,0 @@ -# Memory Bear 前端项目 - -## 快速开始 - -### 环境要求 - -- Python 3.12 -- PostgreSQL 13+ -- Neo4j 4.4+ -- Redis 6.0+ - -### 安装依赖 - -```bash -python -m venv .venv -source .venv/bin/activate # Windows: .venv\Scripts\activate - -# 方式一:基于 pyproject 安装 -pip install . - -# 方式二:使用 requirements.txt -pip install -r requirements.txt -``` - -### 配置环境变量 - -创建 `.env` 文件(示例): - -```env -# Postgres -DB_HOST=127.0.0.1 -DB_PORT=5432 -DB_USER=postgres -DB_PASSWORD=your-password -DB_NAME=redbear-mem -DB_AUTO_UPGRADE=false - -# Neo4j -NEO4J_URI=bolt://localhost:7687 -NEO4J_USERNAME=neo4j -NEO4J_PASSWORD=your-password - -# Redis -REDIS_HOST=127.0.0.1 -REDIS_PORT=6379 -REDIS_DB=1 - -# LLM / API Keys(按需) -OPENAI_API_KEY=your-openai-key -DASHSCOPE_API_KEY=your-dashscope-key - -# 其他 -WEB_URL=http://localhost:3000 -LOG_LEVEL=INFO -``` - -### 初始化与启动 - -```bash -# 如需自动迁移数据库:设置 DB_AUTO_UPGRADE=true 或手动执行 -alembic upgrade head - -# 激活虚拟环境 -api\.venv\Scripts\activate - -# 目录切换到api下 -cd api - - -# 启动开发服务 -uvicorn app.main:app --reload --port 8000 - -# 打开交互文档 -# http://localhost:8000/docs -``` - -## 项目结构 - -``` -app/ -├── main.py # FastAPI 入口 -├── controllers/ # 控制器与路由 -├── core/ # 核心:配置、异常、日志等 -│ └── memory/ # 记忆模块 -│ ├── storage_services/ # 萃取/遗忘/反思/检索 -│ ├── agent/ # Agent + MCP 服务 -│ ├── utils/ # 工具与提示词 -│ └── models/ # 领域模型 -└── rag/ # RAG 能力与文档解析 - -logs/ # 日志与输出 -LICENSE # 许可协议(Apache-2.0) -README.md # 项目说明 -``` - -## API 与路由 - -- 管理端:`/api`(JWT 认证) -- 服务端:`/v1`(API Key 认证) -- 根路由健康检查:`GET /` 返回运行状态 -- Swagger 文档:`/docs` - - -## 部署建议 - -- 使用 `gunicorn` + `uvicorn.workers.UvicornWorker` 作为生产入口 -- 配置 `LOG_LEVEL=WARNING` 并启用文件日志 -- 数据库与缓存请使用托管服务或独立实例 - -示例: - -```bash -gunicorn app.main:app -w 4 -k uvicorn.workers.UvicornWorker -``` - -## 许可证 - -本项目采用 Apache License 2.0 开源协议,详情见 `LICENSE`。 - -## 致谢与交流 - -- 问题反馈与讨论:请提交 Issue 到代码仓库 -- 欢迎贡献:提交 PR 前请先创建功能分支并遵循常规提交信息格式 -- 如感兴趣需要联络:tianyou_hubm@redbearai.com diff --git a/api/env.example b/api/env.example index f368d35d..c4e0c1eb 100644 --- a/api/env.example +++ b/api/env.example @@ -14,7 +14,7 @@ DB_NAME= # Database Migration Configuration # Set to true to automatically upgrade database schema on startup -DB_AUTO_UPGRADE=false +DB_AUTO_UPGRADE=true diff --git a/api/migrations/versions/29c030316adf_20251118191055.py b/api/migrations/versions/29c030316adf_20251118191055.py index 61be22c4..8565a400 100644 --- a/api/migrations/versions/29c030316adf_20251118191055.py +++ b/api/migrations/versions/29c030316adf_20251118191055.py @@ -35,7 +35,7 @@ def upgrade() -> None: sa.PrimaryKeyConstraint('id') ) op.create_index(op.f('ix_app_shares_id'), 'app_shares', ['id'], unique=False) - op.drop_table('data_config') + op.execute('DROP TABLE IF EXISTS data_config') op.add_column('conversations', sa.Column('workspace_id', sa.UUID(), nullable=False, comment='工作空间ID')) op.create_foreign_key(None, 'conversations', 'workspaces', ['workspace_id'], ['id']) # ### end Alembic commands ### diff --git a/api/migrations/versions/2df9cdfc5087_20251118112827.py b/api/migrations/versions/2df9cdfc5087_20251118112827.py index bdfbbeb9..f1ed35ae 100644 --- a/api/migrations/versions/2df9cdfc5087_20251118112827.py +++ b/api/migrations/versions/2df9cdfc5087_20251118112827.py @@ -47,7 +47,7 @@ def upgrade() -> None: sa.PrimaryKeyConstraint('id') ) op.create_index(op.f('ix_messages_id'), 'messages', ['id'], unique=False) - op.drop_table('data_config') + op.execute('DROP TABLE IF EXISTS data_config') # ### end Alembic commands ### diff --git a/api/migrations/versions/57c11f3c7aee_202511292027.py b/api/migrations/versions/57c11f3c7aee_202511292027.py index d0613793..a4df5445 100644 --- a/api/migrations/versions/57c11f3c7aee_202511292027.py +++ b/api/migrations/versions/57c11f3c7aee_202511292027.py @@ -20,11 +20,31 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### - op.add_column('data_config', sa.Column('llm', sa.String(), nullable=True, comment='LLM模型配置ID')) + # 检查表是否存在再添加列 + op.execute(""" + DO $$ + BEGIN + IF EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'data_config') + AND NOT EXISTS (SELECT FROM information_schema.columns + WHERE table_name = 'data_config' AND column_name = 'llm') THEN + ALTER TABLE data_config ADD COLUMN llm VARCHAR; + COMMENT ON COLUMN data_config.llm IS 'LLM模型配置ID'; + END IF; + END $$; + """) # ### end Alembic commands ### def downgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### - op.drop_column('data_config', 'llm') + # 检查列是否存在再删除 + op.execute(""" + DO $$ + BEGIN + IF EXISTS (SELECT FROM information_schema.columns + WHERE table_name = 'data_config' AND column_name = 'llm') THEN + ALTER TABLE data_config DROP COLUMN llm; + END IF; + END $$; + """) # ### end Alembic commands ### diff --git a/api/migrations/versions/6e254c5f498e_20251125181327.py b/api/migrations/versions/6e254c5f498e_20251125181327.py index 518020ab..ae73950d 100644 --- a/api/migrations/versions/6e254c5f498e_20251125181327.py +++ b/api/migrations/versions/6e254c5f498e_20251125181327.py @@ -20,14 +20,21 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### - op.alter_column('data_config', 'llm_id', - existing_type=sa.VARCHAR(), - comment='LLM模型配置ID', - existing_nullable=True) - op.alter_column('data_config', 'embedding_id', - existing_type=sa.VARCHAR(), - comment='嵌入模型配置ID', - existing_nullable=True) + # 检查表和列是否存在再进行操作 + op.execute(""" + DO $$ + BEGIN + IF EXISTS (SELECT FROM information_schema.columns + WHERE table_name = 'data_config' AND column_name = 'llm_id') THEN + COMMENT ON COLUMN data_config.llm_id IS 'LLM模型配置ID'; + END IF; + + IF EXISTS (SELECT FROM information_schema.columns + WHERE table_name = 'data_config' AND column_name = 'embedding_id') THEN + COMMENT ON COLUMN data_config.embedding_id IS '嵌入模型配置ID'; + END IF; + END $$; + """) op.add_column('workspaces', sa.Column('storage_type', sa.String(), nullable=True)) # ### end Alembic commands ### diff --git a/api/migrations/versions/9971d42f0e8c_20251117211522.py b/api/migrations/versions/9971d42f0e8c_20251117211522.py index 338f89a5..a5422600 100644 --- a/api/migrations/versions/9971d42f0e8c_20251117211522.py +++ b/api/migrations/versions/9971d42f0e8c_20251117211522.py @@ -20,8 +20,9 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('data_config_copy1') - op.drop_table('data_config') + # 使用 IF EXISTS 避免表不存在时报错 + op.execute('DROP TABLE IF EXISTS data_config_copy1') + op.execute('DROP TABLE IF EXISTS data_config') op.add_column('agent_configs', sa.Column('model_parameters', postgresql.JSON(astext_type=sa.Text()), nullable=True, comment='模型参数配置(temperature、max_tokens等)')) # ### end Alembic commands ### diff --git a/api/migrations/versions/9a887a617afb_20251118215552.py b/api/migrations/versions/9a887a617afb_20251118215552.py index 62f04781..07a8970c 100644 --- a/api/migrations/versions/9a887a617afb_20251118215552.py +++ b/api/migrations/versions/9a887a617afb_20251118215552.py @@ -20,7 +20,7 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('data_config') + op.execute('DROP TABLE IF EXISTS data_config') op.add_column('app_releases', sa.Column('release_notes', sa.String(), nullable=True, comment='版本说明')) # ### end Alembic commands ### diff --git a/api/migrations/versions/b012ab0089b9_init_db_table.py b/api/migrations/versions/b012ab0089b9_init_db_table.py index 137c7775..04e646fe 100644 --- a/api/migrations/versions/b012ab0089b9_init_db_table.py +++ b/api/migrations/versions/b012ab0089b9_init_db_table.py @@ -358,8 +358,9 @@ def upgrade() -> None: $$ LANGUAGE plpgsql; """) - # 创建 documents 表上的触发器 + # 创建 documents 表上的触发器(如果不存在) op.execute(""" + DROP TRIGGER IF EXISTS tr_documents_update_stats ON documents; CREATE TRIGGER tr_documents_update_stats AFTER INSERT OR UPDATE OR DELETE ON documents FOR EACH ROW diff --git a/api/migrations/versions/bf36acfdffe3_init_db_table.py b/api/migrations/versions/bf36acfdffe3_init_db_table.py index 3aea8649..68a958ba 100644 --- a/api/migrations/versions/bf36acfdffe3_init_db_table.py +++ b/api/migrations/versions/bf36acfdffe3_init_db_table.py @@ -69,8 +69,9 @@ def upgrade(): $$ LANGUAGE plpgsql; """) - # 创建 documents 表上的触发器 + # 创建 documents 表上的触发器(如果不存在) op.execute(""" + DROP TRIGGER IF EXISTS tr_documents_update_stats ON documents; CREATE TRIGGER tr_documents_update_stats AFTER INSERT OR UPDATE OR DELETE ON documents FOR EACH ROW diff --git a/api/migrations/versions/d00648d486ca_20251120202612.py b/api/migrations/versions/d00648d486ca_20251120202612.py index 5942ccae..3f12fd96 100644 --- a/api/migrations/versions/d00648d486ca_20251120202612.py +++ b/api/migrations/versions/d00648d486ca_20251120202612.py @@ -184,5 +184,5 @@ def downgrade() -> None: op.drop_table('end_users') op.drop_index(op.f('ix_retrieval_info_id'), table_name='retrieval_info') op.drop_table('retrieval_info') - op.drop_table('data_config') + op.execute('DROP TABLE IF EXISTS data_config') # ### end Alembic commands ### diff --git a/api/migrations/versions/d1e56ecbf058_202511291902.py b/api/migrations/versions/d1e56ecbf058_202511291902.py index cfd103ec..6ca1a042 100644 --- a/api/migrations/versions/d1e56ecbf058_202511291902.py +++ b/api/migrations/versions/d1e56ecbf058_202511291902.py @@ -24,12 +24,22 @@ def upgrade() -> None: # 先删除依赖的视图(如果存在) op.execute("DROP VIEW IF EXISTS data_config_sorted CASCADE") - op.alter_column('data_config', 'llm_id', - existing_type=sa.VARCHAR(), - comment='LLM模型配置ID', - existing_comment='临时', - existing_nullable=True) - op.drop_column('data_config', 'llm') + # 检查表和列是否存在再进行操作 + op.execute(""" + DO $$ + BEGIN + IF EXISTS (SELECT FROM information_schema.columns + WHERE table_name = 'data_config' AND column_name = 'llm_id') THEN + ALTER TABLE data_config ALTER COLUMN llm_id SET DATA TYPE VARCHAR; + COMMENT ON COLUMN data_config.llm_id IS 'LLM模型配置ID'; + END IF; + + IF EXISTS (SELECT FROM information_schema.columns + WHERE table_name = 'data_config' AND column_name = 'llm') THEN + ALTER TABLE data_config DROP COLUMN llm; + END IF; + END $$; + """) # ### end Alembic commands ### diff --git a/api/migrations/versions/fbab88219447_20251126213637.py b/api/migrations/versions/fbab88219447_20251126213637.py index 224e0ab5..33905c53 100644 --- a/api/migrations/versions/fbab88219447_20251126213637.py +++ b/api/migrations/versions/fbab88219447_20251126213637.py @@ -20,19 +20,29 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### - op.alter_column('data_config', 'workspace_id', - existing_type=sa.UUID(), - comment='工作空间ID', - existing_comment='comment', - existing_nullable=True) + # 检查列是否存在再进行操作 + op.execute(""" + DO $$ + BEGIN + IF EXISTS (SELECT FROM information_schema.columns + WHERE table_name = 'data_config' AND column_name = 'workspace_id') THEN + COMMENT ON COLUMN data_config.workspace_id IS '工作空间ID'; + END IF; + END $$; + """) # ### end Alembic commands ### def downgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### - op.alter_column('data_config', 'workspace_id', - existing_type=sa.UUID(), - comment='comment', - existing_comment='工作空间ID', - existing_nullable=True) + # 检查列是否存在再进行操作 + op.execute(""" + DO $$ + BEGIN + IF EXISTS (SELECT FROM information_schema.columns + WHERE table_name = 'data_config' AND column_name = 'workspace_id') THEN + COMMENT ON COLUMN data_config.workspace_id IS 'comment'; + END IF; + END $$; + """) # ### end Alembic commands ### diff --git a/api/migrations/versions/fda52b5e7c38_20251117114804.py b/api/migrations/versions/fda52b5e7c38_20251117114804.py index de4c5e31..4c3a0619 100644 --- a/api/migrations/versions/fda52b5e7c38_20251117114804.py +++ b/api/migrations/versions/fda52b5e7c38_20251117114804.py @@ -20,8 +20,9 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('mappings_copy1') - op.drop_table('mappings') + # 使用 IF EXISTS 避免表不存在时报错 + op.execute('DROP TABLE IF EXISTS mappings_copy1') + op.execute('DROP TABLE IF EXISTS mappings') op.add_column('agent_configs', sa.Column('knowledge_retrieval', postgresql.JSON(astext_type=sa.Text()), nullable=True, comment='知识库检索配置')) op.add_column('agent_configs', sa.Column('memory', postgresql.JSON(astext_type=sa.Text()), nullable=True, comment='记忆配置')) op.add_column('agent_configs', sa.Column('variables', postgresql.JSON(astext_type=sa.Text()), nullable=True, comment='变量配置')) diff --git a/web/README.md b/web/README.md deleted file mode 100644 index 7ce73358..00000000 --- a/web/README.md +++ /dev/null @@ -1,91 +0,0 @@ -# Memory Bear 前端项目 - -基于 React + TypeScript + Vite + Ant Design 构建的知识库管理系统前端应用。 - -## 技术栈 - -- **框架**: React 18 + TypeScript -- **构建工具**: Vite -- **UI 组件库**: Ant Design 5 -- **样式**: Tailwind CSS 4 -- **路由**: React Router 6 -- **状态管理**: Zustand -- **国际化**: i18next -- **图表**: ECharts -- **其他**: React Markdown - -## 环境要求 - -- Node.js >= 20.19+, 22.12+ -- npm 或 yarn - -## 安装 - -```bash -# 克隆项目 -git clone - -# 进入项目目录 -cd memory-bear-font-end - -# 安装依赖 -npm install -``` - -## 运行 - -### 开发环境 - -```bash -npm run dev -``` - -启动后访问: `http://localhost:5173` - -### 生产构建 - -```bash -npm run build -``` - -构建产物输出到 `dist` 目录。 - -### 预览构建结果 - -```bash -npm run preview -``` - -## 代码检查 - -```bash -npm run lint -``` - -## 项目结构 - -``` -src/ -├── api/ # API 接口 -├── assets/ # 静态资源 -├── components/ # 公共组件 -├── hooks/ # 自定义 Hooks -├── i18n/ # 国际化配置 -├── routes/ # 路由配置 -├── store/ # 状态管理 -├── styles/ # 全局样式 -├── utils/ # 工具函数 -├── views/ # 页面视图 -├── App.tsx # 应用入口组件 -└── main.tsx # 应用入口文件 -``` - -## 配置说明 - -- 开发服务器默认监听 `0.0.0.0:5173` -- API 代理配置在 `vite.config.ts` 中 -- 路径别名 `@` 指向 `src` 目录 - -## License - -Private diff --git a/web/vite.config.ts b/web/vite.config.ts index 7181389f..08054ec9 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -11,7 +11,7 @@ export default defineConfig({ proxy: { // 主要API代理,支持 /api 和 /api/* 格式 '/api': { - target: 'http://0.0.0.0:5173', // 后端服务地址 + target: 'http://127.0.0.1:8000', // 后端服务地址 changeOrigin: true, // 匹配所有以/api开头的请求,包括/api/token