WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
This commit is contained in:
+758
@@ -0,0 +1,758 @@
|
||||
# =====================================================================
|
||||
# WeKnora 环境变量配置示例
|
||||
# ---------------------------------------------------------------------
|
||||
# 使用说明:
|
||||
# 1. 复制此文件为 .env:cp .env.example .env
|
||||
# 2. 替换所有占位符为实际值(尤其标 ⚠️ 必填 的项)
|
||||
# 3. 确保 .env 不会被提交到版本控制
|
||||
#
|
||||
# 配置注入机制:
|
||||
# - app 服务通过 env_file: .env 读取本文件全部变量;
|
||||
# - docreader / mcp / frontend / odl-hybrid 服务无 env_file,仅消费各自
|
||||
# environment 段显式列出的变量(见 docker-compose.yml)。
|
||||
#
|
||||
# 分组顺序:
|
||||
# A. 部署基础 镜像、运行时、网络
|
||||
# B. 数据与存储 数据库、Redis、文件存储、对象存储
|
||||
# C. 检索与图谱 向量库、知识图谱
|
||||
# D. 模型 LLM/VLM/Ollama、内置模型
|
||||
# E. 文档解析 Docreader、任务超时
|
||||
# F. 认证与空间隔离 JWT/AES、注册、RBAC、OIDC
|
||||
# G. Agent 与沙箱 Sandbox、Skills、Agent 超时
|
||||
# H. 可选集成 网络搜索、MCP Server
|
||||
# I. 可观测性 Langfuse
|
||||
# J. 安全与调优 SSRF、代理、并发
|
||||
# =====================================================================
|
||||
|
||||
|
||||
# #####################################################################
|
||||
# A. 部署基础
|
||||
# #####################################################################
|
||||
|
||||
# ========== A1. 镜像版本与构建 ==========
|
||||
# WeKnora 镜像版本标签:latest(稳定版)/ main(最新开发版)。升级到固定版本时,请填写目标 release 的 git tag(如 v0.7.0,注意 v 前缀),并执行 docker compose pull 拉取对应镜像。
|
||||
WEKNORA_VERSION=latest
|
||||
# Alpine apk 镜像源(构建 app 镜像用,留空用默认源;国内可设 mirrors.tencent.com 加速)。
|
||||
APK_MIRROR_ARG=mirrors.tencent.com
|
||||
# 构建 app 镜像时是否链接 anydoc 进程内解析引擎(默认 1)。设 0 可跳过 Rust 工具链、缩短构建时间,引擎在设置页显示为不可用。
|
||||
# WITH_ANYDOC=1
|
||||
# docreader 镜像构建时 apt 镜像源(留空用默认源)。
|
||||
# APT_MIRROR=
|
||||
|
||||
# ========== A2. 运行时基础 ==========
|
||||
# gin 运行模式:debug(开发,详细日志 + Swagger)/ release(生产,禁用 Swagger)。
|
||||
GIN_MODE=release
|
||||
# 日志级别:debug / info / warn / error / fatal,默认 debug。
|
||||
# LOG_LEVEL=debug
|
||||
# 日志文件路径(留空只输出 stdout);LLM_DEBUG_LOG 开启时同目录写 llm_debug.log。
|
||||
# LOG_PATH=
|
||||
# 自定义日志格式模板(留空用内置默认格式)。
|
||||
# LOG_FORMAT=
|
||||
# LLM 调试日志:把每次大模型调用的完整请求/响应写入独立文件,排查上下文问题用。
|
||||
# 可选值:true(写 LOG_PATH 同目录 llm_debug.log)/ false/空(关闭)/ 指定文件路径。
|
||||
# LLM_DEBUG_LOG=true
|
||||
# 时区,默认 Asia/Shanghai。影响系统时间显示与日志时间戳。
|
||||
# 常用值:Asia/Shanghai, Asia/Tokyo, America/New_York, Europe/London, UTC。
|
||||
TZ=Asia/Shanghai
|
||||
# 系统默认语言(BCP-47),用于 Prompt 中 {{language}} 占位符回退。
|
||||
# 优先级:Accept-Language 请求头 > 此变量 > 内置默认值 (en-US)。
|
||||
# WEKNORA_LANGUAGE=zh-CN
|
||||
# 前端界面默认语言(BCP-47)。可选:zh-CN | en-US | ru-RU | ko-KR。
|
||||
# 优先级:用户已选语言(localStorage) > 此变量 > 内置默认 zh-CN。
|
||||
# 仅影响首次访问/未手动切换语言的用户;改完重启 frontend 容器即生效,无需重建镜像。
|
||||
# DEFAULT_LOCALE=en-US
|
||||
# 构建前端镜像时的默认语言回退(仅在不使用 Docker 运行时 config.js 时生效)。
|
||||
# VITE_DEFAULT_LOCALE=en-US
|
||||
# 启动时自动执行数据库迁移;设 false 禁用(默认启用)。
|
||||
AUTO_MIGRATE=true
|
||||
# 自动恢复脏数据迁移状态(默认 true)。
|
||||
AUTO_RECOVER_DIRTY=true
|
||||
# 启动时自动把指定 email 的用户提升为系统管理员(幂等;仅 PROMOTE 从不 DEMOTE)。
|
||||
# 适用于 docker-compose/k8s 首次自举;后续提权/撤销走 SystemAdmin 管理页。
|
||||
# WEKNORA_BOOTSTRAP_SYSTEM_ADMIN_EMAIL=
|
||||
|
||||
# ========== A3. 网络与外部地址 ==========
|
||||
# 应用服务主机名,默认 app(Docker 内部服务名);远程后端可设远程地址。
|
||||
APP_HOST=app
|
||||
# 应用服务宿主机映射端口,默认 8080(仅影响宿主机访问,不影响容器间通信)。
|
||||
APP_PORT=8080
|
||||
# NGINX 代理到后端的目标端口,默认 8080(App 容器内部监听端口)。
|
||||
# 本地部署保持默认;远程部署设为远程 App 服务的实际端口。
|
||||
# APP_BACKEND_PORT=8080
|
||||
# NGINX 代理到后端的协议,默认 http;远程后端为 HTTPS 时设 https。
|
||||
# APP_SCHEME=http
|
||||
# 前端服务端口,默认 80。
|
||||
FRONTEND_PORT=80
|
||||
# Docreader 地址(app 连接 docreader 用)。
|
||||
DOCREADER_ADDR=docreader:50051
|
||||
# Docreader 连接方式:grpc / http / https。
|
||||
DOCREADER_TRANSPORT=grpc
|
||||
# 应用外部访问地址(影响 IM 渠道的图片/文件外链)。
|
||||
# 设为 WeKnora 实例「IM 平台/客户端公网可达」的 URL,如 https://weknora.example.com。
|
||||
# IM 要显示知识库图片,需让 IM 端能通过公网 http URL 拿到图,二选一:
|
||||
# (A) 存储后端本身公网可达(对象存储用公网 endpoint,或 MINIO_ENDPOINT 设为公网 host);
|
||||
# 此时 resource:// 回退到后端预签名 URL,无需本变量。
|
||||
# (B) 设本变量,resource:// 图片改写成 <APP_EXTERNAL_URL>/r/<token> 走 WeKnora(需 nginx 代理 /r/)。
|
||||
# 默认 MinIO 部署(minio:9000 为内网)走 (B) 最简单;local 后端只能走 (B)。
|
||||
# 内网 IP 和 localhost 对飞书/企微/Slack 等 IM 平台不可达;本地开发请用 ngrok/cloudflared/frp。
|
||||
# APP_EXTERNAL_URL=
|
||||
# 前端外部 origin,用于邀请链接等绝对 URL(留空走 host-relative)。
|
||||
# FRONTEND_BASE_URL=
|
||||
# API 响应里文件引用的默认形式:handle(默认)/ public。
|
||||
# handle — 返回内部 resource://<handle>,客户端需再调用带鉴权的 /files 代理取图。
|
||||
# public — 直接返回可加载的 http(s) 链接,第三方 App 拿到即可渲染,无需二次请求。
|
||||
# 单次请求可用 ?resource_urls=handle|public 覆盖本变量,覆盖范围见 README「API 参考」。
|
||||
# public 依赖上面的 APP_EXTERNAL_URL(或存储后端本身公网可达)才能生成外链;
|
||||
# 无法生成时该引用保持 resource:// 原样,客户端仍可回退到 /files。
|
||||
# 注意:public 会为每个被引用文件签发限时(WeKnora 2 小时 / MinIO 24 小时)的匿名可读链接。
|
||||
# 匿名的 embed 渠道与限定知识库的 API Key 不受本变量影响,始终返回 handle。
|
||||
# 建议同时配置 SYSTEM_AES_KEY,以便复用 grant 行、稳定直链 URL 并降低读接口的写入压力。
|
||||
# RESOURCE_URL_MODE=handle
|
||||
# 对外暴露的 MCP Server 端口(默认 8082)。
|
||||
# MCP_PORT=8082
|
||||
|
||||
|
||||
# #####################################################################
|
||||
# B. 数据与存储
|
||||
# #####################################################################
|
||||
|
||||
# ========== B1. 数据库 ⚠️ 必填 ==========
|
||||
# 主数据库类型:postgres / mysql / sqlite。
|
||||
DB_DRIVER=postgres
|
||||
# 数据库主机地址。
|
||||
DB_HOST=postgres
|
||||
# 数据库端口。
|
||||
DB_PORT=5432
|
||||
# 数据库用户名。
|
||||
DB_USER=postgres
|
||||
# 数据库密码。
|
||||
DB_PASSWORD=postgres123!@#
|
||||
# 数据库名称。
|
||||
DB_NAME=WeKnora
|
||||
# SQLite 驱动时使用(DB_DRIVER=sqlite),postgres/mysql 忽略。
|
||||
# DB_PATH=./data/weknora.db
|
||||
|
||||
# ========== B2. Redis / 流处理 / Asynq 队列 ==========
|
||||
# --- 流处理后端与 Redis 连接 ---
|
||||
# 流处理后端:memory / redis。redis 模式下 Asynq 任务队列也依赖此 Redis。
|
||||
STREAM_MANAGER_TYPE=redis
|
||||
# Redis 地址(留空表示禁用 Redis,Lite 模式用 memory)。
|
||||
REDIS_ADDR=redis:6379
|
||||
# Redis 用户名(Redis 6.0+ ACL,可选)。
|
||||
# REDIS_USERNAME=
|
||||
# Redis 密码,未设置可留空。
|
||||
REDIS_PASSWORD=redis123!@#
|
||||
# Redis 数据库索引,默认 0。
|
||||
REDIS_DB=0
|
||||
# Redis key 前缀,用于命名空间隔离。
|
||||
REDIS_PREFIX=stream:
|
||||
# Redis 频道命名空间后缀(多部署共享同一 Redis 时隔离用)。
|
||||
# WEKNORA_REDIS_NAMESPACE=
|
||||
# Asynq 客户端 Redis 读写超时(毫秒,默认 500,写可扩到 1000)。
|
||||
# WEKNORA_REDIS_OP_TIMEOUT_MS=500
|
||||
|
||||
# --- Redis TLS(托管 Redis 加密传输,如 AWS ElastiCache;默认关闭)---
|
||||
# 是否启用 TLS。
|
||||
# REDIS_USE_TLS=false
|
||||
# TLS 证书校验与 SNI 的服务器名(地址为 IP 时有用)。
|
||||
# REDIS_TLS_SERVER_NAME=
|
||||
# 跳过服务器证书校验。不安全,仅开发/自签证书用,生产禁用。
|
||||
# REDIS_TLS_INSECURE_SKIP_VERIFY=false
|
||||
|
||||
# --- Asynq worker 并发 ---
|
||||
# 每个 pool 是独立 asynq.Server,硬隔离;留空或 <1 走代码默认。值为「每实例保底并发」,多副本部署按副本数累加。也可在系统设置(asynq.*_concurrency)运行时调整,无需重启。格式:变量 = 默认值 — 消费的队列 / 说明
|
||||
# default + chat_attachment(文档解析主链路;chat_attachment 权重更高,避免被批量导入挤占)
|
||||
# WEKNORA_ASYNQ_CORE_CONCURRENCY=8
|
||||
# postprocess(解析后的知识库后处理/fan-out;独立成池避免排在长耗时 DocReader 后,需低延迟)
|
||||
# WEKNORA_ASYNQ_POSTPROCESS_CONCURRENCY=2
|
||||
# summary / multimodal / graph / question(摘要、图片多模态、图谱 chunk 抽取、问题生成;LLM/VLM 重,按 provider 预算调)
|
||||
# WEKNORA_ASYNQ_ENRICHMENT_CONCURRENCY=12
|
||||
# sync + low(数据源同步、FAQ 导入、KB 克隆/删除、批量重解析、知识移动;长耗时,不参与 shared 借用)
|
||||
# WEKNORA_ASYNQ_MAINTENANCE_CONCURRENCY=4
|
||||
# 弹性池,可借用 CORE/ENRICHMENT 队列(atomic dequeue 安全);不消费 POSTPROCESS/MAINTENANCE
|
||||
# WEKNORA_ASYNQ_SHARED_CONCURRENCY=6
|
||||
# Wiki 生成专用池(LLM 绑定,按 provider 并发预算调整)
|
||||
# WEKNORA_WIKI_ASYNQ_CONCURRENCY=8
|
||||
# 后台任务每模型并发上限(0/负数禁用;交互 chat 不受其限制)
|
||||
# WEKNORA_MODEL_MAX_CONCURRENCY=32
|
||||
|
||||
# ========== B3. 文件存储(通用)==========
|
||||
# 文件存储类型:local / minio / cos / tos / s3 / obs / oss / dummy。
|
||||
STORAGE_TYPE=local
|
||||
# 允许用户使用的存储类型白名单(逗号分隔,留空允许全部)。
|
||||
# STORAGE_ALLOW_LIST=local,minio,cos,tos,s3,obs,oss
|
||||
# 本地存储根目录(STORAGE_TYPE=local 时用)。
|
||||
LOCAL_STORAGE_BASE_DIR=/data/files
|
||||
# 本地存储路径前缀(legacy 回填用,一般留空)。
|
||||
# LOCAL_STORAGE_PATH_PREFIX=
|
||||
# 统一文件大小限制(MB,默认 50)。影响知识库/附件单文件上传、docreader gRPC 消息、浏览器知识库校验。属部署期配置:Go/Nginx/docreader/浏览器四层启动时读一次,运行中改不生效,改后须同步重启四层。
|
||||
# MAX_FILE_SIZE_MB=50
|
||||
# 技能 zip 上传与 GitHub/ClawHub 来源下载上限(MB,默认 256)。与知识库上传分开:ppt-master 一类技能包会超过 50MB。GitHub zipball 按整仓压缩包计,不是 SKILL.md 子树;仓过大仍会在下载阶段被拒。未设置时不低于 MAX_FILE_SIZE_MB,且不超过 512。Nginx 只对 POST /api/v1/skills/catalog 与 POST /api/v1/sandbox-configs/{id}/skills 放宽到这个值,/install、PATCH 等子路径仍是 MAX_FILE_SIZE_MB。改后须重启 app 与 frontend。
|
||||
# MAX_SKILL_BUNDLE_SIZE_MB=256
|
||||
|
||||
# ========== B4. 对象存储 provider(按 STORAGE_TYPE 选其一)==========
|
||||
# ----- MinIO(STORAGE_TYPE=minio)-----
|
||||
# MinIO 端点(host:port)。IM 渠道时必须是 IM 平台公网可达的 host(不能用 minio:9000)。
|
||||
# MINIO_ENDPOINT=minio:9000
|
||||
# MINIO_ACCESS_KEY_ID=your_minio_access_key
|
||||
# MINIO_SECRET_ACCESS_KEY=your_minio_secret_key
|
||||
# MINIO_BUCKET_NAME=your_minio_bucket_name
|
||||
# MINIO_PATH_PREFIX=
|
||||
# MINIO_USE_SSL=false
|
||||
# MinIO 端口(compose 映射用)。
|
||||
# MINIO_PORT=9000
|
||||
# MINIO_CONSOLE_PORT=9001
|
||||
# ----- 腾讯云 COS(STORAGE_TYPE=cos)-----
|
||||
# COS_SECRET_ID=your_cos_secret_id
|
||||
# COS_SECRET_KEY=your_cos_secret_key
|
||||
# COS_REGION=ap-guangzhou
|
||||
# COS_BUCKET_NAME=your_cos_bucket_name
|
||||
# COS_APP_ID=your_cos_app_id
|
||||
# COS_PATH_PREFIX=your_cos_path_prefix
|
||||
# COS_TEMP_BUCKET_NAME=your_cos_temp_bucket_name
|
||||
# COS_TEMP_REGION=your_cos_temp_region
|
||||
# ----- 火山引擎 TOS(STORAGE_TYPE=tos)-----
|
||||
# TOS_ENDPOINT=https://tos-cn-beijing.volces.com
|
||||
# TOS_REGION=cn-beijing
|
||||
# TOS_ACCESS_KEY=your_tos_access_key
|
||||
# TOS_SECRET_KEY=your_tos_secret_key
|
||||
# TOS_BUCKET_NAME=your_tos_bucket_name
|
||||
# TOS_PATH_PREFIX=your_tos_path_prefix
|
||||
# TOS_TEMP_BUCKET_NAME=your_tos_temp_bucket_name
|
||||
# TOS_TEMP_REGION=your_tos_temp_region
|
||||
# 如果使用AWS S3作为文件存储,需要配置以下参数
|
||||
# AWS S3的访问端点(可选;留空使用 Region 对应的 AWS 标准端点,例如 https://s3.amazonaws.com)
|
||||
# ----- AWS S3(STORAGE_TYPE=s3)-----
|
||||
# S3_ENDPOINT=https://s3.amazonaws.com
|
||||
# S3_REGION=us-east-1
|
||||
# AWS S3访问密钥 Access Key(可选;AK/SK 同时留空时使用 AWS SDK 默认凭证链,
|
||||
# 支持 EC2/ECS/EKS IAM Role、IRSA/Web Identity、环境变量和共享配置文件)
|
||||
# AWS S3访问密钥 Secret Key(必须与 Access Key 同时填写或同时留空)
|
||||
# S3_SECRET_KEY=your_s3_secret_key
|
||||
# S3_ACCESS_KEY=your_s3_access_key
|
||||
# S3_BUCKET_NAME=your_s3_bucket_name
|
||||
# S3_PATH_PREFIX=weknora/
|
||||
# S3_USE_SSL=true
|
||||
# S3_FORCE_PATH_STYLE=false
|
||||
# ----- 华为云 OBS(STORAGE_TYPE=obs)-----
|
||||
# OBS_ENDPOINT=obs.cn-north-4.myhuaweicloud.com
|
||||
# OBS_REGION=cn-north-4
|
||||
# OBS_ACCESS_KEY=your_obs_access_key
|
||||
# OBS_SECRET_KEY=your_obs_secret_key
|
||||
# OBS_BUCKET_NAME=your_obs_bucket_name
|
||||
# OBS_PATH_PREFIX=weknora/
|
||||
# OBS_USE_SSL=true
|
||||
# OBS_PROXY_DOMAIN=https://your-domain.com/obs
|
||||
# ----- 阿里云 OSS(STORAGE_TYPE=oss)-----
|
||||
# OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com
|
||||
# OSS_REGION=cn-hangzhou
|
||||
# OSS_ACCESS_KEY=your_oss_access_key
|
||||
# OSS_SECRET_KEY=your_oss_secret_key
|
||||
# OSS_BUCKET_NAME=your_oss_bucket_name
|
||||
# OSS_PATH_PREFIX=weknora/
|
||||
# OSS_TEMP_BUCKET_NAME=your_oss_temp_bucket_name
|
||||
# OSS_TEMP_REGION=your_oss_temp_region
|
||||
|
||||
|
||||
# #####################################################################
|
||||
# C. 检索与图谱
|
||||
# #####################################################################
|
||||
|
||||
# ========== C1. 向量库 / 检索引擎 ==========
|
||||
# 向量存储类型(逗号分隔可多驱动):postgres / elasticsearch_v7 / elasticsearch_v8 /
|
||||
# opensearch / qdrant / milvus / weaviate / doris / tencent_vectordb。
|
||||
RETRIEVE_DRIVER=postgres
|
||||
# 多向量库并行检索超时(秒,RETRIEVE_DRIVER 含多个驱动时生效)。
|
||||
# MULTI_STORE_RETRIEVE_TIMEOUT_SEC=
|
||||
# ----- Elasticsearch v7/v8 -----
|
||||
# ELASTICSEARCH_ADDR=http://localhost:9200
|
||||
# ELASTICSEARCH_USERNAME=your_elasticsearch_username
|
||||
# ELASTICSEARCH_PASSWORD=your_elasticsearch_password
|
||||
# ELASTICSEARCH_INDEX=WeKnora
|
||||
# ----- OpenSearch(dev compose 自带单节点 profile)-----
|
||||
# OPENSEARCH_ADDR=http://opensearch:9200
|
||||
# OPENSEARCH_USERNAME=
|
||||
# OPENSEARCH_PASSWORD=
|
||||
# OPENSEARCH_INSECURE_SKIP_VERIFY=false
|
||||
# OPENSEARCH_INDEX=WeKnora
|
||||
# ----- Qdrant -----
|
||||
# QDRANT_HOST=qdrant
|
||||
# QDRANT_PORT=6334
|
||||
# QDRANT_REST_PORT=6333
|
||||
# QDRANT_COLLECTION=weknora_embeddings
|
||||
# QDRANT_API_KEY=your_qdrant_api_key
|
||||
# QDRANT_USE_TLS=false
|
||||
# ----- Milvus -----
|
||||
# MILVUS_ADDRESS=milvus:19530
|
||||
# MILVUS_COLLECTION=weknora_embeddings
|
||||
# MILVUS_METRIC_TYPE=IP # IP(默认)/COSINE/L2,改后需重建 collection。
|
||||
# MILVUS_USERNAME=your_milvus_username
|
||||
# MILVUS_PASSWORD=your_milvus_password
|
||||
# MILVUS_DB_NAME=your_milvus_db_name
|
||||
# ----- Weaviate -----
|
||||
# 容器内访问用 service:port(不要用 localhost/宿主机映射端口)。
|
||||
# WEAVIATE_HOST=weaviate:8080
|
||||
# WEAVIATE_GRPC_ADDRESS=weaviate:50051
|
||||
# WEAVIATE_SCHEME=http
|
||||
# WEAVIATE_AUTH_ENABLED=false # 启用 APIKey/OIDC 时再设 true 并配 WEAVIATE_API_KEY。
|
||||
# WEAVIATE_API_KEY=your_secret_key
|
||||
# WEAVIATE_COLLECTION=your_weaviate_db_name
|
||||
# ----- Apache Doris 4.1+ -----
|
||||
# DORIS_ADDR=doris-fe:9030 # FE MySQL 协议地址。
|
||||
# DORIS_HTTP_PORT=8030 # FE HTTP,Stream Load partial update 入口。
|
||||
# DORIS_DATABASE=weknora
|
||||
# DORIS_USERNAME=root
|
||||
# DORIS_PASSWORD=
|
||||
# DORIS_TABLE_PREFIX=weknora_embeddings
|
||||
# DORIS_COMPAT_MODE=auto # auto(探测)/legacy(cosine_distance)/inner_product_duplicate;建表后不可互换。
|
||||
# compose 端口。
|
||||
# DORIS_FE_HTTP_PORT=8030
|
||||
# DORIS_FE_MYSQL_PORT=9030
|
||||
# DORIS_BE_HTTP_PORT=8040
|
||||
# ----- 腾讯云 Tencent VectorDB -----
|
||||
# TENCENT_VECTORDB_ADDR=http://your-instance.tencentvectordb.com
|
||||
# TENCENT_VECTORDB_USERNAME=root
|
||||
# TENCENT_VECTORDB_API_KEY=your_tencent_vectordb_api_key
|
||||
# TENCENT_VECTORDB_DATABASE=weknora
|
||||
# TENCENT_VECTORDB_COLLECTION=weknora_embeddings
|
||||
# TENCENT_VECTORDB_REPLICA_NUMBER=1
|
||||
|
||||
# ========== C2. 知识图谱(Neo4j,可选)==========
|
||||
# 知识图谱唯一全局开关。非 true 则禁用图谱构建与检索(构建阶段需调用大模型,耗时较长)。
|
||||
# 注:ENABLE_GRAPH_RAG 自 v0.1.6 起已被 NEO4J_ENABLE 取代,Go 主应用不再读取,已移除。
|
||||
# NEO4J_ENABLE=false
|
||||
# Neo4j 连接 URI。bolt:// 直连单机(推荐,无路由开销);neo4j:// 走集群路由发现(单机也能用但多一次探测)。
|
||||
# WeKnora 默认部署单机 Neo4j,故用 bolt://。
|
||||
# NEO4J_URI=bolt://neo4j:7687
|
||||
# NEO4J_USERNAME=neo4j
|
||||
# NEO4J_PASSWORD=password
|
||||
|
||||
|
||||
# #####################################################################
|
||||
# D. 模型
|
||||
# #####################################################################
|
||||
|
||||
# ========== D1. LLM / VLM / Ollama ==========
|
||||
# Ollama 不可用时仅告警不阻断(true 时生效,默认不阻断,避免未配 Ollama 就启动失败)。
|
||||
OLLAMA_OPTIONAL=true
|
||||
# Ollama 服务基准 URL(连接本地/远程 Ollama)。
|
||||
OLLAMA_BASE_URL=http://host.docker.internal:11434
|
||||
# 批量 embedding 大小(留空走代码默认)。
|
||||
# BATCH_EMBED_SIZE=
|
||||
# VLM 单次 HTTP 请求超时(秒,默认 180;慢端点超时报 context deadline 时调大)。
|
||||
# VLM_HTTP_TIMEOUT_SECONDS=180
|
||||
# LLM 流原始转储(排查上下文问题;1 启用,默认目录 ~/.weknora/investigate/llm-stream)。
|
||||
# WEKNORA_LLM_STREAM_RAW_DUMP=
|
||||
# WEKNORA_LLM_STREAM_RAW_DUMP_DIR=
|
||||
|
||||
# ========== D2. 内置模型声明式配置(可选)==========
|
||||
# 启用 config/builtin_models.yaml(见 config/builtin_models.yaml.example)后,YAML 中的
|
||||
# ${NAME} 占位符会在应用启动期从这里读取真实值。变量名由 YAML 自定,下面给出常见命名参考。
|
||||
# 详见 docs/BUILTIN_MODELS.md。
|
||||
#
|
||||
# 内置对话模型
|
||||
# LLM_MODEL_NAME=
|
||||
# LLM_BASE_URL=
|
||||
# LLM_API_KEY=
|
||||
# LLM_PROVIDER=openai
|
||||
#
|
||||
# 内置向量模型
|
||||
# EMBEDDING_MODEL_NAME=
|
||||
# EMBEDDING_BASE_URL=
|
||||
# EMBEDDING_API_KEY=
|
||||
# EMBEDDING_PROVIDER=openai
|
||||
#
|
||||
# 内置 Rerank 模型
|
||||
# RERANK_MODEL_NAME=
|
||||
# RERANK_BASE_URL=
|
||||
# RERANK_API_KEY=
|
||||
# RERANK_PROVIDER=generic
|
||||
|
||||
|
||||
# #####################################################################
|
||||
# E. 文档解析
|
||||
# #####################################################################
|
||||
|
||||
# ========== E1. Docreader gRPC 与解析通用 ==========
|
||||
# ----- gRPC 服务端 TLS / 认证(docreader 与 app 共用,客户端/服务端需配相同值)-----
|
||||
# 启用 gRPC TLS 加密传输。默认 false(docreader 仅在 compose 内网供 app 调用,明文即可)。
|
||||
# GRPC_TLS_ENABLED=false
|
||||
# 服务端证书 / 客户端证书(mTLS 时填)。server 启用 TLS 时必填。
|
||||
# GRPC_TLS_CERT=/path/to/server.crt
|
||||
# 证书对应私钥。
|
||||
# GRPC_TLS_KEY=/path/to/server.key
|
||||
# CA 证书:client 用于校验 server 证书;server 端 mTLS 时用于签发客户端证书的 CA。
|
||||
# GRPC_TLS_CA=/path/to/ca.crt
|
||||
# TLS SNI / 证书校验名。DOCREADER_ADDR 用 IP、或与证书 CN 不一致时,设为证书里的 hostname。
|
||||
# GRPC_TLS_SERVER_NAME=
|
||||
# server 端:true 时强制要求客户端提供证书(双向 TLS / mTLS)。
|
||||
# GRPC_MTLS_REQUIRE_CLIENT_CERT=false
|
||||
# gRPC per-RPC 认证 token(客户端/服务端需一致)。启用时强烈建议同时开 TLS,
|
||||
# 否则 token 会明文随每次 RPC 传输;GRPC_TLS_ENABLED=true 时客户端会拒绝在不安全连接上发送 token。
|
||||
# GRPC_AUTH_TOKEN=your-secret-token-at-least-16-bytes
|
||||
# ----- gRPC 服务参数 -----
|
||||
# gRPC server 最大并发 worker 数(默认 4)。
|
||||
# DOCREADER_GRPC_MAX_WORKERS=4
|
||||
# gRPC 单条消息大小上限(MB,默认 50)。通常跟随 MAX_FILE_SIZE_MB;过大扫描件图可能超此值。
|
||||
# DOCREADER_GRPC_MAX_FILE_SIZE_MB=50
|
||||
# gRPC 监听端口(默认 50051)一般无需修改,需要与docker-compose中的docreader port保持一致。
|
||||
# DOCREADER_GRPC_PORT=50051
|
||||
# ----- 解析通用 -----
|
||||
# DOCX 解析最大页数(默认 0=不限制)。设正整数(如 500)可限制超大 Word 文档解析开销。
|
||||
# DOCREADER_DOCX_MAX_PAGES=0
|
||||
# MarkItDown(重型解析器)并发 worker 数。0 关闭限流;默认 1 适合单机/GPU 受限。
|
||||
# DOCREADER_MARKITDOWN_MAX_WORKERS=1
|
||||
# 出站 HTTP/HTTPS 代理(抓取网页 / 远程图片时用)。
|
||||
# DOCREADER_EXTERNAL_HTTP_PROXY=
|
||||
# DOCREADER_EXTERNAL_HTTPS_PROXY=
|
||||
|
||||
# ========== E2. OpenDataLoader hybrid(可选,知识库 parser_engine_rules 指定 engine: opendataloader)==========
|
||||
# ODL 重型解析器并发(默认 1)。
|
||||
# DOCREADER_ODL_MAX_WORKERS=1
|
||||
# hybrid 模式开关:off(默认,纯 fast 本地解析)/ docling-fast 等(需另起 odl-hybrid 容器)。
|
||||
# DOCREADER_ODL_HYBRID=off
|
||||
# odl-hybrid 服务地址。代码默认 http://127.0.0.1:5002;docker compose 注入 http://odl-hybrid:5002。
|
||||
# DOCREADER_ODL_HYBRID_URL=http://odl-hybrid:5002
|
||||
# hybrid 模式选择(auto),一般无需改。
|
||||
# DOCREADER_ODL_HYBRID_MODE=auto
|
||||
# hybrid 失败时是否回退到 fast 模式(默认 false)。
|
||||
# DOCREADER_ODL_HYBRID_FALLBACK=false
|
||||
# hybrid 输出 markdown 是否保留 HTML(默认 false)。
|
||||
# DOCREADER_ODL_MARKDOWN_WITH_HTML=false
|
||||
# ----- odl-hybrid 容器参数(仅 --profile odl-hybrid 时用)-----
|
||||
# 传给 odl-hybrid 的额外参数。默认 --no-ocr(数字 PDF);扫描件用 --force-ocr(需 EasyOCR+libGL)。
|
||||
# ODL_HYBRID_EXTRA_ARGS=--no-ocr
|
||||
# odl-hybrid 对外端口(dev compose 映射用)。
|
||||
# ODL_HYBRID_PORT=5002
|
||||
# app 启动时等待 odl-hybrid 就绪的最长时间(秒)。
|
||||
# ODL_HYBRID_STARTUP_WAIT_SEC=180
|
||||
|
||||
# ========== E3. PDF 渲染 ==========
|
||||
# PDF 渲染 worker 数(跨文档并发,默认 1)。
|
||||
# DOCREADER_PDF_RENDER_MAX_WORKERS=1
|
||||
# 单个 PDF 内部「扫描页渲染」的并行进程数(pdfium 非线程安全,按进程并行)。默认随 CPU 取min(4, cpu);大体量扫描件串行渲染在 CPU 受限容器上可能耗时数小时,调大可显著缩短(实测874 页 117s->30s/4 进程 ->17s/8 进程)。容器需分配足够 CPU 才能吃到加速。
|
||||
# DOCREADER_PDF_RENDER_PARALLELISM=4
|
||||
# 渲染 DPI(默认 200)。
|
||||
# DOCREADER_PDF_RENDER_DPI=200
|
||||
# 渲染图 JPEG 质量(默认 85)。
|
||||
# DOCREADER_PDF_JPEG_QUALITY=85
|
||||
# 渲染页图的最大长边像素(默认 2000)。防止超大页面渲染出 100+MP 图撑爆 gRPC 消息上限;调小可进一步减小图片体积,过小会影响 OCR 识别(密集中文建议 >=1600)。
|
||||
# DOCREADER_PDF_RENDER_MAX_EDGE=2000
|
||||
|
||||
# ========== E4. PDF 扫描件判定与强制 ==========
|
||||
# 强制所有 PDF 全页按扫描件解析(渲染成图片再走 OCR/VLM)。适合文本层质量差但页面可 OCR 的PDF;开启后速度和模型调用成本都会上升。默认 false(由下方阈值自动判定)。
|
||||
# DOCREADER_PDF_FORCE_SCANNED=false
|
||||
# 自动判定:一页的图片对象覆盖面积 >= 此比例视为扫描页(默认 0.5)。
|
||||
# DOCREADER_PDF_SCAN_IMAGE_RATIO=0.5
|
||||
# 自动判定:一页文本字符数少于此值视为「无可用文本层」(默认 10)。
|
||||
# DOCREADER_PDF_SCAN_MIN_CHARS=10
|
||||
|
||||
# ========== E5. PDF 版式 / 文本抽取(原生文本页)==========
|
||||
# 版式感知文本抽取:重建可读阅读顺序(默认 true)。
|
||||
# DOCREADER_PDF_LAYOUT_ORDERING=true
|
||||
# glyph 无显式空格定位时(OCR/搜索文本常见),间距超过 median 字宽的多少倍时插空格(默认 0.4)。
|
||||
# DOCREADER_PDF_WORD_GAP_WIDTH_RATIO=0.4
|
||||
# 丢弃窄边栏(arXiv 侧栏水印、页码标签)的页宽比例阈值(默认 0.12)。
|
||||
# DOCREADER_PDF_MARGIN_COL_WIDTH_RATIO=0.12
|
||||
# 一行至少多少字符才允许字体大小启发式将其提升为 markdown 标题(默认 8)。
|
||||
# DOCREADER_PDF_MIN_HEADING_LINE_CHARS=8
|
||||
# 把视觉上较大的行提升为 markdown 标题(用 rect 高度做字体大小代理,默认 true)。
|
||||
# DOCREADER_PDF_DETECT_HEADINGS=true
|
||||
# 丢弃不可见文本(render-mode 3)、页外文本和退化文本,防 hidden-text 注入(默认 true)。
|
||||
# DOCREADER_PDF_FILTER_HIDDEN_TEXT=true
|
||||
# 清理 pdfium 占位符(U+FFFE)和软连字符;剥离矢量图坐标轴/图例文字(默认 true)。
|
||||
# DOCREADER_PDF_SANITIZE_TEXT=true
|
||||
# 剥离图表区碎片文本(与 SANITIZE_TEXT 配合,默认 true)。
|
||||
# DOCREADER_PDF_STRIP_CHART_DEBRIS=true
|
||||
|
||||
# ========== E6. PDF 嵌入图片抽取(原生文本页)==========
|
||||
# 抽取原生页面里的嵌入位图供 VLM/OCR 分析(默认 true)。
|
||||
# DOCREADER_PDF_EXTRACT_EMBEDDED_IMAGES=true
|
||||
# 嵌入图片保留的最小像素宽与高(默认 80,低于则丢弃)。
|
||||
# DOCREADER_PDF_EMBED_MIN_PIXELS=80
|
||||
# 嵌入图片占页面面积的最小比例,低于则丢弃(默认 0.01)。
|
||||
# DOCREADER_PDF_EMBED_MIN_AREA_RATIO=0.01
|
||||
# 同一张图在文本页中出现比例 >= 此值时视为页眉页脚等运行项并去重(默认 0.5)。
|
||||
# DOCREADER_PDF_EMBED_REPEAT_PAGE_FRAC=0.5
|
||||
# 每篇文档抽取嵌入图片的硬上限(默认 50)。
|
||||
# DOCREADER_PDF_EMBED_MAX_IMAGES=50
|
||||
|
||||
# ========== E7. PDF 图表区识别(矢量图区域)==========
|
||||
# 把检测到的矢量图表区(无嵌入位图)渲染成 JPEG 供 VLM/OCR(默认 true)。
|
||||
# DOCREADER_PDF_RENDER_VECTOR_FIGURES=true
|
||||
# 一个区域被视为图表所需的每行最少字符数(默认 18)。
|
||||
# DOCREADER_PDF_MIN_CHART_REGION_CHARS=18
|
||||
# 一个区域被视为图表所需的最小面积占比(默认 0.015)。
|
||||
# DOCREADER_PDF_MIN_CHART_REGION_AREA=0.015
|
||||
# 一个区域被视为图表的最大面积占比(超过则不当图表,默认 0.42)。
|
||||
# DOCREADER_PDF_MAX_CHART_REGION_AREA=0.42
|
||||
# 图形高度占页高的最大比例,超过则不当作独立图表(默认 0.38)。
|
||||
# DOCREADER_PDF_MAX_FIGURE_HEIGHT_RATIO=0.38
|
||||
|
||||
# ========== E8. 文档处理 / 任务超时 ==========
|
||||
# 单文档处理任务总超时(Go duration,默认 2h;大文件可调大)。
|
||||
# WEKNORA_DOCUMENT_PROCESS_TIMEOUT=2h
|
||||
# 单次 DocReader RPC 超时(默认 30m,须小于上一项)。
|
||||
# WEKNORA_DOCREADER_CALL_TIMEOUT=30m
|
||||
# 知识库巡检回收(回收卡在 processing 的脏数据;false 禁用,不建议)。
|
||||
# WEKNORA_HOUSEKEEPING_ENABLED=true
|
||||
# 聊天附件解析产物保留时长(小时,默认 24)。
|
||||
# WEKNORA_CHAT_ATTACHMENT_TTL_HOURS=24
|
||||
# 单轮问答等待附件解析完成的最长时间(秒,默认 60;大/扫描件调大)。
|
||||
# WEKNORA_CHAT_ATTACHMENT_WAIT_TIMEOUT_SEC=60
|
||||
# 附件 VLM OCR 调优(扫描件/图片型文档):并发数 / 最大页数。
|
||||
# WEKNORA_CHAT_ATTACHMENT_OCR_CONCURRENCY=8
|
||||
# WEKNORA_CHAT_ATTACHMENT_OCR_MAX_PAGES=8
|
||||
|
||||
# ========== E9. 飞书云文档解析模式 ==========
|
||||
# 控制飞书云盘 / 飞书知识库同步新版云文档(docx)时的解析路径:
|
||||
# export(默认,留空即可):走异步导出 API 下载 .docx 二进制,交给 docreader
|
||||
# 解析。图片 inline 进父文档(parent_chunk_id 同 knowledge 关联),检索 /
|
||||
# wiki / 智能体三个场景都能关联图片内容。代价:同步变慢;飞书 docx 内的
|
||||
# file block 附件不会随导出下载(会丢,需单独同步);若云文档中存在电子表格、
|
||||
# 多维表格等,导出后会变成内嵌在 docx 中的表格,weknora 会无法解析这些表格。
|
||||
# blocks:走 blocks API 转 Markdown。快,保留 docx 内附件;但图片渲染成空
|
||||
# `![图片]()` 占位符、并作为独立知识条目入库,与父文档无内容关联(检索 /
|
||||
# wiki / 智能体均无法把图片关联回文档);电子表格会解析成 markdown 表格,
|
||||
# 存在丢失合并单元格等样式风险。
|
||||
# 不需要图片关联、追求速度、要保留附件或需保留电子表格样式时设为 blocks。
|
||||
# 此方案为临时方案,若后续有更好的解析方案,会移除此环境变量并替换。
|
||||
# FEISHU_DOCX_PARSE_MODE=export
|
||||
|
||||
# #####################################################################
|
||||
# F. 认证与空间隔离
|
||||
# #####################################################################
|
||||
|
||||
# ========== F1. 密钥 ⚠️ 必填(生产务必改默认值)==========
|
||||
# JWT 签名密钥,用于前端登录刷新 Token(留空则启动时随机生成)。
|
||||
JWT_SECRET=weknora-jwt-secret
|
||||
# AES-256 主密钥,用于数据库中 API Key 等敏感字段落盘加密(必须为 32 字节)。
|
||||
# 加密范围:tenants.api_key、模型 API key、向量库凭证、web search provider key、
|
||||
# WeKnoraCloud.AppSecret 等。缺失或长度不对时已加密字段无法解密(UI 显示为空,需重填)。
|
||||
# 生产必须显式设置且妥善保管,丢失则所有加密数据不可恢复。
|
||||
# 注:TENANT_AES_KEY / CRYPTO_MASTER_KEY / CRYPTO_SALT 已废弃。v0.4.0 起 CryptoService(PBKDF2)被移除,加密统一改用 SYSTEM_AES_KEY。这三个变量 Go 主应用不再读取,已移除。
|
||||
SYSTEM_AES_KEY=weknora-system-aes-key-32bytes!!
|
||||
|
||||
# ========== F2. 注册与空间策略 ==========
|
||||
# 禁止新用户注册(生产建议 true)。
|
||||
# DISABLE_REGISTRATION=false
|
||||
# 开启后密码必须包含大小写字母、数字和特殊字符,特殊字符包含:!@#$%^&*()_+-=[]{}|;:,.<>?
|
||||
# WEKNORA_AUTH_COMPLEX_PASSWORD_ENABLED=false
|
||||
# 公开注册后的默认空间策略:create_personal(默认,自动创建个人空间)/ tenantless。
|
||||
# WEKNORA_AUTH_DEFAULT_TENANT_MODE=create_personal
|
||||
# 是否允许普通用户登录后主动创建空间(默认 true;false 仅能邀请加入,超管不受影响)。
|
||||
# WEKNORA_TENANT_SELF_SERVICE_CREATION_ENABLED=true
|
||||
# 启用空间级 RBAC(true 默认强制鉴权 / false 观察模式仅记录不拦截)。
|
||||
# WEKNORA_TENANT_ENABLE_RBAC=true
|
||||
# 启用跨空间访问(需配合用户的 CanAccessAllTenants 权限;默认 false)。如需跨空间管理员,需要手动修改数据库,为对应用户授予跨空间管理权限
|
||||
# WEKNORA_TENANT_ENABLE_CROSS_TENANT_ACCESS=false
|
||||
# 单个非超管用户可自助创建空间数上限(>0 强制限额;=0 走默认;<0 关闭限额)。
|
||||
# WEKNORA_TENANT_MAX_OWNED_PER_USER=
|
||||
# 创建空间时是否自动生成 full_access API Key 并在响应中返回 token(默认 false,兼容旧版回退开关)。
|
||||
# WEKNORA_TENANT_AUTO_CREATE_API_KEY=false
|
||||
# 新建空间默认存储配额(GB,默认 10)。
|
||||
# WEKNORA_TENANT_DEFAULT_STORAGE_QUOTA_GB=10
|
||||
# 空间邀请链接 TTL(Go duration,默认 168h=7 天)。
|
||||
# WEKNORA_INVITATION_TTL=168h
|
||||
# 全局开关:邀请已注册用户时直接加入空间。默认 false,可在系统设置页修改。
|
||||
# WEKNORA_TENANT_AUTO_ACCEPT_INVITATION=false
|
||||
# 审计日志保留天数(0 禁用清理,默认 90)。
|
||||
# WEKNORA_AUDIT_RETENTION_DAYS=90
|
||||
|
||||
# ========== F3. OIDC 认证(可选,OIDC_AUTH_ENABLE=true 启用)==========
|
||||
# OIDC_AUTH_ENABLE=false
|
||||
# OIDC_AUTH_ISSUER_URL=http://127.0.0.1:5556/dex
|
||||
# OIDC_AUTH_DISCOVERY_URL=http://127.0.0.1:5556/dex/.well-known/openid-configuration
|
||||
# OIDC_AUTH_PROVIDER_DISPLAY_NAME=OIDC
|
||||
# OIDC_AUTH_CLIENT_ID=client_id_for_oidc_client
|
||||
# OIDC_AUTH_CLIENT_SECRET=secret_for_oidc_client
|
||||
# 若 OIDC_AUTH_DISCOVERY_URL 已填,以下端点可留空。即使显式填写了授权/Token 端点,
|
||||
# 只要 jwks_uri 或 issuer 为空,仍会走 discovery 补全,用于校验 id_token。
|
||||
# OIDC_AUTH_AUTHORIZATION_ENDPOINT=http://127.0.0.1:5556/dex/auth
|
||||
# OIDC_AUTH_TOKEN_ENDPOINT=http://127.0.0.1:5556/dex/token
|
||||
# OIDC_AUTH_USER_INFO_ENDPOINT=http://127.0.0.1:5556/dex/userinfo
|
||||
# OIDC_AUTH_JWKS_URI=http://127.0.0.1:5556/dex/keys
|
||||
# OIDC_AUTH_SCOPES="openid profile email"
|
||||
# OIDC 首次自动开户后的空间策略与本地注册共用 WEKNORA_AUTH_DEFAULT_TENANT_MODE,无独立配置。
|
||||
# OIDC_USER_INFO_MAPPING_USER_NAME=name
|
||||
# OIDC_USER_INFO_MAPPING_EMAIL=email
|
||||
|
||||
|
||||
# #####################################################################
|
||||
# G. Agent 与沙箱
|
||||
# #####################################################################
|
||||
|
||||
# ========== G1. Agent Skills Sandbox ==========
|
||||
# Sandbox 后端、凭据、模板、超时和私网访问策略全部在「设置 → 沙箱后端」
|
||||
# 按空间维护。唯一的部署级开关是 Docker 后端:默认关闭,因为本机
|
||||
# docker.sock 等同宿主机 root;私有化单机需要时改为 true,或由系统管理员在
|
||||
# 「设置 → 系统设置 → 网络安全」打开(立即生效,无需重启)。
|
||||
WEKNORA_SANDBOX_DOCKER_ENABLED=false
|
||||
# 多实例仍应配置通用 REDIS_ADDR,以共享 session→sandbox binding。
|
||||
|
||||
# 自定义 Skills 目录(挂载后指定,免重建镜像)。
|
||||
# WEKNORA_SKILLS_DIR=
|
||||
# 智能体大模型调用默认超时(秒,默认 120;复杂推理调大如 300/600)。
|
||||
# 注:全局默认;单个智能体在数据库配独立 llm_call_timeout 时以其为准。
|
||||
# WEKNORA_AGENT_LLM_TIMEOUT=300
|
||||
# MCP 工具人工审核等待超时(秒,默认 600;支持 Go duration 如 30s/5m/1h)。
|
||||
# WEKNORA_AGENT_TOOL_APPROVAL_TIMEOUT=600
|
||||
# MCP 工具人工审批失败时是否放行(true 放行 / 默认 fail-close 拒绝)。
|
||||
# WEKNORA_AGENT_TOOL_APPROVAL_FAIL_OPEN=
|
||||
|
||||
|
||||
# #####################################################################
|
||||
# H. 可选集成
|
||||
# #####################################################################
|
||||
|
||||
# ========== H1. 网络搜索(SearXNG / Tavily)==========
|
||||
# ===== SearXNG(可选自建网络搜索)=====
|
||||
# 启用:`docker compose --profile searxng up -d`。
|
||||
# SEARXNG_SECRET 不设时使用 docker-compose 中的写死默认值,足够本机/loopback 部署使用;
|
||||
# 如果改 SEARXNG_BIND=0.0.0.0 把实例暴露到 LAN/公网,请务必用 openssl rand -hex 32 生成
|
||||
# 并显式设置 SEARXNG_SECRET,否则默认值会被任何人用于签名 image-proxy URL。
|
||||
#
|
||||
# 后端访问方式(容器化部署):控制台中 Provider 类型选 "SearXNG",
|
||||
# Instance URL 填 http://searxng:8080;docker compose 已默认把 searxng 主机名注入
|
||||
# SSRF_WHITELIST_EXTRA,无需额外配置。
|
||||
#
|
||||
# 本地开发(go run + docker compose -f docker-compose.dev.yml --profile searxng up):
|
||||
# 后端跑在宿主机,需走 published 端口;Instance URL 填 http://127.0.0.1:8888,
|
||||
# 并把 127.0.0.1 加入下方 SSRF_WHITELIST。
|
||||
#
|
||||
# 端口默认仅监听 127.0.0.1,避免把开了 limiter:false 的实例暴露到 LAN。
|
||||
# 如需对内网开放,显式覆盖 SEARXNG_BIND=0.0.0.0 并自行加固。
|
||||
# SEARXNG_PORT=8888
|
||||
# SEARXNG_BIND=127.0.0.1
|
||||
# SEARXNG_SECRET=
|
||||
# Tavily Search API Key(可选,启用 Tavily 网页搜索提供者)。
|
||||
# TAVILY_API_KEY=tvly-your_tavily_api_key
|
||||
|
||||
# ========== H2. MCP Server(可选,--profile full 时启动)==========
|
||||
# MCP Server 访问 WeKnora REST API 用的 API Key(在前端「设置 -> API Keys」生成)。
|
||||
# WEKNORA_API_KEY=
|
||||
# HTTP/SSE 传输必填:mcp-server 启动时若缺少此项会拒绝启动(客户端以 Authorization: Bearer 携带)。
|
||||
# MCP_SERVER_AUTH_TOKEN=
|
||||
# 调用 WeKnora REST 的读超时(秒,默认 300)。
|
||||
# WEKNORA_CHAT_TIMEOUT=300
|
||||
# 是否校验 WeKnora 后端 TLS 证书(默认 true;自签证书可设 false)。
|
||||
# WEKNORA_VERIFY_SSL=true
|
||||
# 允许上传的目录白名单(逗号分隔;留空禁用文件上传工具)。
|
||||
# MCP_ALLOWED_UPLOAD_DIRS=
|
||||
|
||||
|
||||
# #####################################################################
|
||||
# I. 可观测性(Langfuse,可选)
|
||||
# #####################################################################
|
||||
|
||||
# ========== I1. Langfuse 接入(追踪 chat/embedding/rerank/VLM/ASR 模型调用,统计 token 消耗)==========
|
||||
# 详细说明:docs/Langfuse集成.md
|
||||
#
|
||||
# 方案 A:接入 Langfuse Cloud(最简单)
|
||||
# 1) 登录 https://cloud.langfuse.com 生成 API Key
|
||||
# 2) 填入下方 PUBLIC_KEY / SECRET_KEY
|
||||
# 3) docker compose up -d app
|
||||
#
|
||||
# 方案 B:自建 Langfuse(局域网/内网环境)
|
||||
# 1) 启动自建栈:docker compose --profile langfuse up -d
|
||||
# 首次启动后 ClickHouse 迁移约 1-2 分钟,耐心等待 langfuse-web 健康
|
||||
# 2) 浏览器打开 http://localhost:3000 注册管理员账号并生成 API Key
|
||||
# 3) 取消注释下方 LANGFUSE_HOST=http://langfuse-web:3000,填入刚生成的 PUBLIC_KEY / SECRET_KEY
|
||||
# 4) docker compose up -d app
|
||||
#
|
||||
# 只要同时设置 PUBLIC_KEY + SECRET_KEY 就会自动启用,无需显式开关。
|
||||
LANGFUSE_PUBLIC_KEY=pk-lf-xxxxxxxx
|
||||
LANGFUSE_SECRET_KEY=sk-lf-xxxxxxxx
|
||||
LANGFUSE_HOST=http://langfuse-web:3000
|
||||
# 自建模式下改成:LANGFUSE_HOST=http://langfuse-web:3000
|
||||
# 可选:显式开关(true/false,默认根据 key 自动判断)。
|
||||
# LANGFUSE_ENABLED=true
|
||||
# 可选:版本 / 环境标签,便于在 Langfuse UI 过滤。
|
||||
# LANGFUSE_RELEASE=v0.4.2
|
||||
# LANGFUSE_ENVIRONMENT=production
|
||||
# 可选:批量上报与采样策略(生产高流量建议调大 FLUSH_AT、降低 SAMPLE_RATE)。
|
||||
# LANGFUSE_FLUSH_AT=15
|
||||
# LANGFUSE_FLUSH_INTERVAL=3s
|
||||
# LANGFUSE_QUEUE_SIZE=2048
|
||||
# LANGFUSE_REQUEST_TIMEOUT=10s
|
||||
# LANGFUSE_SAMPLE_RATE=1.0
|
||||
# LANGFUSE_DEBUG=false
|
||||
|
||||
# ========== I2. Langfuse 自建栈配置(仅在使用 --profile langfuse 时需要)==========
|
||||
# 设计说明:为最小化资源占用,Langfuse 自建栈会**复用** WeKnora 已有的
|
||||
# - postgres:创建独立的 "langfuse" 数据库(由 langfuse-db-init 容器一次性创建)
|
||||
# - redis :使用独立的 Redis DB 号(默认 1,WeKnora 用 0)
|
||||
# 真正新增的只有 3 个常驻容器:langfuse-web、langfuse-worker、langfuse-clickhouse
|
||||
# + 1 个专用 S3:langfuse-minio
|
||||
# + 1 个一次性 init:langfuse-db-init
|
||||
# Langfuse Web UI 对外端口。
|
||||
# LANGFUSE_WEB_PORT=3000
|
||||
# Langfuse 专用 MinIO 端口(避免和 WeKnora 主 MinIO 9000/9001 冲突)。
|
||||
# LANGFUSE_MINIO_S3_PORT=9100
|
||||
# LANGFUSE_MINIO_CONSOLE_PORT=9101
|
||||
# 媒体上传外部 endpoint(浏览器直连 Langfuse MinIO),默认指向宿主机映射的 9100 端口。
|
||||
# LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT=http://localhost:9100
|
||||
# 在 WeKnora-postgres 中创建的 Langfuse 库名。
|
||||
# LANGFUSE_DB_NAME=langfuse
|
||||
# 在 WeKnora-redis 中使用的 DB 号(1~15,不要和 WeKnora 的 0 冲突)。
|
||||
# LANGFUSE_REDIS_DB=1
|
||||
# ClickHouse / Langfuse 专用 MinIO 凭证(生产请务必修改)。
|
||||
# LANGFUSE_CLICKHOUSE_USER=clickhouse
|
||||
# LANGFUSE_CLICKHOUSE_PASSWORD=clickhouse
|
||||
# LANGFUSE_MINIO_USER=langfuseminio
|
||||
# LANGFUSE_MINIO_PASSWORD=langfuseminiosecret
|
||||
# Langfuse 核心安全字段,生产必须重新生成:
|
||||
# LANGFUSE_SALT=$(openssl rand -base64 32)
|
||||
# LANGFUSE_ENCRYPTION_KEY=$(openssl rand -hex 32)
|
||||
# LANGFUSE_NEXTAUTH_SECRET=$(openssl rand -base64 32)
|
||||
# LANGFUSE_SALT=
|
||||
# LANGFUSE_ENCRYPTION_KEY=
|
||||
# LANGFUSE_NEXTAUTH_SECRET=
|
||||
# LANGFUSE_NEXTAUTH_URL=http://localhost:3000
|
||||
# LANGFUSE_TELEMETRY_ENABLED=false
|
||||
# 可选:自动化首次启动(填写后直接注入管理员+项目,跳过 UI 注册)。
|
||||
# LANGFUSE_INIT_ORG_ID=WeKnora
|
||||
# LANGFUSE_INIT_ORG_NAME=WeKnora
|
||||
# LANGFUSE_INIT_PROJECT_ID=WeKnora
|
||||
# LANGFUSE_INIT_PROJECT_NAME=WeKnora
|
||||
# LANGFUSE_INIT_PROJECT_PUBLIC_KEY=pk-lf-weknora-init
|
||||
# LANGFUSE_INIT_PROJECT_SECRET_KEY=sk-lf-weknora-init
|
||||
# LANGFUSE_INIT_USER_EMAIL=admin@example.com
|
||||
# LANGFUSE_INIT_USER_NAME=Admin
|
||||
# LANGFUSE_INIT_USER_PASSWORD=change-me-please
|
||||
|
||||
|
||||
# #####################################################################
|
||||
# J. 安全与部署调优
|
||||
# #####################################################################
|
||||
|
||||
# ========== J1. SSRF 防护 ==========
|
||||
# SSRF 校验白名单(可选)。逗号分隔;每条可为:精确域名(api.internal)、通配域名(*.example.com)、
|
||||
# IPv4(203.0.113.5)、IPv6(2001:db8::1,不带方括号)或 CIDR(10.0.0.0/8, 2001:db8::/32)。
|
||||
# 列入者会在 URL 校验等地方绕过常规 SSRF 规则,生产环境请谨慎配置。
|
||||
# SSRF_WHITELIST=internal.service,*.corp.example,172.16.0.0/12,2001:db8::1,fd00::/8
|
||||
#
|
||||
# ===== 向量库地址的 SSRF 校验 =====
|
||||
# 通过 API 创建/测试向量库连接时,地址会经过 SSRF 校验。docker-compose 内置服务
|
||||
# (qdrant, milvus, weaviate, doris-fe, doris-be, searxng, minio)已通过 SSRF_WHITELIST_EXTRA 默认放行。
|
||||
# 如使用外部私网地址或被拦截端口(5432, 9200 等),请将主机加入 SSRF_WHITELIST_EXTRA
|
||||
# (注意:自定义该变量会覆盖 compose 默认值,需重新包含内置服务名),例如:
|
||||
# SSRF_WHITELIST_EXTRA=searxng,qdrant,milvus,weaviate,doris-fe,doris-be,minio,my-opensearch.internal
|
||||
#
|
||||
# Doris Stream Load:FE 会将请求 307 重定向到 BE;docker compose 默认放行
|
||||
# doris-fe 与 doris-be(见 SSRF_WHITELIST_EXTRA)。
|
||||
#
|
||||
# 本地开发(go run + docker-compose.dev.yml --profile opensearch):后端跑在宿主机,
|
||||
# OpenSearch 经 published 端口 http://localhost:9200 访问;通过 API 创建该向量库前,
|
||||
# 需把 localhost 加入 SSRF_WHITELIST(9200 是被拦截端口,localhost 是受限主机名):
|
||||
# SSRF_WHITELIST=localhost
|
||||
# 保留原始 URL 的图片域名白名单(可选,逗号分隔)。配置后这些域名的图片仍会被下载分析
|
||||
# (OCR/字幕),但 markdown 中保留原始 URL,不替换为对象存储的 provider:// URL。
|
||||
# IMAGE_HOST_KEEP_URL=mineru.internal.example.com
|
||||
|
||||
# ========== J2. 代理与并发 ==========
|
||||
# gin 信任的代理 CIDR(逗号分隔)。默认信任 loopback + 私网段(容器网络内 nginx 已覆盖)。
|
||||
# 显式设为空字符串则禁用代理信任(ClientIP 返回直连对端)。生产部署在多层代理后需按实际填写。
|
||||
# WEKNORA_TRUSTED_PROXIES=
|
||||
# ants 协程池大小(Embedding 并发,出现 429 时调小)。
|
||||
CONCURRENCY_POOL_SIZE=5
|
||||
@@ -0,0 +1,130 @@
|
||||
name: 🐛 Bug Report
|
||||
description: Report a bug or issue
|
||||
title: "[Bug]: "
|
||||
labels: ["bug", "needs-triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for reporting a bug! Please fill in the information below to help us locate and fix the issue quickly.
|
||||
|
||||
- type: dropdown
|
||||
id: component
|
||||
attributes:
|
||||
label: Affected Component
|
||||
description: Select the component where the issue occurs
|
||||
options:
|
||||
- Frontend UI
|
||||
- Backend Service & API
|
||||
- Document Reader Service
|
||||
- Vector Database
|
||||
- Model Service
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Bug Description
|
||||
description: A clear and concise description of the issue
|
||||
placeholder: "Describe what the bug looks like..."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: Minimal steps that reliably reproduce the problem
|
||||
placeholder: |
|
||||
1. Go to '...'
|
||||
2. Click on '...'
|
||||
3. Send request '...'
|
||||
4. See error
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: Describe what you expected to happen
|
||||
placeholder: "What should have happened..."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Actual Behavior
|
||||
description: Describe what actually happened (error messages, screenshots, etc.)
|
||||
placeholder: "What actually happened..."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: WeKnora Version
|
||||
description: |
|
||||
Please provide the concrete version number you are running, **not** `latest`, `main` or `master`.
|
||||
You can find it on the **Settings → System Info** page (both "App Version" and "UI Version" — if they differ, please report both).
|
||||
If you built from source, include the commit SHA instead.
|
||||
placeholder: "e.g., App Version: v0.6.0, UI Version: v0.6.0 (or commit abc1234)"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: deployment
|
||||
attributes:
|
||||
label: Deployment Method
|
||||
description: How are you running WeKnora?
|
||||
options:
|
||||
- Docker Compose
|
||||
- Build from source
|
||||
- Lite (single binary)
|
||||
- Desktop app
|
||||
- Kubernetes (Helm)
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: os
|
||||
attributes:
|
||||
label: Operating System
|
||||
description: The operating system you are using
|
||||
placeholder: "e.g., macOS 13.0, Ubuntu 20.04, Windows 11"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## 📋 Log Collection Guide
|
||||
|
||||
Please collect logs and paste them into the field below. Pick the command matching your deployment:
|
||||
|
||||
- Docker Compose:
|
||||
```bash
|
||||
docker compose logs -f --tail=1000 app docreader postgres
|
||||
```
|
||||
- Lite / Desktop / build from source: include the console output or log files (e.g., `logs/*.log`).
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Relevant Logs
|
||||
description: Paste relevant logs or error messages here
|
||||
placeholder: "Paste logs or error messages..."
|
||||
render: shell
|
||||
|
||||
- type: checkboxes
|
||||
id: terms
|
||||
attributes:
|
||||
label: Confirmation
|
||||
description: Please confirm the following
|
||||
options:
|
||||
- label: I have searched existing issues and confirmed this is a new one
|
||||
required: true
|
||||
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: WeKnora Documentation
|
||||
url: https://github.com/Tencent/WeKnora/blob/main/docs/QA.md
|
||||
about: Check WeKnora FAQ
|
||||
- name: Discussions
|
||||
url: https://github.com/Tencent/WeKnora/discussions
|
||||
about: Ask questions or share ideas in Discussions
|
||||
@@ -0,0 +1,88 @@
|
||||
name: ✨ Feature Request
|
||||
description: Suggest a new feature or improvement
|
||||
title: "[Feature]: "
|
||||
labels: ["enhancement", "needs-triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for the feature request! Please describe your idea in detail so we can better understand your needs.
|
||||
|
||||
- type: dropdown
|
||||
id: component
|
||||
attributes:
|
||||
label: Affected Component
|
||||
description: Select the component related to this request
|
||||
options:
|
||||
- Frontend UI
|
||||
- Backend Service & API
|
||||
- Document Reader Service
|
||||
- Vector Database
|
||||
- Model Service
|
||||
- Configuration Management
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem Description
|
||||
description: Describe the problem or limitation of the current feature
|
||||
placeholder: "What is wrong or missing today?"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: Proposed Solution
|
||||
description: Describe the feature or improvement you would like to see
|
||||
placeholder: "How would you like to solve it?"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives
|
||||
description: Describe alternative solutions you have considered
|
||||
placeholder: "Any alternatives you considered?"
|
||||
|
||||
- type: dropdown
|
||||
id: impact
|
||||
attributes:
|
||||
label: Impact
|
||||
description: Who is affected by the absence of this feature?
|
||||
options:
|
||||
- Just me / a personal preference
|
||||
- My team or a small group
|
||||
- Many users would benefit
|
||||
- Blocking adoption of WeKnora
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: usecase
|
||||
attributes:
|
||||
label: Use Case
|
||||
description: Describe the specific use case for this feature
|
||||
placeholder: "When would you use this feature?"
|
||||
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: Additional Information
|
||||
description: Any other context, screenshots, or links
|
||||
placeholder: "Add any other relevant information..."
|
||||
|
||||
- type: checkboxes
|
||||
id: terms
|
||||
attributes:
|
||||
label: Confirmation
|
||||
description: Please confirm the following
|
||||
options:
|
||||
- label: I have searched existing issues and confirmed this is a new request
|
||||
required: true
|
||||
- label: I understand this request may need discussion and evaluation
|
||||
required: true
|
||||
@@ -0,0 +1,103 @@
|
||||
name: ❓ Question
|
||||
description: Ask a question about WeKnora or seek help
|
||||
title: "[Question]: "
|
||||
labels: ["question", "needs-triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Welcome! Please provide enough information so we can help you effectively.
|
||||
|
||||
- type: dropdown
|
||||
id: category
|
||||
attributes:
|
||||
label: Question Category
|
||||
description: Select the category of your question
|
||||
options:
|
||||
- Installation & Deployment
|
||||
- Configuration
|
||||
- Usage
|
||||
- Performance
|
||||
- Integration
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: question
|
||||
attributes:
|
||||
label: Question Description
|
||||
description: Please describe your question in detail
|
||||
placeholder: "What problem did you encounter? What help do you need?"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Context
|
||||
description: Please provide relevant context
|
||||
placeholder: |
|
||||
- What are you trying to do?
|
||||
- What outcome do you expect?
|
||||
- What have you tried so far?
|
||||
|
||||
- type: input
|
||||
id: os
|
||||
attributes:
|
||||
label: Operating System
|
||||
description: The operating system you are using
|
||||
placeholder: "e.g., macOS 13.0, Ubuntu 20.04, Windows 11"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: environment
|
||||
attributes:
|
||||
label: Other Environment Info
|
||||
description: Please provide other relevant environment information
|
||||
placeholder: |
|
||||
- WeKnora App Version: [concrete version from Settings → System Info, e.g., v0.6.0 — NOT "latest"]
|
||||
- WeKnora UI Version: [concrete version from Settings → System Info, e.g., v0.6.0]
|
||||
- Deployment: [e.g., Docker, build from source]
|
||||
- Other relevant info...
|
||||
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## 📋 Log Collection Guide (if needed)
|
||||
|
||||
If the issue involves errors or requires debugging, please collect logs:
|
||||
|
||||
```bash
|
||||
docker compose logs -f --tail=1000 app docreader postgres
|
||||
```
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Relevant Logs
|
||||
description: Paste relevant logs or error messages here if available
|
||||
placeholder: "Paste logs or error messages..."
|
||||
render: shell
|
||||
|
||||
- type: textarea
|
||||
id: research
|
||||
attributes:
|
||||
label: Resources Already Checked
|
||||
description: List the docs, issues or other resources you've already checked
|
||||
placeholder: |
|
||||
- Docs checked: [e.g., README.md, API.md]
|
||||
- Issues checked: [e.g., #123, #456]
|
||||
- Other resources: [e.g., official docs, Stack Overflow]
|
||||
|
||||
- type: checkboxes
|
||||
id: terms
|
||||
attributes:
|
||||
label: Confirmation
|
||||
description: Please confirm the following
|
||||
options:
|
||||
- label: I have searched existing issues and documentation
|
||||
required: true
|
||||
- label: I have provided enough information to help understand the question
|
||||
required: true
|
||||
@@ -0,0 +1,144 @@
|
||||
# Dependabot configuration for the WeKnora repository.
|
||||
#
|
||||
# Policy:
|
||||
# * `/cli` is the only ecosystem that receives routine version updates
|
||||
# (monthly, grouped into one minor/patch PR and one major PR). It's
|
||||
# a standalone Go binary with a small, well-scoped dependency surface
|
||||
# where staying current is cheap.
|
||||
# * Every other ecosystem (server gomod, /client, frontend npm,
|
||||
# miniprogram npm, docreader pip, github-actions) is configured as
|
||||
# "security-only" — no scheduled version PRs, but CVE security
|
||||
# updates still open PRs automatically.
|
||||
#
|
||||
# Security-only pattern (mirrors fluent/fluentd-kubernetes-daemonset and
|
||||
# the dependabot docs for "Disabling Dependabot version updates"):
|
||||
#
|
||||
# open-pull-requests-limit: 0 # primary kill-switch for version PRs
|
||||
# ignore: # belt-and-suspenders: drop every
|
||||
# - dependency-name: "*" # non-security update at the source.
|
||||
# update-types: ["version-update"]
|
||||
# groups: # collapse simultaneous CVE PRs into
|
||||
# security-updates: # ONE PR per ecosystem so a noisy
|
||||
# applies-to: security-updates
|
||||
# patterns: ["*"]
|
||||
#
|
||||
# Why both `open-pull-requests-limit: 0` AND `ignore`:
|
||||
# The official docs treat `limit: 0` as the canonical way to disable
|
||||
# version updates, but dependabot-core#7353 shows it has historically
|
||||
# been ignored for some ecosystems. Adding the wildcard `ignore` rule
|
||||
# guarantees zero scheduled version PRs even if the limit gets
|
||||
# regressed upstream. Security updates are unaffected by either knob.
|
||||
#
|
||||
# Prerequisite: "Dependabot security updates" must be enabled in
|
||||
# Settings → Code security and analysis. Without it, no CVE PRs are
|
||||
# opened regardless of this file.
|
||||
#
|
||||
# Conventions applied to `/cli`:
|
||||
# * Monthly schedule (one batch / month).
|
||||
# * Two groups so per-dep PRs never flood:
|
||||
# - `cli-deps` bundles minor + patch → ONE PR / month.
|
||||
# - `cli-deps-major` bundles semver-major bumps the same way so
|
||||
# breaking changes surface as a single
|
||||
# review-required PR rather than silently
|
||||
# accumulating.
|
||||
# * `open-pull-requests-limit: 3` as a safety net against stalled queues.
|
||||
# * `commit-message.prefix: "chore(deps)"` to match the repo's
|
||||
# Conventional Commits convention.
|
||||
version: 2
|
||||
updates:
|
||||
# ─── Go modules ───
|
||||
- package-ecosystem: gomod
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: monthly
|
||||
open-pull-requests-limit: 0
|
||||
ignore:
|
||||
- dependency-name: "*"
|
||||
update-types: ["version-update:semver-major", "version-update:semver-minor", "version-update:semver-patch"]
|
||||
groups:
|
||||
server-security:
|
||||
applies-to: security-updates
|
||||
patterns: ["*"]
|
||||
|
||||
- package-ecosystem: gomod
|
||||
directory: "/cli"
|
||||
schedule:
|
||||
interval: monthly
|
||||
open-pull-requests-limit: 3
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
groups:
|
||||
cli-deps:
|
||||
patterns: ["*"]
|
||||
update-types: ["minor", "patch"]
|
||||
cli-deps-major:
|
||||
patterns: ["*"]
|
||||
update-types: ["major"]
|
||||
|
||||
- package-ecosystem: gomod
|
||||
directory: "/client"
|
||||
schedule:
|
||||
interval: monthly
|
||||
open-pull-requests-limit: 0
|
||||
ignore:
|
||||
- dependency-name: "*"
|
||||
update-types: ["version-update:semver-major", "version-update:semver-minor", "version-update:semver-patch"]
|
||||
groups:
|
||||
client-security:
|
||||
applies-to: security-updates
|
||||
patterns: ["*"]
|
||||
|
||||
# ─── npm ───
|
||||
- package-ecosystem: npm
|
||||
directory: "/frontend"
|
||||
schedule:
|
||||
interval: monthly
|
||||
open-pull-requests-limit: 0
|
||||
ignore:
|
||||
- dependency-name: "*"
|
||||
update-types: ["version-update:semver-major", "version-update:semver-minor", "version-update:semver-patch"]
|
||||
groups:
|
||||
frontend-security:
|
||||
applies-to: security-updates
|
||||
patterns: ["*"]
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: "/miniprogram"
|
||||
schedule:
|
||||
interval: monthly
|
||||
open-pull-requests-limit: 0
|
||||
ignore:
|
||||
- dependency-name: "*"
|
||||
update-types: ["version-update:semver-major", "version-update:semver-minor", "version-update:semver-patch"]
|
||||
groups:
|
||||
miniprogram-security:
|
||||
applies-to: security-updates
|
||||
patterns: ["*"]
|
||||
|
||||
# ─── Python ───
|
||||
- package-ecosystem: pip
|
||||
directory: "/docreader"
|
||||
schedule:
|
||||
interval: monthly
|
||||
open-pull-requests-limit: 0
|
||||
ignore:
|
||||
- dependency-name: "*"
|
||||
update-types: ["version-update:semver-major", "version-update:semver-minor", "version-update:semver-patch"]
|
||||
groups:
|
||||
docreader-security:
|
||||
applies-to: security-updates
|
||||
patterns: ["*"]
|
||||
|
||||
# ─── GitHub Actions ───
|
||||
- package-ecosystem: github-actions
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: monthly
|
||||
open-pull-requests-limit: 0
|
||||
ignore:
|
||||
- dependency-name: "*"
|
||||
update-types: ["version-update:semver-major", "version-update:semver-minor", "version-update:semver-patch"]
|
||||
groups:
|
||||
gha-security:
|
||||
applies-to: security-updates
|
||||
patterns: ["*"]
|
||||
@@ -0,0 +1,42 @@
|
||||
<!-- Title should follow Conventional Commits, e.g. `feat: ...`, `fix: ...`, `docs: ...` -->
|
||||
|
||||
## Description
|
||||
<!-- Briefly describe the purpose and changes of this PR -->
|
||||
|
||||
## Type of Change
|
||||
<!-- Check applicable items -->
|
||||
- [ ] 🐛 Bug fix
|
||||
- [ ] ✨ New feature
|
||||
- [ ] 💥 Breaking change
|
||||
- [ ] 📚 Documentation update
|
||||
- [ ] 🎨 Refactor
|
||||
- [ ] ⚡ Performance improvement
|
||||
- [ ] 🧪 Test
|
||||
- [ ] 🔧 Configuration / Build / CI
|
||||
|
||||
## Related Issue
|
||||
<!-- If this PR resolves an issue, use "Fixes #123" or "Closes #123" -->
|
||||
Fixes #
|
||||
|
||||
## Testing
|
||||
<!-- Describe how these changes were tested. Include reproduction or verification steps. -->
|
||||
<!--
|
||||
For a focused change, run checks scoped to the files/packages you changed.
|
||||
See the Contributing section in README.md for examples. If a full-repository
|
||||
check is blocked by unrelated baseline or environment failures, record the
|
||||
exact command and failure here.
|
||||
-->
|
||||
|
||||
## Checklist
|
||||
- [ ] `git diff --check origin/main...HEAD` passes
|
||||
- [ ] Changed source files are formatted
|
||||
- [ ] Targeted tests for the changed packages/components pass
|
||||
- [ ] Diff-scoped lint passes where applicable (for Go: `golangci-lint run --new-from-rev=origin/main ./...`)
|
||||
- [ ] Full-repository checks were run, or any unrelated/environment-dependent failures are documented above
|
||||
- [ ] Self-reviewed the code
|
||||
- [ ] Added/updated tests covering the change
|
||||
- [ ] Updated related documentation (README, `docs/`, Swagger annotations, etc.)
|
||||
- [ ] Breaking changes are clearly called out in the description above
|
||||
|
||||
## Screenshots / Recordings
|
||||
<!-- Required for user-visible UI changes -->
|
||||
@@ -0,0 +1,81 @@
|
||||
name: Anydoc
|
||||
|
||||
# Compiles the vendored Rust archive, audits it, and exercises the `anydoc`
|
||||
# build tag. That is minutes of rustc + cargo-audit, so this workflow stays
|
||||
# off PRs that only touch migrations, frontend, CLI, or unrelated Go packages.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "third_party/anydoc-go/**"
|
||||
- "scripts/build-anydoc-lib.sh"
|
||||
- "internal/infrastructure/docparser/**"
|
||||
- ".github/workflows/anydoc.yml"
|
||||
pull_request:
|
||||
paths:
|
||||
- "third_party/anydoc-go/**"
|
||||
- "scripts/build-anydoc-lib.sh"
|
||||
- "internal/infrastructure/docparser/**"
|
||||
- ".github/workflows/anydoc.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
GO_VERSION: "1.26"
|
||||
GOPROXY: https://proxy.golang.org,direct
|
||||
|
||||
jobs:
|
||||
anydoc:
|
||||
name: anydoc parser engine
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache: true
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Cache cargo-audit
|
||||
id: cargo-audit-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cargo/bin/cargo-audit
|
||||
key: cargo-audit-${{ runner.os }}
|
||||
|
||||
- name: Install cargo-audit
|
||||
if: steps.cargo-audit-cache.outputs.cache-hit != 'true'
|
||||
run: cargo install cargo-audit --locked
|
||||
|
||||
# The archive is a build artifact, not a committed blob, so CI is what
|
||||
# keeps the vendored bindings in third_party/anydoc-go compiling.
|
||||
# This also materializes patched-anydoc/ (gitignored); cargo audit below
|
||||
# cannot load the crates-io patch until that tree exists.
|
||||
- name: Build the anydoc static archive
|
||||
run: ./scripts/build-anydoc-lib.sh
|
||||
|
||||
# The vendored bindings pull anydoc and its PDF stack from crates.io, and
|
||||
# WeKnora parses untrusted uploads in-process. A Rust advisory there (as
|
||||
# in RUSTSEC-2026-0187, an uncatchable abort on a hostile PDF) has to
|
||||
# break the build, not wait for someone to notice. Vulnerabilities fail
|
||||
# the job; unmaintained-crate warnings only report, since a transitive
|
||||
# crate going unmaintained is not something this repo can act on.
|
||||
- name: Audit the vendored dependency tree
|
||||
working-directory: third_party/anydoc-go
|
||||
run: cargo audit
|
||||
|
||||
- name: Vet, test, and build with the engine linked in
|
||||
run: |
|
||||
go vet -tags anydoc ./internal/infrastructure/docparser/...
|
||||
go test -tags anydoc -count=1 ./internal/infrastructure/docparser/...
|
||||
go build -tags anydoc ./cmd/server
|
||||
@@ -0,0 +1,107 @@
|
||||
name: App
|
||||
|
||||
# Anydoc (Rust archive + `anydoc` build tag) lives in anydoc.yml so a
|
||||
# migrations-only or unrelated Go PR does not compile the parser engine.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**/*.go"
|
||||
- "!cli/**"
|
||||
- "!client/**"
|
||||
- "!docreader/**"
|
||||
- "!third_party/anydoc-go/**"
|
||||
- "go.mod"
|
||||
- "go.sum"
|
||||
- "Makefile"
|
||||
- "config/**"
|
||||
- "migrations/**"
|
||||
- "skills/preloaded/**"
|
||||
- "testdata/**"
|
||||
- "docker/Dockerfile.app"
|
||||
- ".github/workflows/app.yml"
|
||||
pull_request:
|
||||
paths:
|
||||
- "**/*.go"
|
||||
- "!cli/**"
|
||||
- "!client/**"
|
||||
- "!docreader/**"
|
||||
- "!third_party/anydoc-go/**"
|
||||
- "go.mod"
|
||||
- "go.sum"
|
||||
- "Makefile"
|
||||
- "config/**"
|
||||
- "migrations/**"
|
||||
- "skills/preloaded/**"
|
||||
- "testdata/**"
|
||||
- "docker/Dockerfile.app"
|
||||
- ".github/workflows/app.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
GO_VERSION: "1.26"
|
||||
GOPROXY: https://proxy.golang.org,direct
|
||||
# Keep tests independent of a developer machine's custom log template.
|
||||
LOG_FORMAT: ""
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
name: Format, vet, test, and build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache: true
|
||||
# v6 defaults to go.mod for the cache key (more stable than go.sum).
|
||||
|
||||
- name: Download modules
|
||||
run: go mod download
|
||||
|
||||
- name: Check formatting
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
# Compare base..head, not the PR merge commit, so files changed only
|
||||
# on main after the branch point are not attributed to this PR.
|
||||
diff_range="${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}"
|
||||
else
|
||||
base="$BASE_SHA"
|
||||
if [ -z "$base" ] || [[ "$base" =~ ^0+$ ]]; then
|
||||
base=$(git rev-parse HEAD^)
|
||||
fi
|
||||
diff_range="$base..$GITHUB_SHA"
|
||||
fi
|
||||
unformatted=$(
|
||||
git diff --name-only --diff-filter=ACMR -z "$diff_range" -- '*.go' ':!cli/**' |
|
||||
xargs -0 -r gofmt -l
|
||||
)
|
||||
if [ -n "$unformatted" ]; then
|
||||
echo "The following Go files are not gofmt-formatted:"
|
||||
echo "$unformatted"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Vet and test
|
||||
shell: bash
|
||||
run: |
|
||||
mapfile -t pkgs < <(go list ./... | grep -v '/docreader/')
|
||||
go vet "${pkgs[@]}"
|
||||
go test "${pkgs[@]}"
|
||||
|
||||
- name: Build server
|
||||
run: go build ./cmd/server
|
||||
@@ -0,0 +1,66 @@
|
||||
name: CLI E2E
|
||||
|
||||
# Runs the cli/acceptance/e2e/ suite against a real WeKnora server.
|
||||
# Cost-gated so it doesn't fire on every PR:
|
||||
# - workflow_dispatch: maintainer can trigger manually
|
||||
# - pull_request labeled "acceptance-e2e": adding the label kicks a run
|
||||
#
|
||||
# Required repo secrets (only set on Tencent/WeKnora; cross-fork PRs have no
|
||||
# access and the job exits early via the secrets-present guard):
|
||||
# WEKNORA_E2E_HOST - https URL of the test server
|
||||
# WEKNORA_E2E_TOKEN - bearer token / API key for that host
|
||||
#
|
||||
# Mirrors gh acceptance/ trigger pattern: env-gated, opt-in, single matrix.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
types: [labeled, synchronize]
|
||||
paths:
|
||||
- 'cli/**'
|
||||
- '.github/workflows/cli-e2e.yml'
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: cli
|
||||
shell: bash
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
name: rag full loop
|
||||
# Skip auto-trigger on label-add unless the label is exactly "acceptance-e2e".
|
||||
# Manual workflow_dispatch always runs.
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'acceptance-e2e'))
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: '1.26'
|
||||
cache-dependency-path: cli/go.sum
|
||||
|
||||
- name: secrets present
|
||||
id: secrets
|
||||
env:
|
||||
E2E_HOST: ${{ secrets.WEKNORA_E2E_HOST }}
|
||||
E2E_TOKEN: ${{ secrets.WEKNORA_E2E_TOKEN }}
|
||||
run: |
|
||||
if [ -z "$E2E_HOST" ] || [ -z "$E2E_TOKEN" ]; then
|
||||
echo "::warning::WEKNORA_E2E_HOST or WEKNORA_E2E_TOKEN not set; skipping e2e."
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: go test -tags=acceptance_e2e
|
||||
if: steps.secrets.outputs.skip == 'false'
|
||||
env:
|
||||
WEKNORA_E2E_HOST: ${{ secrets.WEKNORA_E2E_HOST }}
|
||||
WEKNORA_E2E_TOKEN: ${{ secrets.WEKNORA_E2E_TOKEN }}
|
||||
run: go test -tags=acceptance_e2e -v -timeout=8m ./acceptance/e2e/...
|
||||
@@ -0,0 +1,58 @@
|
||||
name: CLI
|
||||
|
||||
# Builds and tests the cli/ Go module on every push / PR that touches it.
|
||||
# Foundation PR (PR-1) gates: 3-platform `go build` + `go test ./...` green.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'cli/**'
|
||||
- '.github/workflows/cli.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'cli/**'
|
||||
- '.github/workflows/cli.yml'
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: cli
|
||||
# Windows default is PowerShell, which tokenizes `go test -coverprofile=coverage.out ./...`
|
||||
# in a way that detaches `.out` as a separate positional package arg.
|
||||
# Force bash everywhere so the run lines parse identically across platforms
|
||||
# (Git Bash is preinstalled on windows-latest runners).
|
||||
shell: bash
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: test (${{ matrix.os }} / go ${{ matrix.go }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
go: ['1.26']
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: ${{ matrix.go }}
|
||||
cache-dependency-path: cli/go.sum
|
||||
- name: go build
|
||||
run: go build ./...
|
||||
- name: go test
|
||||
run: go test -race -coverprofile=coverage.out ./...
|
||||
- name: go vet
|
||||
run: go vet ./...
|
||||
- name: Check agent-facing doc wire vocabulary
|
||||
if: runner.os == 'Linux'
|
||||
run: scripts/check-skill-wire-vocab.sh
|
||||
- name: Check for committed credentials in docs
|
||||
if: runner.os == 'Linux'
|
||||
run: scripts/check-secret-tokens.sh
|
||||
- name: coverage report
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: go tool cover -func=coverage.out
|
||||
@@ -0,0 +1,293 @@
|
||||
name: Build and Push Docker Image
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
NODE_VERSION: "24"
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
jobs:
|
||||
build-ui:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Prepare version info
|
||||
id: version
|
||||
run: |
|
||||
eval "$(./scripts/get_version.sh env)"
|
||||
echo "commit_id=$COMMIT_ID" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Build frontend
|
||||
env:
|
||||
VITE_IS_DOCKER: true
|
||||
VITE_FRONTEND_COMMIT: ${{ steps.version.outputs.commit_id }}
|
||||
run: ./scripts/build_frontend_dist.sh
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ${{ secrets.DOCKERHUB_USERNAME }}/weknora-ui
|
||||
|
||||
- name: Build ui Image
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
file: frontend/Dockerfile
|
||||
context: ./frontend
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
cache-from: type=gha,scope=build-ui
|
||||
cache-to: type=gha,scope=build-ui,mode=max
|
||||
|
||||
build-docreader:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Free Disk Space
|
||||
uses: jlumbroso/free-disk-space@main
|
||||
with:
|
||||
# this might remove tools that are actually needed,
|
||||
# if set to "true" but frees about 6 GB
|
||||
tool-cache: false
|
||||
|
||||
# all of these default to true, but feel free to set to
|
||||
# "false" if necessary for your workflow
|
||||
android: true
|
||||
dotnet: true
|
||||
haskell: true
|
||||
large-packages: true
|
||||
docker-images: true
|
||||
swap-storage: true
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ${{ secrets.DOCKERHUB_USERNAME }}/weknora-docreader
|
||||
|
||||
- name: Build docreader Image
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
file: docker/Dockerfile.docreader
|
||||
context: .
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
cache-from: type=gha,scope=build-docreader
|
||||
cache-to: type=gha,scope=build-docreader,mode=max
|
||||
|
||||
build-sandbox:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ${{ secrets.DOCKERHUB_USERNAME }}/weknora-sandbox
|
||||
|
||||
- name: Build sandbox Image
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
file: docker/Dockerfile.sandbox
|
||||
target: sandbox
|
||||
context: .
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
cache-from: type=gha,scope=build-sandbox
|
||||
cache-to: type=gha,scope=build-sandbox,mode=max
|
||||
|
||||
# Cube builds templates straight from the image and gates the build on
|
||||
# GET :49983/health, so it needs the variant with envd baked in.
|
||||
- name: Docker meta (Cube variant)
|
||||
id: meta-cube
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ${{ secrets.DOCKERHUB_USERNAME }}/weknora-sandbox
|
||||
flavor: |
|
||||
suffix=-cube,onlatest=true
|
||||
|
||||
# amd64 only: cubesandbox-base, the source of the envd binary, publishes
|
||||
# no arm64 image.
|
||||
- name: Build sandbox Cube Image
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
push: true
|
||||
platforms: linux/amd64
|
||||
file: docker/Dockerfile.sandbox
|
||||
target: cube
|
||||
context: .
|
||||
labels: ${{ steps.meta-cube.outputs.labels }}
|
||||
tags: ${{ steps.meta-cube.outputs.tags }}
|
||||
cache-from: type=gha,scope=build-sandbox-cube
|
||||
cache-to: type=gha,scope=build-sandbox-cube,mode=max
|
||||
|
||||
build-app:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
platform: linux/amd64
|
||||
runs: ubuntu-latest
|
||||
- arch: arm64
|
||||
platform: linux/arm64
|
||||
runs: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runs }}
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ${{ secrets.DOCKERHUB_USERNAME }}/weknora-app
|
||||
|
||||
- name: Prepare version info
|
||||
id: version
|
||||
run: |
|
||||
# 使用统一的版本管理脚本
|
||||
eval "$(./scripts/get_version.sh env)"
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "commit_id=$COMMIT_ID" >> $GITHUB_OUTPUT
|
||||
echo "build_time=$BUILD_TIME" >> $GITHUB_OUTPUT
|
||||
echo "go_version=$GO_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
# 显示版本信息
|
||||
./scripts/get_version.sh info
|
||||
|
||||
- name: Build app Image
|
||||
id: build
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
push: true
|
||||
platforms: ${{ matrix.platform }}
|
||||
file: docker/Dockerfile.app
|
||||
context: .
|
||||
build-args: |
|
||||
${{ format('VERSION_ARG={0}', steps.version.outputs.version) }}
|
||||
${{ format('COMMIT_ID_ARG={0}', steps.version.outputs.commit_id) }}
|
||||
${{ format('BUILD_TIME_ARG={0}', steps.version.outputs.build_time) }}
|
||||
${{ format('GO_VERSION_ARG={0}', steps.version.outputs.go_version) }}
|
||||
WITH_ANYDOC=1
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
tags: ${{ secrets.DOCKERHUB_USERNAME }}/weknora-app
|
||||
cache-from: type=gha,scope=build-app-${{ env.PLATFORM_PAIR }}
|
||||
cache-to: type=gha,scope=build-app-${{ env.PLATFORM_PAIR }},mode=max
|
||||
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p ${{ runner.temp }}/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM_PAIR }}
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build-app
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ${{ secrets.DOCKERHUB_USERNAME }}/weknora-app
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: ${{ runner.temp }}/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ secrets.DOCKERHUB_USERNAME }}/weknora-app@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ secrets.DOCKERHUB_USERNAME }}/weknora-app:${{ steps.meta.outputs.version }}
|
||||
@@ -0,0 +1,107 @@
|
||||
name: DocReader
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "docreader/**"
|
||||
- "go.mod"
|
||||
- "go.sum"
|
||||
- "testdata/**"
|
||||
- "docker/Dockerfile.docreader"
|
||||
- "docker/Dockerfile.odl-hybrid"
|
||||
- ".github/workflows/docreader.yml"
|
||||
pull_request:
|
||||
paths:
|
||||
- "docreader/**"
|
||||
- "go.mod"
|
||||
- "go.sum"
|
||||
- "testdata/**"
|
||||
- "docker/Dockerfile.docreader"
|
||||
- "docker/Dockerfile.odl-hybrid"
|
||||
- ".github/workflows/docreader.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
PYTHON_VERSION: "3.10.18"
|
||||
GO_VERSION: "1.26"
|
||||
GOPROXY: https://proxy.golang.org,direct
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
name: Python tests and Go client tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v4
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: docreader/uv.lock
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: uv sync --project docreader --locked --no-dev --python ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Compile Python sources
|
||||
run: uv run --project docreader --python ${{ env.PYTHON_VERSION }} python -m compileall -q docreader
|
||||
|
||||
- name: Run Python tests
|
||||
run: uv run --project docreader --python ${{ env.PYTHON_VERSION }} python -m unittest discover -s docreader/tests -p "test_*.py" -v
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache: true
|
||||
|
||||
- name: Download Go modules
|
||||
run: go mod download
|
||||
|
||||
# TestReadURL drives the live WebParser (Playwright WebKit). Without the
|
||||
# browser binary, scrape used to return "Error parsing web page: …" as
|
||||
# markdown and the Go client treated that as success. After #2883 the
|
||||
# parser raises, so CI must install WebKit the same way the image does.
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: ${{ runner.os }}-playwright-webkit-${{ hashFiles('docreader/uv.lock') }}
|
||||
|
||||
- name: Install Playwright WebKit
|
||||
run: uv run --project docreader --python ${{ env.PYTHON_VERSION }} python -m playwright install --with-deps webkit
|
||||
|
||||
- name: Start DocReader gRPC server
|
||||
run: |
|
||||
uv run --project docreader --python ${{ env.PYTHON_VERSION }} python -m docreader.main &
|
||||
echo $! > /tmp/docreader.pid
|
||||
for i in $(seq 1 30); do
|
||||
if nc -z 127.0.0.1 50051 2>/dev/null; then
|
||||
echo "DocReader gRPC server is ready"
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "DocReader gRPC server failed to start"
|
||||
exit 1
|
||||
|
||||
- name: Run Go client tests
|
||||
run: go test ./docreader/client ./docreader/proto
|
||||
|
||||
- name: Stop DocReader gRPC server
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f /tmp/docreader.pid ]; then
|
||||
kill "$(cat /tmp/docreader.pid)" 2>/dev/null || true
|
||||
fi
|
||||
@@ -0,0 +1,268 @@
|
||||
name: Build & Publish (dsh-weknora)
|
||||
|
||||
# Build, test, and publish the DeepSeek Harness plugin (packages/dsh-weknora/) to npm.
|
||||
#
|
||||
# Three jobs:
|
||||
# test typecheck, unit tests, both halves of the contract, and a
|
||||
# packaging check
|
||||
# e2e install the package into a throwaway dsh profile and drive real
|
||||
# tool calls through the harness agent loop against a mock backend
|
||||
# publish on a `dsh-weknora-v*` tag only, publish to npm with provenance
|
||||
#
|
||||
# The e2e job pins the harness version so a plugin change is the only thing that
|
||||
# can turn it red. The weekly drift job runs the same check against dsh@latest and
|
||||
# is allowed to fail: it exists to notice a harness breaking change early, since
|
||||
# dsh is in developer preview.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags:
|
||||
- "dsh-weknora-v*"
|
||||
paths:
|
||||
- "packages/dsh-weknora/**"
|
||||
- ".github/workflows/dsh-plugin.yml"
|
||||
pull_request:
|
||||
paths:
|
||||
- "packages/dsh-weknora/**"
|
||||
- ".github/workflows/dsh-plugin.yml"
|
||||
schedule:
|
||||
# Monday 03:00 UTC: catch a harness release that breaks the plugin.
|
||||
- cron: "0 3 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
# The harness release this plugin is verified against. Bump it together with
|
||||
# the compatibility note in packages/dsh-weknora/README.md.
|
||||
DSH_PINNED_SPEC: "@deepseek-ai/dsh@0.1.0-rc.8"
|
||||
# The contract's Go half lives in this repository's module. app.yml runs it
|
||||
# only for a Go diff, so a change to the fixture or to the plugin alone would
|
||||
# otherwise skip the side that checks WeKnora still serves those calls.
|
||||
GO_VERSION: "1.26"
|
||||
# `dsh plugin add` shells out to pnpm to install into the profile, and the
|
||||
# runner image ships only npm and yarn. The profile it writes carries its
|
||||
# settings in pnpm-workspace.yaml, which pnpm 10 is the first to read.
|
||||
PNPM_VERSION: "10"
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Typecheck, test, pack
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
defaults:
|
||||
run:
|
||||
working-directory: packages/dsh-weknora
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
cache-dependency-path: packages/dsh-weknora/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci || npm install
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Unit and contract tests
|
||||
run: node --test "test/*.test.mjs"
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache: true
|
||||
|
||||
- name: Contract tests against WeKnora's Go types
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: go test ./packages/dsh-weknora/contract/...
|
||||
|
||||
- name: Pack and verify the published file list
|
||||
run: |
|
||||
set -e
|
||||
npm pack --dry-run --json > pack.json
|
||||
node -e "
|
||||
const files = require('./pack.json')[0].files.map(f => f.path);
|
||||
const required = ['dist/index.js', 'dist/index.d.ts', 'cordis.patch.yml', 'README.md', 'package.json'];
|
||||
const missing = required.filter(name => !files.includes(name));
|
||||
if (missing.length > 0) {
|
||||
console.error('missing from the tarball: ' + missing.join(', '));
|
||||
process.exit(1);
|
||||
}
|
||||
const leaked = files.filter(name => name.startsWith('test/') || name.startsWith('src/'));
|
||||
if (leaked.length > 0) {
|
||||
console.error('unexpected files in the tarball: ' + leaked.join(', '));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('tarball contents verified: ' + files.length + ' files');
|
||||
"
|
||||
rm pack.json
|
||||
|
||||
e2e:
|
||||
name: End-to-end inside dsh (pinned)
|
||||
runs-on: ubuntu-latest
|
||||
# Cold cache: npm install @deepseek-ai/dsh takes ~15 min; keep headroom.
|
||||
timeout-minutes: 25
|
||||
defaults:
|
||||
run:
|
||||
working-directory: packages/dsh-weknora
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
cache-dependency-path: packages/dsh-weknora/package-lock.json
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: ${{ env.PNPM_VERSION }}
|
||||
|
||||
# The e2e script installs @deepseek-ai/dsh on first run (~15 min uncached).
|
||||
# Reuse the install across workflow runs; bump DSH_PINNED_SPEC to refresh.
|
||||
- name: Cache pinned dsh install
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: .cache/dsh-e2e-install
|
||||
key: dsh-e2e-${{ env.DSH_PINNED_SPEC }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci || npm install
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Run the plugin inside a real dsh profile
|
||||
env:
|
||||
DSH_PACKAGE_SPEC: ${{ env.DSH_PINNED_SPEC }}
|
||||
DSH_INSTALL_DIR: ${{ github.workspace }}/.cache/dsh-e2e-install
|
||||
DSH_E2E_LOG: ${{ runner.temp }}/dsh-weknora-e2e.log
|
||||
run: node test/e2e/run-in-dsh.mjs
|
||||
|
||||
- name: Upload the transcript
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dsh-weknora-e2e-transcript
|
||||
path: ${{ runner.temp }}/dsh-weknora-e2e.log
|
||||
if-no-files-found: ignore
|
||||
|
||||
drift:
|
||||
name: End-to-end inside dsh (latest, advisory)
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
continue-on-error: true
|
||||
defaults:
|
||||
run:
|
||||
working-directory: packages/dsh-weknora
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
cache-dependency-path: packages/dsh-weknora/package-lock.json
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: ${{ env.PNPM_VERSION }}
|
||||
|
||||
- name: Week id for dsh@latest cache
|
||||
id: week
|
||||
run: echo "id=$(date -u +%G-W%V)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# dsh@latest moves; refresh the cache weekly.
|
||||
- name: Cache latest dsh install
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: .cache/dsh-e2e-install-latest
|
||||
key: dsh-e2e-latest-${{ steps.week.outputs.id }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci || npm install
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Run against the latest harness release
|
||||
env:
|
||||
DSH_PACKAGE_SPEC: "@deepseek-ai/dsh@latest"
|
||||
DSH_INSTALL_DIR: ${{ github.workspace }}/.cache/dsh-e2e-install-latest
|
||||
run: node test/e2e/run-in-dsh.mjs
|
||||
|
||||
publish:
|
||||
name: Publish to npm
|
||||
needs: [test, e2e]
|
||||
if: startsWith(github.ref, 'refs/tags/dsh-weknora-v')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
environment:
|
||||
name: npm-publish
|
||||
url: https://www.npmjs.com/package/@wxg-prc-cpg/dsh-weknora
|
||||
defaults:
|
||||
run:
|
||||
working-directory: packages/dsh-weknora
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
cache: npm
|
||||
cache-dependency-path: packages/dsh-weknora/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci || npm install
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Check the tag names the packaged version
|
||||
run: |
|
||||
set -e
|
||||
version="$(node -p "require('./package.json').version")"
|
||||
expected="dsh-weknora-v${version}"
|
||||
if [ "$GITHUB_REF_NAME" != "$expected" ]; then
|
||||
echo "::error::tag $GITHUB_REF_NAME does not name the packaged version $version (expected $expected)."
|
||||
echo "::error::Bump packages/dsh-weknora/package.json, or retag — publishing here would ship a version the tag does not name."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Check whether this version is already published
|
||||
id: check_npm
|
||||
run: |
|
||||
set -e
|
||||
name="$(node -p "require('./package.json').name")"
|
||||
version="$(node -p "require('./package.json').version")"
|
||||
echo "packaged: $name@$version"
|
||||
if npm view "$name@$version" version >/dev/null 2>&1; then
|
||||
echo "should_publish=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::warning::$name $version is already on npm; skipping publish. Bump package.json to release."
|
||||
else
|
||||
echo "should_publish=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Publish
|
||||
if: steps.check_npm.outputs.should_publish == 'true'
|
||||
run: npm publish --provenance --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
@@ -0,0 +1,55 @@
|
||||
name: Frontend
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "frontend/**"
|
||||
- "scripts/build_frontend_dist.sh"
|
||||
- ".github/workflows/frontend.yml"
|
||||
pull_request:
|
||||
paths:
|
||||
- "frontend/**"
|
||||
- "scripts/build_frontend_dist.sh"
|
||||
- ".github/workflows/frontend.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
NODE_VERSION: "24"
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
name: Test, type-check, and build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
defaults:
|
||||
run:
|
||||
working-directory: frontend
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --no-audit --no-fund
|
||||
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
|
||||
- name: Type-check
|
||||
run: npm run type-check
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
@@ -0,0 +1,59 @@
|
||||
name: Go Lint Cache
|
||||
|
||||
# Action caches written by a pull request run are scoped to that pull request,
|
||||
# so only runs on the default branch produce caches every pull request can
|
||||
# restore. Without this workflow each pull request would recompile the whole
|
||||
# package graph from scratch (~6 minutes for the root module, versus ~40s warm).
|
||||
#
|
||||
# This lives apart from go-lint.yml rather than as a conditional job inside it:
|
||||
# a job skipped by `if` still surfaces as a check on every pull request, and
|
||||
# GitHub renders its name with the matrix expression unexpanded.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**/*.go"
|
||||
- "**/go.mod"
|
||||
- "**/go.sum"
|
||||
- ".golangci.yml"
|
||||
- ".github/workflows/go-lint-cache.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
GO_VERSION: "1.26"
|
||||
GOLANGCI_LINT_VERSION: "v2.12.2"
|
||||
GOPROXY: https://proxy.golang.org,direct
|
||||
|
||||
jobs:
|
||||
warm-cache:
|
||||
name: warm lint cache (${{ matrix.module }})
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
module: [".", "cli", "client"]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache-dependency-path: ${{ matrix.module }}/go.sum
|
||||
|
||||
- uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
version: ${{ env.GOLANGCI_LINT_VERSION }}
|
||||
working-directory: ${{ matrix.module }}
|
||||
# This workflow exists for its cache side effect only; the backlog of
|
||||
# existing findings must not turn main red. The run still prints the
|
||||
# full report, which makes a manual dispatch a useful backlog view.
|
||||
args: --issues-exit-code=0
|
||||
@@ -0,0 +1,104 @@
|
||||
name: Go Lint
|
||||
|
||||
# The repository carries a large backlog of pre-existing golangci-lint findings,
|
||||
# so pull requests are only judged on the code they introduce (`only-new-issues`).
|
||||
# That filter needs a pull request diff, which is why this workflow has no other
|
||||
# trigger; the companion go-lint-cache.yml keeps the caches this job restores
|
||||
# from warm on main.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "**/*.go"
|
||||
- "**/go.mod"
|
||||
- "**/go.sum"
|
||||
- ".golangci.yml"
|
||||
- ".github/workflows/go-lint.yml"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
GO_VERSION: "1.26"
|
||||
GOLANGCI_LINT_VERSION: "v2.12.2"
|
||||
GOPROXY: https://proxy.golang.org,direct
|
||||
|
||||
jobs:
|
||||
# Path filters above start this workflow for any Go change; the matrix still
|
||||
# used to lint all three modules. Detect which modules the PR actually
|
||||
# touches so a root-only change (migrations test, handler, …) does not queue
|
||||
# cli/ and client/ runners. Changing .golangci.yml or this workflow still
|
||||
# lints every module, since they share the root config.
|
||||
changes:
|
||||
name: detect lint modules
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
modules: ${{ steps.detect.outputs.modules }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Detect changed modules
|
||||
id: detect
|
||||
shell: bash
|
||||
run: |
|
||||
diff_range="${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}"
|
||||
modules=()
|
||||
if git diff --name-only --diff-filter=ACMR "$diff_range" -- \
|
||||
.golangci.yml .github/workflows/go-lint.yml | grep -q .; then
|
||||
modules=("." "cli" "client")
|
||||
else
|
||||
if git diff --name-only --diff-filter=ACMR "$diff_range" -- \
|
||||
'*.go' ':!cli/**' ':!client/**' go.mod go.sum | grep -q .; then
|
||||
modules+=(".")
|
||||
fi
|
||||
if git diff --name-only --diff-filter=ACMR "$diff_range" -- 'cli/**' | grep -q .; then
|
||||
modules+=("cli")
|
||||
fi
|
||||
if git diff --name-only --diff-filter=ACMR "$diff_range" -- 'client/**' | grep -q .; then
|
||||
modules+=("client")
|
||||
fi
|
||||
fi
|
||||
json="["
|
||||
first=1
|
||||
for m in "${modules[@]}"; do
|
||||
if [ "$first" -eq 1 ]; then
|
||||
first=0
|
||||
else
|
||||
json+=","
|
||||
fi
|
||||
json+="\"${m}\""
|
||||
done
|
||||
json+="]"
|
||||
echo "modules=${json}" >> "$GITHUB_OUTPUT"
|
||||
echo "Lint modules: ${json}"
|
||||
|
||||
golangci:
|
||||
name: golangci-lint (${{ matrix.module }})
|
||||
needs: changes
|
||||
if: needs.changes.outputs.modules != '[]'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
module: ${{ fromJSON(needs.changes.outputs.modules) }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache-dependency-path: ${{ matrix.module }}/go.sum
|
||||
|
||||
- uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
version: ${{ env.GOLANGCI_LINT_VERSION }}
|
||||
working-directory: ${{ matrix.module }}
|
||||
only-new-issues: true
|
||||
@@ -0,0 +1,149 @@
|
||||
name: Build & Publish (mcp-server)
|
||||
|
||||
# Build, test, and publish the Python MCP server (mcp-server/) to PyPI.
|
||||
# Publishing uses PyPI Trusted Publishing (OIDC) — no API token required,
|
||||
# only the GitHub→PyPI OIDC trust configured on the repo.
|
||||
#
|
||||
# Release policy: publishing does NOT depend on tags. Bump the version in
|
||||
# mcp-server/pyproject.toml (and the synced version strings in setup.py,
|
||||
# __init__.py, weknora_mcp_server.py server_version, main.py --version),
|
||||
# merge to main, and the pipeline builds + publishes automatically.
|
||||
# A pre-publish check queries PyPI and skips upload if that version is
|
||||
# already published, so re-running on an unchanged version stays green.
|
||||
# Releases also still trigger on mcp-server-v* tags (back-compat).
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags:
|
||||
- "mcp-server-v*"
|
||||
paths:
|
||||
- "mcp-server/**"
|
||||
- ".github/workflows/mcp-server.yml"
|
||||
pull_request:
|
||||
paths:
|
||||
- "mcp-server/**"
|
||||
- ".github/workflows/mcp-server.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test (Python ${{ matrix.python-version }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
||||
defaults:
|
||||
run:
|
||||
working-directory: mcp-server
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v4
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
run: uv python install ${{ matrix.python-version }}
|
||||
|
||||
- name: Install dependencies (test extras)
|
||||
run: uv sync --python ${{ matrix.python-version }} --extra test
|
||||
|
||||
- name: Run tests
|
||||
# unittest avoids pytest importing __init__.py as a broken package root.
|
||||
# Manual diagnostics live in check_imports.py (not matched by test_*.py).
|
||||
run: uv run --python ${{ matrix.python-version }} python -m unittest discover -s . -p "test_*.py" -v
|
||||
|
||||
build:
|
||||
name: Build sdist + wheel
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
# Build on push to main, manual runs, and release tags — but never on PRs.
|
||||
if: github.event_name != 'pull_request'
|
||||
defaults:
|
||||
run:
|
||||
working-directory: mcp-server
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v4
|
||||
|
||||
- name: Set up Python
|
||||
run: uv python install 3.12
|
||||
|
||||
- name: Build package
|
||||
run: uv build
|
||||
|
||||
- name: Verify wheel contains upload_paths.py (regression guard)
|
||||
run: |
|
||||
set -e
|
||||
if ! python3 -c "import glob, zipfile, sys; whl=glob.glob('dist/*.whl')[0]; z=zipfile.ZipFile(whl); sys.exit(0 if 'upload_paths.py' in z.namelist() else 1)"; then
|
||||
echo "::error::upload_paths.py is missing from the wheel — check py-modules in pyproject.toml/setup.py"
|
||||
exit 1
|
||||
fi
|
||||
echo "upload_paths.py present in wheel ✓"
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dist
|
||||
path: mcp-server/dist/
|
||||
|
||||
publish:
|
||||
name: Publish to PyPI
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
# Publish on push to main, manual runs (main/tags only), and release tags — never on PRs.
|
||||
if: >-
|
||||
github.event_name != 'pull_request' &&
|
||||
(github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/mcp-server-v'))
|
||||
permissions:
|
||||
id-token: write
|
||||
environment:
|
||||
name: pypi
|
||||
url: https://pypi.org/project/tencent-weknora-mcp/
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Check if version is already published
|
||||
id: check_pypi
|
||||
working-directory: mcp-server
|
||||
# Read the version from pyproject.toml and query PyPI. If that exact
|
||||
# version already exists, skip publishing (so re-runs on an unchanged
|
||||
# version stay green instead of failing with "File already exists").
|
||||
run: |
|
||||
set -e
|
||||
version="$(python3 -c 'import tomllib; print(tomllib.load(open("pyproject.toml","rb"))["project"]["version"])')"
|
||||
echo "packaged version: $version"
|
||||
exists="$(python3 -c '
|
||||
import json, sys, urllib.request
|
||||
v = sys.argv[1]
|
||||
try:
|
||||
d = json.load(urllib.request.urlopen("https://pypi.org/pypi/tencent-weknora-mcp/json"))
|
||||
print("true" if v in d.get("releases", {}) else "false")
|
||||
except Exception:
|
||||
# If PyPI is unreachable or the project is brand-new (404), treat as unpublished.
|
||||
print("false")
|
||||
' "$version")"
|
||||
echo "already on PyPI: $exists"
|
||||
if [ "$exists" = "true" ]; then
|
||||
echo "should_publish=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::warning::tencent-weknora-mcp $version is already on PyPI; skipping publish. Bump pyproject.toml (and synced version strings) to release new changes."
|
||||
else
|
||||
echo "should_publish=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Download artifacts
|
||||
if: steps.check_pypi.outputs.should_publish == 'true'
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: dist
|
||||
path: dist/
|
||||
|
||||
- name: Publish to PyPI
|
||||
if: steps.check_pypi.outputs.should_publish == 'true'
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
@@ -0,0 +1,451 @@
|
||||
name: Release Lite Binaries
|
||||
|
||||
on:
|
||||
# push:
|
||||
# tags:
|
||||
# - "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Release tag (e.g. v0.2.0)"
|
||||
required: true
|
||||
|
||||
concurrency:
|
||||
group: release-lite-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
GO_VERSION: "1.24"
|
||||
NODE_VERSION: "22"
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
jobs:
|
||||
# ── 1. Build frontend once, share as artifact (for Web CLI) ──
|
||||
build-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Build frontend
|
||||
working-directory: frontend
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: frontend-dist
|
||||
path: frontend/dist/
|
||||
retention-days: 1
|
||||
|
||||
# ── 2a. Build Web CLI binary per platform (native CGO) ──
|
||||
build-binary:
|
||||
needs: build-frontend
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
goos: linux
|
||||
goarch: amd64
|
||||
- os: ubuntu-24.04-arm
|
||||
goos: linux
|
||||
goarch: arm64
|
||||
- os: macos-14
|
||||
goos: darwin
|
||||
goarch: amd64
|
||||
- os: macos-14
|
||||
goos: darwin
|
||||
goarch: arm64
|
||||
runs-on: ${{ matrix.os }}
|
||||
env:
|
||||
CGO_ENABLED: 1
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache: true
|
||||
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: frontend-dist
|
||||
path: web/
|
||||
|
||||
- name: Resolve version
|
||||
id: ver
|
||||
run: |
|
||||
if [ -n "${{ inputs.tag }}" ]; then
|
||||
VERSION="${{ inputs.tag }}"
|
||||
else
|
||||
VERSION="${GITHUB_REF#refs/tags/}"
|
||||
fi
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "Building ${VERSION} for ${{ matrix.goos }}/${{ matrix.goarch }}"
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
export EDITION=lite
|
||||
eval "$(./scripts/get_version.sh env)"
|
||||
LDFLAGS="-w -s $(./scripts/get_version.sh ldflags)"
|
||||
export CGO_CFLAGS="-Wno-deprecated-declarations"
|
||||
if [ "${{ matrix.goos }}" = "darwin" ]; then
|
||||
export CGO_LDFLAGS="-Wl,-no_warn_duplicate_libraries"
|
||||
fi
|
||||
go build -tags "sqlite_fts5" -ldflags="${LDFLAGS}" \
|
||||
-o WeKnora-lite ./cmd/server
|
||||
|
||||
- name: Package tarball
|
||||
run: |
|
||||
ARCHIVE="WeKnora-lite_${{ steps.ver.outputs.version }}_${{ matrix.goos }}_${{ matrix.goarch }}"
|
||||
mkdir -p "${ARCHIVE}/web"
|
||||
cp WeKnora-lite "${ARCHIVE}/"
|
||||
cp -r web/* "${ARCHIVE}/web/"
|
||||
cp .env.lite.example "${ARCHIVE}/"
|
||||
cp docs/LITE.md "${ARCHIVE}/README.md"
|
||||
if [ -d config ]; then
|
||||
cp -r config "${ARCHIVE}/config"
|
||||
fi
|
||||
if [ -d migrations/sqlite ]; then
|
||||
mkdir -p "${ARCHIVE}/migrations/sqlite"
|
||||
cp -r migrations/sqlite/* "${ARCHIVE}/migrations/sqlite/"
|
||||
fi
|
||||
if [ -f deploy/weknora-lite.service ]; then
|
||||
cp deploy/weknora-lite.service "${ARCHIVE}/"
|
||||
fi
|
||||
tar czf "${ARCHIVE}.tar.gz" "${ARCHIVE}"
|
||||
shasum -a 256 "${ARCHIVE}.tar.gz" > "${ARCHIVE}.tar.gz.sha256"
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
path: |
|
||||
WeKnora-lite_*.tar.gz
|
||||
WeKnora-lite_*.tar.gz.sha256
|
||||
retention-days: 3
|
||||
|
||||
# ── 2b. Build Desktop APP per platform (Wails) ──
|
||||
build-desktop-app:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: macos-14
|
||||
platform: darwin/universal
|
||||
arch_label: macOS_universal
|
||||
artifact_name: desktop-macos-universal
|
||||
- os: macos-14
|
||||
platform: darwin/amd64
|
||||
arch_label: macOS_amd64
|
||||
artifact_name: desktop-macos-amd64
|
||||
- os: macos-14
|
||||
platform: darwin/arm64
|
||||
arch_label: macOS_arm64
|
||||
artifact_name: desktop-macos-arm64
|
||||
- os: ubuntu-latest
|
||||
platform: linux/amd64
|
||||
arch_label: linux_amd64
|
||||
artifact_name: desktop-linux-amd64
|
||||
- os: windows-latest
|
||||
platform: windows/amd64
|
||||
arch_label: windows_amd64
|
||||
artifact_name: desktop-windows-amd64
|
||||
runs-on: ${{ matrix.os }}
|
||||
env:
|
||||
CGO_ENABLED: 1
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache: true
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install Wails CLI
|
||||
run: go install github.com/wailsapp/wails/v2/cmd/wails@latest
|
||||
|
||||
- name: Install Linux dependencies
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev
|
||||
|
||||
- name: Install NSIS (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
run: choco install nsis -y
|
||||
|
||||
- name: Resolve version
|
||||
id: ver
|
||||
shell: bash
|
||||
run: |
|
||||
if [ -n "${{ inputs.tag }}" ]; then
|
||||
VERSION="${{ inputs.tag }}"
|
||||
else
|
||||
VERSION="${GITHUB_REF#refs/tags/}"
|
||||
fi
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Pre-compile Go packages (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
shell: bash
|
||||
run: |
|
||||
export GOLANG_PROTOBUF_REGISTRATION_CONFLICT=warn
|
||||
export CGO_CFLAGS="-Wno-deprecated-declarations"
|
||||
cd cmd/desktop
|
||||
go build -p 1 -buildvcs=false -tags "desktop,sqlite_fts5,production" -o _prebuild_tmp.exe . || true
|
||||
rm -f _prebuild_tmp.exe
|
||||
|
||||
- name: Build desktop app
|
||||
shell: bash
|
||||
run: |
|
||||
export EDITION=lite
|
||||
export GOLANG_PROTOBUF_REGISTRATION_CONFLICT=warn
|
||||
eval "$(./scripts/get_version.sh env)"
|
||||
LDFLAGS="$(./scripts/get_version.sh ldflags) -X 'google.golang.org/protobuf/reflect/protoregistry.conflictPolicy=warn'"
|
||||
export CGO_CFLAGS="-Wno-deprecated-declarations"
|
||||
|
||||
BUILD_TAGS="sqlite_fts5"
|
||||
NSIS_FLAG=""
|
||||
|
||||
if [ "${{ runner.os }}" = "Linux" ]; then
|
||||
BUILD_TAGS="sqlite_fts5 webkit2_41"
|
||||
fi
|
||||
if [ "${{ runner.os }}" = "macOS" ]; then
|
||||
export CGO_LDFLAGS="-Wl,-no_warn_duplicate_libraries"
|
||||
fi
|
||||
if [ "${{ runner.os }}" = "Windows" ]; then
|
||||
NSIS_FLAG="-nsis"
|
||||
fi
|
||||
|
||||
cd cmd/desktop
|
||||
wails build -clean \
|
||||
-tags "${BUILD_TAGS}" \
|
||||
-platform "${{ matrix.platform }}" \
|
||||
${NSIS_FLAG} \
|
||||
-ldflags="${LDFLAGS}" \
|
||||
-o "WeKnora Lite"
|
||||
|
||||
# ── macOS: bundle .app with resources, then create DMG ──
|
||||
- name: Package macOS app
|
||||
if: runner.os == 'macOS'
|
||||
run: |
|
||||
APP_BUNDLE="WeKnora Lite.app"
|
||||
SRC="cmd/desktop/build/bin/${APP_BUNDLE}"
|
||||
RESOURCES="${SRC}/Contents/Resources"
|
||||
|
||||
mkdir -p "${RESOURCES}/config"
|
||||
mkdir -p "${RESOURCES}/migrations/sqlite"
|
||||
|
||||
if [ -f .env.lite.example ]; then
|
||||
cp .env.lite.example "${RESOURCES}/.env"
|
||||
fi
|
||||
if [ -d migrations/sqlite ]; then
|
||||
cp -r migrations/sqlite/* "${RESOURCES}/migrations/sqlite/"
|
||||
fi
|
||||
if [ -d config ]; then
|
||||
cp -r config/* "${RESOURCES}/config/"
|
||||
fi
|
||||
|
||||
DMG_NAME="WeKnora-Lite-App_${{ steps.ver.outputs.version }}_${{ matrix.arch_label }}"
|
||||
|
||||
DMG_SRC="dmg_staging"
|
||||
mkdir -p "${DMG_SRC}"
|
||||
cp -R "cmd/desktop/build/bin/${APP_BUNDLE}" "${DMG_SRC}/"
|
||||
ln -s /Applications "${DMG_SRC}/Applications"
|
||||
|
||||
hdiutil create -volname "WeKnora Lite" \
|
||||
-srcfolder "${DMG_SRC}" \
|
||||
-ov -format UDZO \
|
||||
"${DMG_NAME}.dmg"
|
||||
|
||||
shasum -a 256 "${DMG_NAME}.dmg" > "${DMG_NAME}.dmg.sha256"
|
||||
|
||||
# ── Linux: package tarball ──
|
||||
- name: Package Linux app
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
ARCHIVE="WeKnora-Lite-App_${{ steps.ver.outputs.version }}_${{ matrix.arch_label }}"
|
||||
mkdir -p "${ARCHIVE}"
|
||||
cp "cmd/desktop/build/bin/WeKnora Lite" "${ARCHIVE}/"
|
||||
if [ -f .env.lite.example ]; then
|
||||
cp .env.lite.example "${ARCHIVE}/"
|
||||
fi
|
||||
cp docs/LITE.md "${ARCHIVE}/README.md"
|
||||
if [ -d config ]; then
|
||||
cp -r config "${ARCHIVE}/config"
|
||||
fi
|
||||
if [ -d migrations/sqlite ]; then
|
||||
mkdir -p "${ARCHIVE}/migrations/sqlite"
|
||||
cp -r migrations/sqlite/* "${ARCHIVE}/migrations/sqlite/"
|
||||
fi
|
||||
tar czf "${ARCHIVE}.tar.gz" "${ARCHIVE}"
|
||||
shasum -a 256 "${ARCHIVE}.tar.gz" > "${ARCHIVE}.tar.gz.sha256"
|
||||
|
||||
# ── Windows: package NSIS installer or plain exe ──
|
||||
- name: Package Windows app
|
||||
if: runner.os == 'Windows'
|
||||
shell: bash
|
||||
run: |
|
||||
ARCHIVE="WeKnora-Lite-App_${{ steps.ver.outputs.version }}_${{ matrix.arch_label }}"
|
||||
# Wails -nsis generates an installer named {app}-amd64-installer.exe
|
||||
INSTALLER="cmd/desktop/build/bin/WeKnora Lite-amd64-installer.exe"
|
||||
if [ -f "$INSTALLER" ]; then
|
||||
cp "$INSTALLER" "${ARCHIVE}_setup.exe"
|
||||
sha256sum "${ARCHIVE}_setup.exe" > "${ARCHIVE}_setup.exe.sha256"
|
||||
else
|
||||
cp "cmd/desktop/build/bin/WeKnora Lite.exe" "${ARCHIVE}.exe"
|
||||
sha256sum "${ARCHIVE}.exe" > "${ARCHIVE}.exe.sha256"
|
||||
fi
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ matrix.artifact_name }}
|
||||
path: |
|
||||
WeKnora-Lite-App_*
|
||||
retention-days: 3
|
||||
|
||||
# ── 3. Create GitHub Release ──
|
||||
release:
|
||||
needs: [build-binary, build-desktop-app]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: release-*
|
||||
merge-multiple: true
|
||||
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: desktop-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Resolve version
|
||||
id: ver
|
||||
run: |
|
||||
if [ -n "${{ inputs.tag }}" ]; then
|
||||
VERSION="${{ inputs.tag }}"
|
||||
else
|
||||
VERSION="${GITHUB_REF#refs/tags/}"
|
||||
fi
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Generate release notes
|
||||
id: notes
|
||||
run: |
|
||||
cat > notes.md << 'NOTES'
|
||||
## WeKnora Lite ${{ steps.ver.outputs.version }}
|
||||
|
||||
### Web CLI 版(命令行 / 服务器部署)
|
||||
|
||||
单二进制部署,零外部依赖(无需 Docker / PostgreSQL / Redis)。
|
||||
|
||||
```bash
|
||||
# 1. 解压
|
||||
tar xzf WeKnora-lite_${{ steps.ver.outputs.version }}_<os>_<arch>.tar.gz
|
||||
cd WeKnora-lite_${{ steps.ver.outputs.version }}_<os>_<arch>
|
||||
|
||||
# 2. 配置
|
||||
cp .env.lite.example .env.lite
|
||||
# 编辑 .env.lite,至少确认 OLLAMA_BASE_URL 正确
|
||||
|
||||
# 3. 启动 Ollama(如尚未运行)
|
||||
ollama serve &
|
||||
ollama pull qwen2.5:7b
|
||||
ollama pull nomic-embed-text
|
||||
|
||||
# 4. 运行
|
||||
set -a && source .env.lite && set +a
|
||||
./WeKnora-lite
|
||||
# 访问 http://localhost:8080
|
||||
```
|
||||
|
||||
| 文件 | 平台 |
|
||||
|------|------|
|
||||
| `WeKnora-lite_*_linux_amd64.tar.gz` | Linux x86_64 |
|
||||
| `WeKnora-lite_*_linux_arm64.tar.gz` | Linux ARM64 |
|
||||
| `WeKnora-lite_*_darwin_amd64.tar.gz` | macOS Intel |
|
||||
| `WeKnora-lite_*_darwin_arm64.tar.gz` | macOS Apple Silicon |
|
||||
|
||||
---
|
||||
|
||||
### 桌面 APP 版(开箱即用,无需命令行)
|
||||
|
||||
原生桌面应用,内置 Web 服务,双击即可运行。
|
||||
|
||||
| 文件 | 平台 |
|
||||
|------|------|
|
||||
| `WeKnora-Lite-App_*_macOS_universal.dmg` | macOS (Intel + Apple Silicon) |
|
||||
| `WeKnora-Lite-App_*_macOS_amd64.dmg` | macOS Intel |
|
||||
| `WeKnora-Lite-App_*_macOS_arm64.dmg` | macOS Apple Silicon |
|
||||
| `WeKnora-Lite-App_*_linux_amd64.tar.gz` | Linux x86_64 |
|
||||
| `WeKnora-Lite-App_*_windows_amd64_setup.exe` | Windows x86_64 (安装包) |
|
||||
|
||||
详细文档见 [LITE.md](docs/LITE.md)。
|
||||
NOTES
|
||||
|
||||
- name: Create release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
gh release create "${{ steps.ver.outputs.version }}" \
|
||||
--title "WeKnora Lite ${{ steps.ver.outputs.version }}" \
|
||||
--notes-file notes.md \
|
||||
WeKnora-lite_*.tar.gz \
|
||||
WeKnora-lite_*.tar.gz.sha256 \
|
||||
WeKnora-Lite-App_* || true
|
||||
|
||||
# ── 4. Update Homebrew Formula ──
|
||||
update-homebrew:
|
||||
needs: release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: main
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Resolve version
|
||||
id: ver
|
||||
run: |
|
||||
if [ -n "${{ inputs.tag }}" ]; then
|
||||
VERSION="${{ inputs.tag }}"
|
||||
else
|
||||
VERSION="${GITHUB_REF#refs/tags/}"
|
||||
fi
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Wait for release assets to be available
|
||||
run: sleep 15
|
||||
|
||||
- name: Update Formula
|
||||
run: ./scripts/update-homebrew-formula.sh "${{ steps.ver.outputs.version }}"
|
||||
|
||||
- name: Commit and push
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add Formula/weknora-lite.rb
|
||||
if git diff --cached --quiet; then
|
||||
echo "No changes to Formula"
|
||||
else
|
||||
git commit -m "formula: update weknora-lite to ${{ steps.ver.outputs.version }}"
|
||||
git push origin main
|
||||
fi
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
# 忽略所有隐藏文件和目录
|
||||
.*
|
||||
# 但不忽略特定隐藏文件和目录
|
||||
!.env.example
|
||||
!.gitignore
|
||||
!.github/
|
||||
|
||||
# 敏感文件
|
||||
*.pem
|
||||
*_key
|
||||
*_secret
|
||||
*.key
|
||||
*.crt
|
||||
|
||||
# IDE和编辑器文件
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# 构建和依赖文件
|
||||
node_modules/
|
||||
/dist/
|
||||
/build/
|
||||
*.log
|
||||
|
||||
# 临时文件
|
||||
tmp/
|
||||
temp/
|
||||
logs/
|
||||
*.pid
|
||||
|
||||
WeKnora
|
||||
WeKnora-lite
|
||||
cmd/desktop/build/bin/
|
||||
/models/
|
||||
test/data/mswag.txt
|
||||
data/files/
|
||||
data/weknora.db
|
||||
data/weknora.db-wal
|
||||
data/weknora.db-shm
|
||||
skills-lock.json
|
||||
|
||||
web/
|
||||
|
||||
**/__pycache__
|
||||
/scripts/scale_dev_jobs.sh
|
||||
/server
|
||||
frontend/.vite
|
||||
frontend/chrome-extension/
|
||||
WeKnora-Chrome-Extension
|
||||
packages/protoc-3.19.4-linux-x86_64.zip
|
||||
|
||||
Claude.md
|
||||
|
||||
# 本地测试脚本
|
||||
configure-ollama.sh
|
||||
fix-docker.sh
|
||||
|
||||
# WeChat Mini Program local/private configuration
|
||||
miniprogram/project.private.config.json
|
||||
|
||||
# Staging dir for the Docker sandbox self-check probe (removed after each run)
|
||||
.weknora-sandbox-check-*
|
||||
|
||||
# Native binaries and release artifacts kept next to the source tree
|
||||
WeKnora-*
|
||||
*.bin
|
||||
/acceptance/
|
||||
/releases/
|
||||
/go-cache/
|
||||
/go-build/
|
||||
/.buildtmp/
|
||||
|
||||
# Compiled helper binaries kept next to the source tree
|
||||
scripts/native-graph-backfill*
|
||||
scripts/native-*
|
||||
+1640
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,350 @@
|
||||
.PHONY: help build run test clean docker-build-app docker-build-docreader docker-build-frontend docker-build-all docker-run migrate-up migrate-down docker-restart docker-stop start-all stop-all start-ollama stop-ollama build-images build-images-app build-images-docreader build-images-frontend clean-images check-env list-containers pull-images show-platform dev-start dev-stop dev-restart dev-logs dev-status dev-app dev-frontend docs install-swagger build-lite run-lite package-lite anydoc-lib build-anydoc
|
||||
|
||||
# Show help
|
||||
help:
|
||||
@echo "WeKnora Makefile 帮助"
|
||||
@echo ""
|
||||
@echo "基础命令:"
|
||||
@echo " build 构建应用"
|
||||
@echo " run 运行应用"
|
||||
@echo " test 运行测试"
|
||||
@echo " anydoc-lib 构建 anydoc 静态库(需要 Rust 工具链)"
|
||||
@echo " build-anydoc 构建带 anydoc 解析引擎的应用"
|
||||
@echo " clean 清理构建文件"
|
||||
@echo ""
|
||||
@echo "Docker 命令:"
|
||||
@echo " docker-build-app 构建应用 Docker 镜像 (wechatopenai/weknora-app)"
|
||||
@echo " docker-build-docreader 构建文档读取器镜像 (wechatopenai/weknora-docreader)"
|
||||
@echo " docker-build-frontend 构建前端镜像 (wechatopenai/weknora-ui)"
|
||||
@echo " docker-build-all 构建所有 Docker 镜像"
|
||||
@echo " docker-run 运行 Docker 容器"
|
||||
@echo " docker-stop 停止 Docker 容器"
|
||||
@echo " docker-restart 重启 Docker 容器"
|
||||
@echo ""
|
||||
@echo "服务管理:"
|
||||
@echo " start-all 启动所有服务"
|
||||
@echo " stop-all 停止所有服务"
|
||||
@echo " start-ollama 仅启动 Ollama 服务"
|
||||
@echo ""
|
||||
@echo "镜像构建:"
|
||||
@echo " build-images 从源码构建所有镜像"
|
||||
@echo " build-images-app 从源码构建应用镜像"
|
||||
@echo " build-images-docreader 从源码构建文档读取器镜像"
|
||||
@echo " build-images-frontend 从源码构建前端镜像"
|
||||
@echo " clean-images 清理本地镜像"
|
||||
@echo ""
|
||||
@echo "数据库:"
|
||||
@echo " migrate-up 执行数据库迁移"
|
||||
@echo " migrate-down 回滚数据库迁移"
|
||||
@echo ""
|
||||
@echo "开发工具:"
|
||||
@echo " fmt 格式化代码"
|
||||
@echo " lint 代码检查"
|
||||
@echo " deps 安装依赖"
|
||||
@echo " docs 生成 Swagger API 文档"
|
||||
@echo " install-swagger 安装 swag 工具"
|
||||
@echo ""
|
||||
@echo "环境检查:"
|
||||
@echo " check-env 检查环境配置"
|
||||
@echo " list-containers 列出运行中的容器"
|
||||
@echo " pull-images 拉取最新镜像"
|
||||
@echo " show-platform 显示当前构建平台"
|
||||
@echo ""
|
||||
@echo "开发模式(推荐):"
|
||||
@echo " dev-start 启动开发环境基础设施(仅启动依赖服务)"
|
||||
@echo " 可选: make dev-start DEV_ARGS=--odl-hybrid"
|
||||
@echo " dev-stop 停止开发环境"
|
||||
@echo " dev-restart 重启开发环境"
|
||||
@echo " dev-logs 查看开发环境日志"
|
||||
@echo " dev-status 查看开发环境状态"
|
||||
@echo " dev-app 启动后端应用(本地运行,需先运行 dev-start)"
|
||||
@echo " 已 make anydoc-lib 时自动链接 anydoc 引擎"
|
||||
@echo " dev-frontend 启动前端(本地运行,需先运行 dev-start)"
|
||||
@echo ""
|
||||
@echo "Lite 模式(零外部依赖):"
|
||||
@echo " build-lite 构建 Lite 版本(先构建前端到 web/,再构建 Go;SKIP_FRONTEND=1 跳过前端)"
|
||||
@echo " run-lite 构建并启动 Lite 版本"
|
||||
@echo " package-lite 构建并打包 Lite 发行包(tarball)"
|
||||
@echo " package-mac-app 构建并打包 macOS 桌面应用 (.app)"
|
||||
|
||||
# Go related variables
|
||||
BINARY_NAME=WeKnora
|
||||
MAIN_PATH=./cmd/server
|
||||
|
||||
# Docker related variables
|
||||
DOCKER_IMAGE=wechatopenai/weknora-app
|
||||
DOCKER_TAG=latest
|
||||
|
||||
# Platform detection
|
||||
ifeq ($(shell uname -m),x86_64)
|
||||
PLATFORM=linux/amd64
|
||||
else ifeq ($(shell uname -m),aarch64)
|
||||
PLATFORM=linux/arm64
|
||||
else ifeq ($(shell uname -m),arm64)
|
||||
PLATFORM=linux/arm64
|
||||
else
|
||||
PLATFORM=linux/amd64
|
||||
endif
|
||||
|
||||
# Build the application
|
||||
build:
|
||||
go build -o $(BINARY_NAME) $(MAIN_PATH)
|
||||
|
||||
# Build the anydoc static archive (Rust) that the `anydoc` build tag links.
|
||||
# Override the platform with TARGET=<rust-target-triple>.
|
||||
anydoc-lib:
|
||||
./scripts/build-anydoc-lib.sh
|
||||
|
||||
# Build the application with the in-process anydoc parser engine linked in.
|
||||
build-anydoc: anydoc-lib
|
||||
go build -tags anydoc -o $(BINARY_NAME) $(MAIN_PATH)
|
||||
|
||||
# Run the application
|
||||
run: build
|
||||
./$(BINARY_NAME)
|
||||
|
||||
# Run tests
|
||||
test:
|
||||
go test -v ./...
|
||||
|
||||
# Clean build artifacts
|
||||
clean:
|
||||
go clean
|
||||
rm -f $(BINARY_NAME)
|
||||
|
||||
# Build Docker image
|
||||
docker-build-app:
|
||||
@echo "获取版本信息..."
|
||||
@eval $$(./scripts/get_version.sh env); \
|
||||
./scripts/get_version.sh info; \
|
||||
docker build --platform $(PLATFORM) \
|
||||
--build-arg VERSION_ARG="$$VERSION" \
|
||||
--build-arg COMMIT_ID_ARG="$$COMMIT_ID" \
|
||||
--build-arg BUILD_TIME_ARG="$$BUILD_TIME" \
|
||||
--build-arg GO_VERSION_ARG="$$GO_VERSION" \
|
||||
--build-arg WITH_ANYDOC=$${WITH_ANYDOC:-1} \
|
||||
-f docker/Dockerfile.app -t $(DOCKER_IMAGE):$(DOCKER_TAG) .
|
||||
|
||||
# Build docreader Docker image
|
||||
docker-build-docreader:
|
||||
docker build --platform $(PLATFORM) -f docker/Dockerfile.docreader -t wechatopenai/weknora-docreader:latest .
|
||||
|
||||
# Build frontend Docker image
|
||||
docker-build-frontend:
|
||||
./scripts/build_frontend_dist.sh
|
||||
docker build --platform $(PLATFORM) -f frontend/Dockerfile -t wechatopenai/weknora-ui:latest frontend/
|
||||
|
||||
# Build all Docker images
|
||||
docker-build-all: docker-build-app docker-build-docreader docker-build-frontend
|
||||
|
||||
# Run Docker container (传统方式)
|
||||
# Touch .env if missing — docker-compose.yml's `env_file: [.env]` is required
|
||||
# for ${ENV} interpolation in builtin_models.yaml and would otherwise refuse
|
||||
# to parse on fresh clones. `start-all` handles this via check_env_file; this
|
||||
# direct path needs its own guard.
|
||||
docker-run:
|
||||
@[ -f .env ] || ([ -f .env.example ] && cp .env.example .env || touch .env)
|
||||
docker-compose up
|
||||
|
||||
# 使用新脚本启动所有服务
|
||||
start-all:
|
||||
./scripts/start_all.sh
|
||||
|
||||
# 使用新脚本仅启动Ollama服务
|
||||
start-ollama:
|
||||
./scripts/start_all.sh --ollama
|
||||
|
||||
# 使用新脚本仅启动Docker容器
|
||||
start-docker:
|
||||
./scripts/start_all.sh --docker
|
||||
|
||||
# 使用新脚本停止所有服务
|
||||
stop-all:
|
||||
./scripts/start_all.sh --stop
|
||||
|
||||
# Stop Docker container (传统方式)
|
||||
docker-stop:
|
||||
docker-compose down
|
||||
|
||||
# 从源码构建镜像相关命令
|
||||
build-images:
|
||||
./scripts/build_images.sh
|
||||
|
||||
build-images-app:
|
||||
./scripts/build_images.sh --app
|
||||
|
||||
build-images-docreader:
|
||||
./scripts/build_images.sh --docreader
|
||||
|
||||
build-images-frontend:
|
||||
./scripts/build_images.sh --frontend
|
||||
|
||||
clean-images:
|
||||
./scripts/build_images.sh --clean
|
||||
|
||||
# Restart Docker container (stop, start)
|
||||
docker-restart:
|
||||
@[ -f .env ] || ([ -f .env.example ] && cp .env.example .env || touch .env)
|
||||
docker-compose stop -t 60
|
||||
docker-compose up
|
||||
|
||||
# Database migrations
|
||||
migrate-up:
|
||||
./scripts/migrate.sh up
|
||||
|
||||
migrate-down:
|
||||
./scripts/migrate.sh down
|
||||
|
||||
migrate-version:
|
||||
./scripts/migrate.sh version
|
||||
|
||||
migrate-create:
|
||||
@if [ -z "$(name)" ]; then \
|
||||
echo "Error: migration name is required"; \
|
||||
echo "Usage: make migrate-create name=your_migration_name"; \
|
||||
exit 1; \
|
||||
fi
|
||||
./scripts/migrate.sh create $(name)
|
||||
|
||||
migrate-force:
|
||||
@if [ -z "$(version)" ]; then \
|
||||
echo "Error: version is required"; \
|
||||
echo "Usage: make migrate-force version=4"; \
|
||||
exit 1; \
|
||||
fi
|
||||
./scripts/migrate.sh force $(version)
|
||||
|
||||
migrate-goto:
|
||||
@if [ -z "$(version)" ]; then \
|
||||
echo "Error: version is required"; \
|
||||
echo "Usage: make migrate-goto version=3"; \
|
||||
exit 1; \
|
||||
fi
|
||||
./scripts/migrate.sh goto $(version)
|
||||
|
||||
# Generate API documentation (Swagger)
|
||||
docs:
|
||||
@echo "生成 Swagger API 文档..."
|
||||
swag init -g $(MAIN_PATH)/main.go -o ./docs --parseDependency --parseInternal
|
||||
@echo "文档已生成到 ./docs 目录"
|
||||
@echo "启动服务后访问 http://localhost:8080/swagger/index.html 查看文档"
|
||||
|
||||
# Install swagger tool
|
||||
install-swagger:
|
||||
go install github.com/swaggo/swag/cmd/swag@latest
|
||||
|
||||
# Format code
|
||||
fmt:
|
||||
go fmt ./...
|
||||
|
||||
# Lint code
|
||||
lint:
|
||||
golangci-lint run
|
||||
|
||||
# Install dependencies
|
||||
deps:
|
||||
go mod download
|
||||
|
||||
# Build for production
|
||||
# google.golang.org/protobuf/reflect/protoregistry.conflictPolicy=warn for qdrant milvus proto conflict
|
||||
# GO_BUILD_TAGS adds optional build tags, e.g. GO_BUILD_TAGS=anydoc to link the
|
||||
# in-process office document parser (run `make anydoc-lib` first).
|
||||
build-prod:
|
||||
VERSION=$$(git describe --tags --abbrev=0 2>/dev/null || echo "$${VERSION:-unknown}"); \
|
||||
COMMIT_ID=$${COMMIT_ID:-unknown}; \
|
||||
CGO_ENABLED=1 \
|
||||
CGO_CFLAGS="-Wno-deprecated-declarations" \
|
||||
CGO_LDFLAGS="$$(if [ "$$(uname)" = 'Darwin' ]; then echo '-Wl,-no_warn_duplicate_libraries'; fi)" \
|
||||
BUILD_TIME=$${BUILD_TIME:-unknown}; \
|
||||
GO_VERSION=$${GO_VERSION:-unknown}; \
|
||||
LDFLAGS="-X 'github.com/Tencent/WeKnora/internal/handler.Version=$$VERSION' -X 'github.com/Tencent/WeKnora/internal/handler.Edition=standard' -X 'github.com/Tencent/WeKnora/internal/handler.CommitID=$$COMMIT_ID' -X 'github.com/Tencent/WeKnora/internal/handler.BuildTime=$$BUILD_TIME' -X 'github.com/Tencent/WeKnora/internal/handler.GoVersion=$$GO_VERSION' -X 'google.golang.org/protobuf/reflect/protoregistry.conflictPolicy=warn'"; \
|
||||
go build -tags "$(GO_BUILD_TAGS)" -ldflags="-w -s $$LDFLAGS" -o $(BINARY_NAME) $(MAIN_PATH)
|
||||
|
||||
# Build Lite version (single binary, SQLite + in-memory queue)
|
||||
# 会先构建前端到 web/,再构建 Go 二进制;SKIP_FRONTEND=1 可跳过前端
|
||||
build-lite:
|
||||
@if [ -f frontend/package.json ] && [ "$${SKIP_FRONTEND:-}" != "1" ]; then \
|
||||
echo ">> Building frontend for Lite..."; \
|
||||
(cd frontend && npm ci --prefer-offline && npm run build) && \
|
||||
rm -rf web && cp -r frontend/dist web; \
|
||||
elif [ "$${SKIP_FRONTEND:-}" = "1" ]; then \
|
||||
echo ">> Skipping frontend (SKIP_FRONTEND=1)"; \
|
||||
else \
|
||||
echo ">> No frontend/package.json, skipping frontend"; \
|
||||
fi
|
||||
export EDITION=lite; \
|
||||
eval "$$(./scripts/get_version.sh env)"; \
|
||||
LDFLAGS="$$(./scripts/get_version.sh ldflags) -X 'google.golang.org/protobuf/reflect/protoregistry.conflictPolicy=warn'"; \
|
||||
CGO_ENABLED=1 \
|
||||
CGO_CFLAGS="-Wno-deprecated-declarations" \
|
||||
CGO_LDFLAGS="$$(if [ "$$(uname)" = 'Darwin' ]; then echo '-Wl,-no_warn_duplicate_libraries'; fi)" \
|
||||
go build -tags "sqlite_fts5" -ldflags="-w -s $$LDFLAGS" -o $(BINARY_NAME)-lite $(MAIN_PATH)
|
||||
|
||||
# Run Lite version with .env.lite defaults
|
||||
run-lite: build-lite
|
||||
@if [ ! -f .env.lite ]; then echo "Error: .env.lite not found"; exit 1; fi
|
||||
@set -a && . ./.env.lite && set +a && ./$(BINARY_NAME)-lite
|
||||
|
||||
# Package Lite version into distributable tarball
|
||||
package-lite:
|
||||
./scripts/package-lite.sh
|
||||
|
||||
# Package Mac App
|
||||
package-mac-app:
|
||||
./scripts/package-mac-app.sh
|
||||
|
||||
download_spatial:
|
||||
go run cmd/download/duckdb/duckdb.go
|
||||
|
||||
clean-db:
|
||||
@echo "Cleaning database..."
|
||||
@if [ $$(docker volume ls -q -f name=weknora_postgres-data) ]; then \
|
||||
docker volume rm weknora_postgres-data; \
|
||||
fi
|
||||
@if [ $$(docker volume ls -q -f name=weknora_minio_data) ]; then \
|
||||
docker volume rm weknora_minio_data; \
|
||||
fi
|
||||
@if [ $$(docker volume ls -q -f name=weknora_redis_data) ]; then \
|
||||
docker volume rm weknora_redis_data; \
|
||||
fi
|
||||
|
||||
# Environment check
|
||||
check-env:
|
||||
./scripts/start_all.sh --check
|
||||
|
||||
# List containers
|
||||
list-containers:
|
||||
./scripts/start_all.sh --list
|
||||
|
||||
# Pull latest images
|
||||
pull-images:
|
||||
./scripts/start_all.sh --pull
|
||||
|
||||
# Show current platform
|
||||
show-platform:
|
||||
@echo "当前系统架构: $(shell uname -m)"
|
||||
@echo "Docker构建平台: $(PLATFORM)"
|
||||
|
||||
# Development mode commands
|
||||
dev-start:
|
||||
./scripts/dev.sh start $(DEV_ARGS)
|
||||
|
||||
dev-stop:
|
||||
./scripts/dev.sh stop
|
||||
|
||||
dev-restart:
|
||||
./scripts/dev.sh restart
|
||||
|
||||
dev-logs:
|
||||
./scripts/dev.sh logs
|
||||
|
||||
dev-status:
|
||||
./scripts/dev.sh status
|
||||
|
||||
dev-app:
|
||||
./scripts/dev.sh app
|
||||
|
||||
dev-frontend:
|
||||
./scripts/dev.sh frontend
|
||||
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
<p align="center">
|
||||
<picture>
|
||||
<img src="./docs/images/logo.png" alt="WeKnora Logo" height="120"/>
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<picture>
|
||||
<a href="https://trendshift.io/repositories/15289" target="_blank">
|
||||
<img src="https://trendshift.io/api/badge/repositories/15289" alt="Tencent/WeKnora | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/>
|
||||
</a>
|
||||
</picture>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://weknora.weixin.qq.com" target="_blank">
|
||||
<img alt="Official Website" src="https://img.shields.io/badge/Official Website-WeKnora-4e6b99">
|
||||
</a>
|
||||
<a href="https://chatbot.weixin.qq.com" target="_blank">
|
||||
<img alt="WeChat Dialog Open Platform" src="https://img.shields.io/badge/WeChat Dialog Open Platform-5ac725">
|
||||
</a>
|
||||
<a href="https://chromewebstore.google.com/detail/jpemjbopikggjlmikmclgbmkhhopjdgd" target="_blank">
|
||||
<img alt="Chrome Extension" src="https://img.shields.io/badge/Chrome Extension-WeKnora-4285F4">
|
||||
</a>
|
||||
<a href="https://clawhub.ai/lyingbug/weknora" target="_blank">
|
||||
<img alt="ClawHub Skill" src="https://img.shields.io/badge/ClawHub Skill-WeKnora-ff6b35">
|
||||
</a>
|
||||
<a href="https://www.npmjs.com/package/@wxg-prc-cpg/dsh-weknora" target="_blank">
|
||||
<img alt="npm @wxg-prc-cpg/dsh-weknora" src="https://img.shields.io/npm/v/@wxg-prc-cpg/dsh-weknora?label=dsh-weknora">
|
||||
</a>
|
||||
<a href="https://github.com/Tencent/WeKnora/blob/main/LICENSE">
|
||||
<img src="https://img.shields.io/badge/License-MIT-ffffff?labelColor=d4eaf7&color=2e6cc4" alt="License">
|
||||
</a>
|
||||
<a href="./CHANGELOG.md">
|
||||
<img alt="Version" src="https://img.shields.io/badge/version-0.8.0-2e6cc4?labelColor=d4eaf7">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
| <b>English</b> | <a href="./README_CN.md"><b>简体中文</b></a> | <a href="./README_JA.md"><b>日本語</b></a> | <a href="./README_KO.md"><b>한국어</b></a> |
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<h4 align="center">
|
||||
|
||||
[Overview](#-overview) • [Architecture](#-architecture) • [Key Features](#-key-features) • [Getting Started](#-getting-started) • [API Reference](#-api-reference) • [Developer Guide](#-developer-guide)
|
||||
|
||||
</h4>
|
||||
</p>
|
||||
|
||||
# 💡 WeKnora — Turn Documents into Living Knowledge with RAG, Agents and Auto-Wiki
|
||||
|
||||
## 📌 Overview
|
||||
|
||||
[**WeKnora**](https://weknora.weixin.qq.com) is an open-source, LLM-powered knowledge framework built for enterprise-grade document understanding, semantic retrieval, and autonomous reasoning.
|
||||
|
||||
It is organized around three core capabilities: **RAG-based Quick Q&A** for everyday lookups, a **ReAct Agent** that autonomously orchestrates retrieval, MCP tools, a **tenant skill catalog**, session-persistent **Docker / E2B / Cube sandboxes** and web search to handle complex multi-step tasks, and a brand-new **Wiki Mode** in which agents distill raw documents into a self-maintaining, interlinked markdown knowledge base with an interactive knowledge graph, complete with manual editing, revision history and one-click rollback. **Cross-session long-term memory** remembers who you are and what you keep asking about. Knowledge curation is equally hands-on: a **tree-structured folder view** preserves the directory layout of uploads, and **chunk editing with revision history** lets retrieval chunks be edited, diffed and reverted like documents. Combined with multi-source ingestion (Feishu wiki / Feishu Drive / GitLab / Tencent IMA / Notion / Yuque / RSS, and growing), **website embed widgets** for publishing agents to external sites, **scoped API keys with a principal model** for programmatic integrations, **multi-instance storage backends** per workspace for flexible data placement, 20+ LLM provider integrations (including LiteLLM), full Langfuse observability plus a **runtime task-queue dashboard with worker-pool governance**, **enterprise-ready multi-workspace RBAC** (4-tier role matrix + per-resource ownership + per-workspace audit log), and a fully self-hostable modular architecture, WeKnora turns scattered documents into a queryable, reasoning-capable, continuously evolving knowledge asset.
|
||||
|
||||
The framework supports auto-syncing knowledge from Feishu, GitLab, Tencent IMA, Notion, and Yuque (more data sources coming soon), handles 10+ document formats including PDF, Word, images, Excel and XMind, and can serve Q&A directly through IM channels like WeCom, Feishu, Slack, and Telegram. It is compatible with major LLM providers including OpenAI, DeepSeek, Qwen (Alibaba Cloud), Zhipu, Hunyuan, Gemini, MiniMax, NVIDIA, LiteLLM, and Ollama. Office files can be parsed in-process with **anydoc**. Its fully modular design allows swapping LLMs, vector databases, and storage backends, with support for local and private cloud deployment ensuring complete data sovereignty. WeKnora also integrates with **Langfuse** for comprehensive observability into agent reasoning, token usage, and pipeline tracing.
|
||||
|
||||
|
||||
## ✨ Latest Updates
|
||||
|
||||
- **v0.8.0** — **Skill sandbox runtime** (session-persistent Docker / E2B / Cube backends with per-tenant network policy; Local host-process backend removed; Docker opt-in); **tenant skill catalog** (install from ClawHub / SkillHub / git / zip, per-sandbox snapshots, live progress, file browse/edit, personal and workspace env vars); **cross-session long-term memory** (profile / preference / fact / task / interest, auto-extract with confirm, `search_memory`); **in-process anydoc office parser**; official **DeepSeek Harness plugin** `@wxg-prc-cpg/dsh-weknora`; GitLab and Tencent IMA data sources; LiteLLM; Exa and Metaso web search; XMind parsing; chat artifacts, question outline and timestamps; context compaction and provider prompt-cache markers. Plus OIDC JWKS verification, optional complex passwords, document auto-tagging, and broad sandbox/security hardening. See [`CHANGELOG.md`](./CHANGELOG.md).
|
||||
- **v0.7.2** — Launched the **official product documentation site** (VitePress; six sections, ~50 pages covering ~360 API endpoints and ~150 environment variables, with standalone Docker/Nginx deployment, quickstart sample data and a local MCP demo); **knowledge base folder tree** (upload paths stored as first-class data, browse/rename/re-file documents like a file manager); **chunk editing with revision history** (edit retrieval chunks in the UI, per-version diff and rollback, automatic reindexing, plus custom document metadata); **Wiki page revision history** (snapshots + line-level diff + one-click rollback + in-browser manual editing); **directly loadable file URLs** via `resource_urls=public` / `RESOURCE_URL_MODE` (third-party apps render images and files without a second authenticated proxy call); **Feishu Drive data source** and docx sync through the blocks API; batch document tagging; **MCP Server 1.1.x** (migrated to the mcp 2.x high-level API, official PyPI package `tencent-weknora-mcp`, new `create_knowledge_from_text` and `list_shared_knowledge_bases` for 29 tools total); AWS S3 default credential chain (IAM Role / IRSA); local HTML upload parsing; QQBot markdown replies; new PR CI checks for app / frontend / docreader / mcp-server. Plus large-scale router and `modelcontext` refactors, rerank and chunking quality work, and broad stability fixes. See [`CHANGELOG.md`](./CHANGELOG.md).
|
||||
- **v0.7.1** — New **Yunzhijia (云之家) IM integration** (WebSocket + image messages + markdown replies); **Volcengine rerank** provider (with request batching) and **Zhipu AI web search** provider; **platform-scoped API keys** for control-plane automation (tenant management, system settings, runtime queues, audit logs); **per-KB activity audit trail**; FAQ management enhancements (filtering, tagging, export, import tracking); **Langfuse OTLP/OTel tracing** migration with W3C traceparent propagation; chat header actions with one-click **Markdown export** and wiki tool results in the references drawer; prompt-cache observability; session channel governance (admin-scoped IM/embed/API sessions); resilient Feishu large-wiki sync; and removal of the legacy Neo4j conversation-memory dependency. Plus broad slug-integrity, SSRF-transport, and state-sync hardening. See [`CHANGELOG.md`](./CHANGELOG.md).
|
||||
- **v0.7.0** — Fine-grained **scoped API keys & principal model** (capability-level grants + per-KB restriction + API integration playground); **runtime task-queue observability dashboard & worker-pool governance** (per-stage pools + per-model concurrency governors + failed-task inspection/retry); **multi-instance storage backends** (multiple storage instances per workspace, per-KB binding, default instance); **session-scoped temporary attachments** (async image/doc parsing + combined limits); question & follow-up suggestions; stable resource registry with LLM-context alias compaction; `@Skill / @MCP` mentions with scoped agent runtime; mid-conversation MCP OAuth; QQBot & Lark (Feishu International) IM integration; Redis TLS; Requesty model provider + Keenable web search; tenantless provisioning & gated self-service workspaces; admin password reset; knowledge base duplicate flow; `weknora` CLI v0.10. Plus broad security hardening (SSRF, secret redaction, SQL validation, IDOR). See [`CHANGELOG.md`](./CHANGELOG.md).
|
||||
- **v0.6.3** — Website embed widget & Integrations Center (secure-mode token exchange + rate limits); chat experience overhaul (citation popovers, RAG pipeline progress, streaming markdown); document multi-tag & batch reparse; Wiki folders & hierarchy navigation; RSS data source; MCP OAuth2; EPUB / MHTML parsing; agent model-readiness checks; model test debugger; session source filter; workspace deletion UI. See [`CHANGELOG.md`](./CHANGELOG.md).
|
||||
- **v0.6.2** — Per-upload process configuration with upload-confirm dialog; document reparse with `process_config`; `weknora` CLI v0.9 (bundled Agent Skills, `session stop`, auth/profile harmonization); KB marquee multi-select; HNSW index for 1024-dim pgvector embeddings; chat resources store refactor; Langfuse-only tracing (Jaeger removed). See [`CHANGELOG.md`](./CHANGELOG.md).
|
||||
- **v0.6.1** — Document parsing trace timeline (Langfuse-style span tree with stage-by-stage progress + stop-parse); OpenSearch vector store driver; declarative built-in models via YAML; system admin & consolidated platform settings + audit log; new-user onboarding guide; settings UI redesign; `weknora` CLI v0.7 / v0.8 (agent-first wire contract, NDJSON, `--dry-run`); OpenDataLoader + PaddleOCR-VL parsers; MCP server multi-transport (stdio / SSE / HTTP); per-model thinking-mode config; Tencent LKEAP rerank + native Gemini embeddings + MiniMax-M3. See [`CHANGELOG.md`](./CHANGELOG.md).
|
||||
- **v0.6.0** — Workspace RBAC (4-tier role matrix `Owner` / `Admin` / `Contributor` / `Viewer` + per-KB ownership + per-workspace audit log), workspace member management & multi-workspace UX, self-service workspaces; `weknora` CLI v0.4 GA with `mcp serve`; KB retrieval fan-out across vector stores; AES-256-GCM credential encryption + docreader gRPC TLS + Token; Zhipu embedder + Huawei OBS; server-side user preferences; Go 1.26.0. See [`docs/RBAC说明.md`](./docs/RBAC说明.md) and [`CHANGELOG.md`](./CHANGELOG.md).
|
||||
- **v0.5.2** — Wiki ingest scales to 40k-document KBs (task queue + DLQ); MCP human-in-the-loop tool approval; Anthropic / Apache Doris / Tencent VectorDB / KS3 / SearXNG backends; adaptive 3-tier chunking with live preview; global ⌘K command palette; Yuque connector + WeChat Mini Program; `weknora` CLI preview.
|
||||
- **v0.5.1** — Knowledge-base batch management; workspace-wide IM channels overview; session search + user-scoped pinning; unified Model / Web Search / MCP settings cards; per-agent LLM timeout; desktop workspace switching.
|
||||
- **v0.5.0** — Wiki Mode GA — agents auto-generate structured, interlinked Markdown wiki pages with a knowledge graph; wiki browser + visual graph in the UI.
|
||||
- **v0.4.0** — WeKnora Cloud (hosted LLM + parsing); Chrome Extension; ClawHub Skill; WeChat IM; attachment processing; Azure OpenAI / Alibaba OSS; Notion connector; Baidu + Ollama web search; VectorStore management.
|
||||
- **v0.3.6** — ASR (audio); Feishu data-source auto-sync; OIDC; IM quote-reply context + thread-based sessions; document summarization; Tavily search; parallel tool calling; agent @mention scope restriction.
|
||||
- **v0.3.5** — Telegram / DingTalk / Mattermost IM; IM slash commands + QA queue; suggested questions; VLM auto-describe MCP tool images; Novita AI; channel tracking.
|
||||
- **v0.3.4** — WeCom / Feishu / Slack IM; multimodal image support; NVIDIA model API; Weaviate; AWS S3; AES-256-GCM API-key encryption; built-in MCP service; hybrid-search optimization; `final_answer` tool.
|
||||
- **v0.3.3** — Parent-child chunking; KB pinning; fallback response; passage cleaning for rerank; storage auto-creation; Milvus.
|
||||
- **v0.3.2** — Knowledge Search entry; per-source parser & storage engine config; image rendering in local storage; document preview; Volcengine TOS; Mermaid rendering; batch session management; memory graph preview.
|
||||
- **v0.3.0** — Shared Space; Agent Skills + sandboxed execution; custom agents; Data Analyst agent; thinking mode; Bing / Google web search; API Key auth; Helm chart; Korean i18n; Qdrant.
|
||||
- **v0.2.0** — Agent Mode (ReACT); multi-type knowledge bases (FAQ + document); conversation strategy config; DuckDuckGo web search; MCP tool integration; new UI with agent mode switching; MQ async task management.
|
||||
|
||||
|
||||
## 📱 Interface Showcase
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td colspan="2" align="center"><b>🛠️ Skill Sandbox Chat · generate and preview a Word file</b><br/><img src="./docs/images/skill-sandbox-chat.png" alt="Skill sandbox conversation generating and previewing a Word document" width="100%"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" align="center"><b>📦 Skill Catalog · install onto an E2B sandbox</b><br/><img src="./docs/images/skill-catalog.png" alt="Workspace skill catalog with docx pptx pdf installed on E2B" width="100%"></td>
|
||||
<td width="50%" align="center"><b>🤖 Agent Mode · search, read a skill, write sandbox files</b><br/><img src="./docs/images/agent-qa.png" alt="Agent searching the knowledge base, reading the docx skill, and writing a sandbox script" width="100%"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" align="center"><b>💬 Intelligent Q&A Conversation</b><br/><img src="./docs/images/qa.png" alt="Intelligent Q&A Conversation" width="100%"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" align="center"><b>📖 Wiki Browser</b><br/><img src="./docs/images/wiki-browser.png" alt="Wiki Browser" width="100%"></td>
|
||||
<td width="50%" align="center"><b>🕸️ Wiki Knowledge Graph</b><br/><img src="./docs/images/wiki-graph.png" alt="Wiki Knowledge Graph" width="100%"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" align="center"><b>🕘 Wiki Page Revision History & Rollback</b><br/><img src="./docs/images/wiki-revision-history.png" alt="Wiki Page Revision History and Rollback" width="100%"></td>
|
||||
<td width="50%" align="center"><b>✂️ Chunk Editing & Revision History</b><br/><img src="./docs/images/kb-chunk-edit.png" alt="Chunk Editing and Revision History" width="100%"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" align="center"><b>📁 Folder Tree & Batch Operations</b><br/><img src="./docs/images/kb-document-list.png" alt="Knowledge Base Folder Tree and Batch Operations" width="100%"></td>
|
||||
<td width="50%" align="center"><b>🔭 Observability · Langfuse Tracing</b><br/><img src="./docs/images/langfuse.png" alt="Observability Langfuse Tracing" width="100%"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||

|
||||
|
||||
Fully modular pipeline from document parsing, vectorization, and retrieval to LLM inference — every component is swappable and extensible. Supports local / private cloud deployment with full data sovereignty and a zero-barrier Web UI for quick onboarding.
|
||||
|
||||
## 🧩 Feature Overview
|
||||
|
||||
**Intelligent Conversation**
|
||||
|
||||
| Capability | Details |
|
||||
|------------|---------|
|
||||
| Intelligent Reasoning | ReACT progressive multi-step reasoning, autonomously orchestrating knowledge retrieval, MCP tools, skill sandboxes, and web search |
|
||||
| Quick Q&A | RAG-based Q&A over knowledge bases for fast and accurate answers |
|
||||
| Wiki Mode | Agent-driven auto-generation of structured, interlinked markdown Wiki pages from raw documents; in-browser manual editing, page revision history, line-level diff and one-click rollback |
|
||||
| Skill Catalog & Sandbox | Workspace skill catalog (ClawHub / SkillHub / git / zip) installed onto session-persistent Docker / E2B / Cube sandboxes; `shell_exec`, file tools, artifacts, per-config network policy; Local host-process backend removed |
|
||||
| Long-term Memory | Cross-session memory (profile / preference / fact / task / interest) with auto-extract, user confirm, and on-demand `search_memory` |
|
||||
| Tool Calling | Built-in tools, MCP tools (incl. OAuth2 remote services, mid-conversation OAuth), web search; `@Skill / @MCP` mentions to scope the agent runtime per turn |
|
||||
| Conversation Strategy | Online Prompt editing, retrieval threshold tuning, multi-turn context awareness, per-agent citation output toggle |
|
||||
| Suggested Questions | Auto-generated question suggestions and after-answer follow-ups based on knowledge base content |
|
||||
| Temporary Attachments | Session-scoped image / document uploads with async parsing for one-off Q&A, with a combined image + attachment limit |
|
||||
| Citations & RAG Progress | Inline citation popovers and a references drawer (web / KB source distinction), shared markdown rendering, and stage-by-stage RAG pipeline progress in chat |
|
||||
| Session Management | Filter and group sidebar sessions by source (Web / IM / Embed), with inline session-title rename |
|
||||
|
||||
**Knowledge Management**
|
||||
|
||||
| Capability | Details |
|
||||
|------------|---------|
|
||||
| Knowledge Base Types | FAQ / Document / Wiki with folder import, URL import, multi-tag management, and online entry |
|
||||
| Folder Tree | Folder uploads keep their original directory structure, with a sidebar tree for browsing, folder rename, and re-filing documents into another folder |
|
||||
| Chunk Editing & Revisions | Edit retrieval chunks directly in the UI with per-version snapshots, diff and one-click rollback, and automatic reindexing after an edit; generated questions can be added, edited, deleted and regenerated; custom document metadata supported |
|
||||
| Per-Upload Process Config | Override parser, chunking, multimodal (VLM / ASR), graph extraction, and question generation per upload batch via upload-confirm dialog or `process_config` API; reparse with new settings |
|
||||
| Batch Reparse | Re-queue parsing for multiple documents at once with optional per-batch `process_config` |
|
||||
| Data Source Import | Auto-sync from Feishu wiki / Feishu Drive / Lark / GitLab / Tencent IMA / Notion / Yuque / RSS feeds (more data sources coming soon); incremental and full sync |
|
||||
| Document Formats | PDF / Word / Txt / Markdown / HTML / EPUB / MHTML / Images / CSV / Excel / PPT / JSON / XMind |
|
||||
| Auto-Tagging | After parse, pick matching tags from the knowledge base's existing set without creating tags or overwriting manual ones |
|
||||
| Retrieval Strategies | BM25 sparse / Dense retrieval / GraphRAG / parent-child chunking / HNSW-accelerated pgvector (1024-dim) / multi-dimensional indexing |
|
||||
| Batch Selection & Tagging | Marquee drag-select multiple documents in the KB list for batch reparse and batch tagging (common tags pre-selected) |
|
||||
| E2E Testing | Full-pipeline visualization with recall hit rate, BLEU / ROUGE metric evaluation |
|
||||
|
||||
**Integrations & Extensions**
|
||||
|
||||
| Capability | Details |
|
||||
|------------|---------|
|
||||
| LLMs | OpenAI / Azure OpenAI / Anthropic (Claude) / DeepSeek / Qwen (Alibaba Cloud) / Zhipu / Hunyuan / Doubao (Volcengine) / Gemini / MiniMax / NVIDIA / Novita AI / SiliconFlow / OpenRouter / Requesty / LiteLLM / Ollama |
|
||||
| Embeddings | Ollama / BGE / GTE / Zhipu / OpenAI-compatible APIs |
|
||||
| Vector DBs | PostgreSQL (pgvector) / Elasticsearch / OpenSearch / Milvus / Weaviate / Qdrant / Apache Doris / Tencent VectorDB |
|
||||
| Object Storage | Local / MinIO / AWS S3 (IAM Role / IRSA default credential chain) / Volcengine TOS / Alibaba Cloud OSS / Kingsoft Cloud KS3 / Huawei Cloud OBS; **multiple storage instances per workspace** with per-KB binding and a default instance |
|
||||
| IM Channels | WeCom / Feishu / Lark (Feishu International) / QQBot / Slack / Telegram / DingTalk / Mattermost / WeChat / Yunzhijia |
|
||||
| Website Embed | Publish agents via embed widget with domain allowlists, rate limits, and secure-mode token exchange |
|
||||
| Web Search | DuckDuckGo / Bing / Google / Tavily / Baidu / Ollama / SearXNG / Keenable / Zhipu AI / Exa / Metaso |
|
||||
| API Integration | Scoped API keys (capability-level grants + per-KB restriction + throttled last-used tracking) with an API integration playground; MCP OAuth and embed sessions isolated per principal; `resource_urls=public` returns directly loadable file/image URLs, removing the second authenticated proxy call |
|
||||
| MCP Server | Official PyPI package `tencent-weknora-mcp` with 29 tools over stdio / SSE / HTTP transports |
|
||||
|
||||
**Platform**
|
||||
|
||||
| Capability | Details |
|
||||
|------------|---------|
|
||||
| Deployment | Local / Docker / Kubernetes (Helm) with private and offline support |
|
||||
| UI | Web UI / RESTful API / CLI (`weknora`) / Chrome Extension / Website Embed Widget / WeChat Mini Program |
|
||||
| Access Control | Workspace RBAC with 4-tier role matrix (Owner / Admin / Contributor / Viewer), per-KB resource ownership, per-workspace audit log, invite-only workspaces, tenantless provisioning & gated self-service workspace creation, admin password reset (session revocation), cross-workspace superuser, scoped API keys |
|
||||
| Security | AES-256-GCM at-rest encryption for API keys and MCP / data-source credentials with graceful key rotation; gRPC TLS + Token between app and docreader; Redis TLS; SSRF-safe HTTP client (data sources, URL import, redirect chains); secret redaction in responses; skill sandbox isolation (Docker opt-in / E2B / Cube) with per-config network policy; OIDC ID-token JWKS verification; optional complex-password policy |
|
||||
| Observability | Integrated Langfuse (sole tracing backend) for ReAct loops, token tracking, tool calls, and pipeline tracing; built-in Langfuse-style document parsing trace timeline with stage-by-stage progress; system-admin runtime task-queue dashboard (queue depth, per-model concurrency, failed-task inspection & manual retry) |
|
||||
| Task Management | MQ async tasks with per-stage worker-pool governance (core / post-process / enrichment / maintenance + elastic shared pool, plus an independent Wiki pool) and per-model background concurrency governors; automatic database migration on version upgrade |
|
||||
| Model Management | Centralized config, declarative built-in models via YAML, per-knowledge-base model selection, per-model thinking-mode and embedding-dimension overrides, interactive model test debugger, multi-workspace built-in model sharing, WeKnora Cloud hosted models and parsing |
|
||||
|
||||
## 🧩 Chrome Extension
|
||||
|
||||
[**WeKnora Chrome Extension**](https://chromewebstore.google.com/detail/jpemjbopikggjlmikmclgbmkhhopjdgd) lets you capture web content directly into your WeKnora knowledge base. Select text, images, or entire pages in the browser and save them as knowledge entries with one click — no copy-paste or file upload needed.
|
||||
|
||||
|
||||
## 📱 WeChat Mini Program
|
||||
|
||||
The [WeKnora Mini Program](./miniprogram/README.md) provides a lightweight mobile client for configuring WeKnora API access, selecting knowledge bases, importing URLs, and asking knowledge chat from WeChat.
|
||||
|
||||
|
||||
## 🦞 ClawHub Skill
|
||||
|
||||
[**WeKnora ClawHub Skill**](https://clawhub.ai/lyingbug/weknora) is a WeKnora skill published on the ClawHub platform. Once installed, it enables document import (file / URL / Markdown), hybrid search (vector + keyword) across knowledge bases, and knowledge entry management — all through the WeKnora REST API.
|
||||
|
||||
- **Document Import** — Upload files, import web pages, or write Markdown knowledge via the agent
|
||||
- **Hybrid Search** — Search within or across knowledge bases with vector + keyword retrieval
|
||||
- **Knowledge Management** — List, browse, edit, and delete knowledge entries programmatically
|
||||
|
||||
## 🐋 DeepSeek Harness Plugin
|
||||
|
||||
[**`@wxg-prc-cpg/dsh-weknora`**](https://www.npmjs.com/package/@wxg-prc-cpg/dsh-weknora) is the official [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (`dsh`) plugin ([docs](./packages/dsh-weknora/README.md)). The harness ships no retrieval, embedding or knowledge-base capability of its own, so the plugin gives a coding agent your documents: `dsh plugin --profile web add @wxg-prc-cpg/dsh-weknora`, point it at a deployment, and four read-only tools appear in the agent's tool set.
|
||||
|
||||
- **`weknora_search`** — hybrid retrieval returning source passages verbatim, each with a reusable `knowledge_id`
|
||||
- **`weknora_read_document`** — one document's passages reassembled in order, with paging
|
||||
- **`weknora_ask`** — WeKnora's own composed answer with citations, over the RAG or the ReAct pipeline
|
||||
- **`weknora_list_knowledge_bases`** — knowledge base names and ids, so the agent can scope its own search
|
||||
|
||||
## ⌨️ Command-Line Interface
|
||||
|
||||
`weknora` is the official CLI for driving the API from a terminal or an AI
|
||||
agent. It is **agent-first**: every command emits a stable JSON envelope by
|
||||
default (with typed error codes mapped to exit codes), and `--format text`
|
||||
renders for humans. It also serves a curated MCP tool surface
|
||||
(`weknora mcp serve`) and ships bundled Agent Skills.
|
||||
|
||||
```bash
|
||||
weknora profile add prod --host https://kb.example.com --use
|
||||
weknora auth login
|
||||
weknora kb list
|
||||
weknora link --kb my-knowledge-base # bind the current directory
|
||||
weknora doc upload notes.md
|
||||
weknora chat "summarise the design doc"
|
||||
```
|
||||
|
||||
For headless / CI use, set `WEKNORA_API_KEY` + `WEKNORA_HOST` and skip
|
||||
`auth login` entirely — no credentials written to disk.
|
||||
|
||||
See [`cli/README.md`](./cli/README.md) for install + 5-minute quickstart and
|
||||
[`cli/AGENTS.md`](./cli/AGENTS.md) for the operational contract AI agents rely on.
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
### 🛠 Prerequisites
|
||||
|
||||
- [Docker](https://www.docker.com/) & [Docker Compose](https://docs.docker.com/compose/)
|
||||
- [Git](https://git-scm.com/)
|
||||
|
||||
### 📦 Installation & Launch
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Tencent/WeKnora.git
|
||||
cd WeKnora
|
||||
cp .env.example .env # Edit .env as needed, see comments in the file
|
||||
docker compose pull # Pull the latest images
|
||||
docker compose up -d # Start core services
|
||||
```
|
||||
|
||||
Once started, visit **http://localhost** to get started.
|
||||
|
||||
> To use a local Ollama model, run `ollama serve > /dev/null 2>&1 &` first.
|
||||
|
||||
### 🔄 Upgrading
|
||||
|
||||
If you already have WeKnora running and downloaded a newer release:
|
||||
|
||||
```bash
|
||||
# Set WEKNORA_VERSION in .env to the target release (e.g. 0.7.0), or keep latest
|
||||
docker compose pull # Pull images matching WEKNORA_VERSION
|
||||
docker compose up -d # Recreate containers with new images
|
||||
```
|
||||
|
||||
> `docker compose up -d` alone reuses locally cached images and may leave the UI version out of sync with the release you downloaded.
|
||||
|
||||
### 🔧 Optional Services (Docker Compose Profiles)
|
||||
|
||||
Add `--profile` flags to enable additional components. Multiple profiles can be combined:
|
||||
|
||||
| Profile | Description | Command |
|
||||
|---------|-------------|---------|
|
||||
| _(default)_ | Core services | `docker compose pull && docker compose up -d` |
|
||||
| `full` | All features | `docker compose --profile full pull && docker compose --profile full up -d` |
|
||||
| `neo4j` | Knowledge Graph (Neo4j) | `docker compose --profile neo4j pull && docker compose --profile neo4j up -d` |
|
||||
| `minio` | Object Storage (MinIO) | `docker compose --profile minio pull && docker compose --profile minio up -d` |
|
||||
| `langfuse` | Tracing (Langfuse) | `docker compose --profile langfuse pull && docker compose --profile langfuse up -d` |
|
||||
|
||||
Combine profiles: `docker compose --profile neo4j --profile minio pull && docker compose --profile neo4j --profile minio up -d`
|
||||
|
||||
Stop services: `docker compose down`
|
||||
|
||||
### 🌐 Service URLs
|
||||
|
||||
| Service | URL |
|
||||
|---------|-----|
|
||||
| Web UI | `http://localhost` |
|
||||
| Backend API | `http://localhost:8080` |
|
||||
| Langfuse Tracing | `http://localhost:3000` |
|
||||
|
||||
## MCP Server
|
||||
|
||||
Please refer to the [MCP Configuration Guide](./mcp-server/MCP_CONFIG.md) for the necessary setup.
|
||||
|
||||
## 🔌 Using WeChat Dialog Open Platform
|
||||
|
||||
WeKnora serves as the core technology framework for the [WeChat Dialog Open Platform](https://chatbot.weixin.qq.com), providing a more convenient usage approach:
|
||||
|
||||
- **Zero-code Deployment**: Simply upload knowledge to quickly deploy intelligent Q&A services within the WeChat ecosystem, achieving an "ask and answer" experience
|
||||
- **Efficient Question Management**: Support for categorized management of high-frequency questions, with rich data tools to ensure accurate, reliable, and easily maintainable answers
|
||||
- **WeChat Ecosystem Integration**: Through the WeChat Dialog Open Platform, WeKnora's intelligent Q&A capabilities can be seamlessly integrated into WeChat Official Accounts, Mini Programs, and other WeChat scenarios, enhancing user interaction experiences
|
||||
|
||||
|
||||
|
||||
## 📘 API Reference
|
||||
|
||||
**Official product documentation**: [`website-docs/`](./website-docs/README.md) — the complete documentation set organized as Getting Started → Architecture → Features → API → Clients → Development, covering ~360 API endpoints, ~150 environment variables, and 9 extension points. The directory is also a VitePress site: run `cd website-docs && npm install && npm run dev` to preview locally, or deploy it standalone with the `Dockerfile` inside.
|
||||
|
||||
Troubleshooting FAQ: [Troubleshooting FAQ](./docs/QA.md)
|
||||
|
||||
Detailed API documentation is available at: [API Docs](./docs/api/README.md)
|
||||
|
||||
Product plans and upcoming features: [Roadmap](./docs/ROADMAP.md)
|
||||
|
||||
## 🧭 Developer Guide
|
||||
|
||||
### ⚡ Fast Development Mode (Recommended)
|
||||
|
||||
If you need to frequently modify code, **you don't need to rebuild Docker images every time**! Use fast development mode:
|
||||
|
||||
```bash
|
||||
# Start infrastructure
|
||||
make dev-start
|
||||
|
||||
# Start backend (new terminal)
|
||||
make dev-app
|
||||
|
||||
# Start frontend (new terminal)
|
||||
make dev-frontend
|
||||
```
|
||||
|
||||
**Development Advantages:**
|
||||
- ✅ Frontend modifications auto hot-reload (no restart needed)
|
||||
- ✅ Backend modifications quick restart (5-10 seconds, supports Air hot-reload)
|
||||
- ✅ No need to rebuild Docker images
|
||||
- ✅ Support IDE breakpoint debugging
|
||||
|
||||
**Detailed Documentation:** [Development Environment Quick Start](./docs/开发指南.md)
|
||||
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Welcome to submit [Issues](https://github.com/Tencent/WeKnora/issues) or Pull Requests.
|
||||
|
||||
**Process:** Fork → Create branch → Commit changes → Open PR
|
||||
|
||||
**Standards:** Format code with `gofmt`, follow [Conventional Commits](https://www.conventionalcommits.org/) (`feat:` / `fix:` / `docs:` / `test:` / `refactor:`)
|
||||
|
||||
### Validation
|
||||
|
||||
For a focused PR, validate the changed scope first:
|
||||
|
||||
```bash
|
||||
git fetch origin main
|
||||
git diff --check origin/main...HEAD
|
||||
golangci-lint run --new-from-rev=origin/main ./...
|
||||
go test ./path/to/changed/package -count=1
|
||||
```
|
||||
|
||||
Run `gofmt` on changed Go files before committing. For frontend changes, run the relevant tests from `frontend/` and use `npm run type-check` when the change affects TypeScript or Vue components.
|
||||
|
||||
The full maintainer gate remains:
|
||||
|
||||
```bash
|
||||
make fmt
|
||||
make lint
|
||||
make test
|
||||
```
|
||||
|
||||
`make fmt` formats the entire Go repository, so run it only with a clean worktree and review the resulting diff. Some full-suite tests require local infrastructure or service configuration. If a full check fails for an unrelated baseline or environment reason, include the exact command and failure in the PR while still providing passing targeted tests for your change.
|
||||
|
||||
## 🔒 Security Notice
|
||||
|
||||
**Important:** Starting from v0.1.3, WeKnora includes login authentication functionality to enhance system security. For production deployments, we strongly recommend:
|
||||
|
||||
- Deploy WeKnora services in internal/private network environments rather than public internet
|
||||
- Avoid exposing the service directly to public networks to prevent potential information leakage
|
||||
- Configure proper firewall rules and access controls for your deployment environment
|
||||
- Regularly update to the latest version for security patches and improvements
|
||||
|
||||
## 👥 Contributors
|
||||
|
||||
Thanks to these excellent contributors:
|
||||
|
||||
[](https://github.com/Tencent/WeKnora/graphs/contributors)
|
||||
|
||||
## 📄 License
|
||||
|
||||
This project is licensed under the [MIT License](./LICENSE).
|
||||
You are free to use, modify, and distribute the code with proper attribution.
|
||||
+366
@@ -0,0 +1,366 @@
|
||||
<p align="center">
|
||||
<picture>
|
||||
<img src="./docs/images/logo.png" alt="WeKnora Logo" height="120"/>
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<picture>
|
||||
<a href="https://trendshift.io/repositories/15289" target="_blank">
|
||||
<img src="https://trendshift.io/api/badge/repositories/15289" alt="Tencent/WeKnora | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/>
|
||||
</a>
|
||||
</picture>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://weknora.weixin.qq.com" target="_blank">
|
||||
<img alt="官方网站" src="https://img.shields.io/badge/官方网站-WeKnora-4e6b99">
|
||||
</a>
|
||||
<a href="https://chatbot.weixin.qq.com" target="_blank">
|
||||
<img alt="微信对话开放平台" src="https://img.shields.io/badge/微信对话开放平台-5ac725">
|
||||
</a>
|
||||
<a href="https://chromewebstore.google.com/detail/jpemjbopikggjlmikmclgbmkhhopjdgd" target="_blank">
|
||||
<img alt="Chrome 插件" src="https://img.shields.io/badge/Chrome 插件-WeKnora-4285F4">
|
||||
</a>
|
||||
<a href="https://clawhub.ai/lyingbug/weknora" target="_blank">
|
||||
<img alt="ClawHub Skill" src="https://img.shields.io/badge/ClawHub Skill-WeKnora-ff6b35">
|
||||
</a>
|
||||
<a href="https://www.npmjs.com/package/@wxg-prc-cpg/dsh-weknora" target="_blank">
|
||||
<img alt="npm @wxg-prc-cpg/dsh-weknora" src="https://img.shields.io/npm/v/@wxg-prc-cpg/dsh-weknora?label=dsh-weknora">
|
||||
</a>
|
||||
<a href="https://github.com/Tencent/WeKnora/blob/main/LICENSE">
|
||||
<img src="https://img.shields.io/badge/License-MIT-ffffff?labelColor=d4eaf7&color=2e6cc4" alt="License">
|
||||
</a>
|
||||
<a href="./CHANGELOG.md">
|
||||
<img alt="版本" src="https://img.shields.io/badge/version-0.8.0-2e6cc4?labelColor=d4eaf7">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
| <a href="./README.md"><b>English</b></a> | <b>简体中文</b> | <a href="./README_JA.md"><b>日本語</b></a> | <a href="./README_KO.md"><b>한국어</b></a> |
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<h4 align="center">
|
||||
|
||||
[项目介绍](#-项目介绍) • [架构设计](#-架构设计) • [核心特性](#-核心特性) • [快速开始](#-快速开始) • [文档](#-文档) • [开发指南](#-开发指南)
|
||||
|
||||
</h4>
|
||||
</p>
|
||||
|
||||
# 💡 WeKnora — 让文档活起来:RAG、Agent 推理与自动 Wiki 一体化的知识框架
|
||||
|
||||
## 📌 项目介绍
|
||||
|
||||
**[WeKnora(维娜拉)](https://weknora.weixin.qq.com)** 是一款开源的、基于大语言模型(LLM)的知识管理框架,专为企业级文档理解、语义检索与智能推理场景打造。
|
||||
|
||||
框架围绕三大核心能力构建:**RAG 快速问答**适合日常知识查询,**ReAct Agent 智能推理**自主编排知识检索、MCP 工具、**技能目录**、会话级 **Docker / E2B / Cube 沙箱**与网络搜索完成复杂多步任务,全新的 **Wiki 模式**则让 Agent 从原始文档中自治生成相互链接的 Markdown 知识库与可视化知识图谱,并支持人工编辑、版本历史与一键回滚。**跨会话长期记忆**会记住你是谁、你常问什么。知识加工环节也完全可控:**树形文件夹**保留上传目录结构,**分块编辑与版本历史**让检索片段可以像文档一样被修改、比对与回滚。结合多源数据接入(飞书知识库 / 飞书云盘 / GitLab / 腾讯 IMA / Notion / 语雀 / RSS,更多持续接入中)、**网站嵌入 Widget** 将智能体发布到外部站点、**权限范围 API Key 与 Principal 模型**面向程序化集成、**每空间多实例存储后端**灵活编排数据落地、二十余家主流模型厂商集成(含 LiteLLM)、Langfuse 全链路可观测性与**运行时任务队列面板 + Worker 池治理**、**企业级多空间 RBAC(四级角色矩阵 + 资源归属 + 空间审计日志)**,以及完全可私有化部署的模块化架构,WeKnora 帮助团队把分散文档沉淀为可查询、可推理、可持续演进的专属知识资产。
|
||||
|
||||
框架支持从飞书、GitLab、腾讯 IMA、Notion 及语雀等外部平台自动同步知识(更多数据源持续接入中),覆盖 PDF、Word、图片、Excel、XMind 等十余种文档格式,并可通过企业微信、飞书、Slack、Telegram 等 IM 频道直接提供问答服务。模型层面兼容 OpenAI、DeepSeek、Qwen(阿里云)、智谱、混元、Gemini、MiniMax、NVIDIA、LiteLLM、Ollama 等主流厂商。Office 文档可由 **anydoc** 在 Go 进程内解析。全流程模块化设计,大模型、向量数据库、存储等组件均可灵活替换,支持本地与私有云部署,数据完全自主可控。WeKnora 还无缝集成了 **Langfuse**,为 Agent 运行、Token 使用及任务流水线提供了全面的可观测性追踪。
|
||||
|
||||
## ✨ 最新更新
|
||||
|
||||
- **v0.8.0** —— **技能沙箱运行时**(会话级常驻 Docker / E2B / Cube 后端,按空间配置网络策略;移除 Local 宿主机进程后端;Docker 需显式开启);**空间技能目录**(从 ClawHub / SkillHub / git / zip 安装,按沙箱快照、实时进度、文件浏览/编辑、个人与空间环境变量);**跨会话长期记忆**(profile / preference / fact / task / interest,自动抽取需确认,`search_memory`);进程内 **anydoc** Office 解析;官方 **DeepSeek Harness 插件** `@wxg-prc-cpg/dsh-weknora`;GitLab 与腾讯 IMA 数据源;LiteLLM;Exa 与 Metaso 网络搜索;XMind 解析;对话产物、问题大纲与时间戳;上下文压缩与供应商 Prompt Cache 标记。另有 OIDC JWKS 验签、可选复杂密码、文档自动打标签,以及大范围沙箱/安全加固。详见 [`CHANGELOG.md`](./CHANGELOG.md)。
|
||||
- **v0.7.2** —— 上线**官方产品文档站**(VitePress,六大板块约 50 篇,覆盖约 360 个 API 端点与约 150 个环境变量,含独立 Docker/Nginx 部署、快速上手样例数据与本地 MCP demo);**知识库文件夹树**(文件夹路径独立入库,可像文件管理器一样浏览、重命名与重新归档);**分块编辑与版本历史**(可视化编辑检索分块、逐版本 diff 与回滚、自动重建索引、文档自定义元数据);**Wiki 页面版本历史**(快照 + 行级 diff + 一键回滚 + 浏览器内手动编辑);**API 文件直链模式** `resource_urls=public` / `RESOURCE_URL_MODE`(第三方 App 无需二次调用鉴权代理即可加载图片与文件);**飞书云盘数据源**与 docx blocks 逐类型下钻同步;文档批量打标签;**MCP Server 1.1.x**(迁移到 mcp 2.x 高级 API,官方 PyPI 包 `tencent-weknora-mcp`,新增 `create_knowledge_from_text` 与 `list_shared_knowledge_bases`,共 29 个工具);AWS S3 默认凭据链(IAM Role / IRSA);本地 HTML 上传解析;QQBot Markdown 回复;新增 app / frontend / docreader / mcp-server 的 PR CI 检查。另有 router 与 modelcontext 大规模重构、重排与分块质量优化,以及大量稳定性修复。详见 [`CHANGELOG.md`](./CHANGELOG.md)。
|
||||
- **v0.7.1** —— 新增**云之家 IM 集成**(WebSocket + 图片消息 + Markdown 回复);**火山引擎 Rerank** 供应商(自动分批请求)与**智谱 AI 网络搜索**供应商;**平台级 API Key**,用于控制面自动化(空间管理、系统设置、运行时队列、审计日志);**按知识库的活动审计追踪**;FAQ 管理增强(筛选、打标签、导出、导入结果追踪);**Langfuse OTLP/OTel 追踪**迁移,支持 W3C traceparent 跨服务传播;对话头部操作栏,支持一键 **Markdown 导出**,并在引用抽屉中展示 Wiki 工具结果;Prompt 缓存可观测性;会话渠道治理(IM/嵌入/API 会话按管理员范围隔离);飞书大型 Wiki 同步韧性增强;移除旧版 Neo4j 会话记忆依赖。另有大范围的 slug 完整性、SSRF 传输与状态同步加固。详见 [`CHANGELOG.md`](./CHANGELOG.md)。
|
||||
- **v0.7.0** —— 细粒度**权限范围 API Key 与 Principal 模型**(能力级授权 + 按 KB 限制 + API 集成调试台);**运行时任务队列可观测面板与 Worker 池治理**(分阶段独立池 + 按模型并发治理 + 失败任务排查/重试);**多实例存储后端**(每空间多存储实例、按 KB 绑定、默认实例);**会话级临时附件**(图片/文档异步解析 + 合并限额);推荐问题与追问;稳定资源注册表与 LLM 上下文别名压缩;`@Skill / @MCP` 提及范围化 Agent 运行时;会话内 MCP OAuth 授权;QQBot 与 Lark(飞书国际版)IM 集成;Redis TLS;Requesty 模型厂商 + Keenable 网络搜索;无空间预置与受控自助创建工作区;管理员密码重置;知识库复制流程;`weknora` CLI v0.10。同时完成大范围安全加固(SSRF、密钥脱敏、SQL 校验、越权)。详见 [`CHANGELOG.md`](./CHANGELOG.md)。
|
||||
- **v0.6.3** —— 网站嵌入 Widget 与发布集成中心(安全模式 Token 交换 + 限流);对话体验全面革新(引用浮层、RAG 流水线进度、流式 Markdown);文档多标签与批量重新解析;Wiki 文件夹与层级导航;RSS 数据源;MCP OAuth2;EPUB / MHTML 解析;Agent 模型就绪校验;模型调试器;会话来源筛选;工作区删除 UI。详见 [`CHANGELOG.md`](./CHANGELOG.md)。
|
||||
- **v0.6.2** —— 按批次解析配置(`process_config`)+ 上传确认对话框;文档重新解析(reparse)支持覆盖配置;`weknora` CLI v0.9(内置 Agent Skills、`session stop`、auth/profile 统一);知识库框选多选;pgvector 1024 维 HNSW 索引;对话资源 Store 重构;仅保留 Langfuse 追踪(移除 Jaeger)。详见 [`CHANGELOG.md`](./CHANGELOG.md)。
|
||||
- **v0.6.1** —— 文档解析追踪时间线(Langfuse 风格 Span 树,逐阶段进度展示 + 解析中止);OpenSearch 向量库驱动;YAML 声明式内置模型配置;系统管理员与统一平台设置 + 审计日志;新用户引导;设置页 UI 重构;`weknora` CLI v0.7 / v0.8(Agent 优先线协议、NDJSON、`--dry-run`);OpenDataLoader 与 PaddleOCR-VL 解析引擎;MCP Server 多传输(stdio / SSE / HTTP);按模型的思考模式配置;腾讯云 LKEAP 重排 + 原生 Gemini Embedding + MiniMax-M3。详见 [`CHANGELOG.md`](./CHANGELOG.md)。
|
||||
- **v0.6.0** —— 空间 RBAC(四级角色矩阵 `Owner` / `Admin` / `Contributor` / `Viewer` + 按 KB 归属 + 每空间审计日志)、空间成员管理与多工作区 UX、自助创建工作区;`weknora` CLI v0.4 正式版 + `mcp serve`;KB 检索跨向量库扇出;MCP / 数据源凭据 AES-256-GCM 加密 + docreader gRPC TLS + Token;新增智谱 Embedding 与华为云 OBS;服务端用户偏好;Go 1.26.0。详见 [`docs/RBAC说明.md`](./docs/RBAC说明.md) 与 [`CHANGELOG.md`](./CHANGELOG.md)。
|
||||
- **v0.5.2** —— Wiki 入库支撑万级文档知识库(任务队列 + 死信队列);MCP 工具人机审批;Anthropic / Apache Doris / 腾讯云 VectorDB / 金山云 KS3 / SearXNG 后端;自适应三层分块 + 实时调试面板;全局 ⌘K 命令面板;语雀连接器 + 微信小程序;`weknora` CLI 早期版本。
|
||||
- **v0.5.1** —— 知识库批量管理;空间级 IM 频道总览;会话搜索 + 用户级置顶;模型 / 网页搜索 / MCP 统一卡片化设置;按 Agent LLM 调用超时;桌面端空间切换。
|
||||
- **v0.5.0** —— Wiki 模式正式版 —— Agent 从原始文档自治生成结构化、相互链接的 Markdown Wiki 页面及知识图谱;Wiki 浏览器 + 可视化图谱。
|
||||
- **v0.4.0** —— WeKnora Cloud(托管模型 + 解析);Chrome 插件;ClawHub Skill;微信 IM;附件处理;Azure OpenAI / 阿里云 OSS;Notion 连接器;百度 + Ollama 网页搜索;VectorStore 管理。
|
||||
- **v0.3.6** —— ASR 语音;飞书数据源自动同步;OIDC;IM 引用回复 + 线程会话;文档自动摘要;Tavily 搜索;并行工具调用;Agent @提及范围限制。
|
||||
- **v0.3.5** —— Telegram / 钉钉 / Mattermost IM;IM 斜杠命令 + QA 队列;推荐问题;VLM 自动描述 MCP 返回图片;Novita AI;来源频道标记。
|
||||
- **v0.3.4** —— 企业微信 / 飞书 / Slack IM;多模态图片;NVIDIA 模型 API;Weaviate;AWS S3;AES-256-GCM API Key 加密;内置 MCP 服务;混合检索优化;`final_answer` 工具。
|
||||
- **v0.3.3** —— 父子分块;知识库置顶;兜底回复;Rerank 段落清洗;存储桶自动创建;Milvus。
|
||||
- **v0.3.2** —— 知识搜索入口;按来源配置解析与存储引擎;本地存储图片渲染;文档预览;火山引擎 TOS;Mermaid 渲染;对话批量管理;记忆图谱预览。
|
||||
- **v0.3.0** —— 共享空间;Agent Skills + 沙盒执行;自定义 Agent;数据分析 Agent;思考模式;Bing / Google 搜索;API Key 认证;Helm Chart;韩语 i18n;Qdrant。
|
||||
- **v0.2.0** —— Agent 模式(ReACT);多类型知识库(FAQ + 文档);对话策略配置;DuckDuckGo 网页搜索;MCP 工具集成;全新 UI + Agent 模式切换;MQ 异步任务管理。
|
||||
|
||||
|
||||
## 📱 功能展示
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td colspan="2" align="center"><b>🛠️ 沙箱技能对话 · 生成并预览 Word</b><br/><img src="./docs/images/skill-sandbox-chat.png" alt="沙箱技能对话:生成并预览 Word 文档" width="100%"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" align="center"><b>📦 技能目录 · 安装到 E2B 沙箱</b><br/><img src="./docs/images/skill-catalog.png" alt="空间技能目录:docx / pptx / pdf 已安装到 E2B" width="100%"></td>
|
||||
<td width="50%" align="center"><b>🤖 Agent 模式 · 检索、读技能、写入沙箱文件</b><br/><img src="./docs/images/agent-qa.png" alt="Agent 检索知识库、读取 docx 技能并写入沙箱脚本" width="100%"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" align="center"><b>💬 智能问答对话</b><br/><img src="./docs/images/qa.png" alt="智能问答对话" width="100%"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" align="center"><b>📖 Wiki 浏览器</b><br/><img src="./docs/images/wiki-browser.png" alt="Wiki 浏览器" width="100%"></td>
|
||||
<td width="50%" align="center"><b>🕸️ Wiki 知识图谱</b><br/><img src="./docs/images/wiki-graph.png" alt="Wiki 知识图谱" width="100%"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" align="center"><b>🕘 Wiki 页面版本历史与回滚</b><br/><img src="./docs/images/wiki-revision-history.png" alt="Wiki 页面版本历史与回滚" width="100%"></td>
|
||||
<td width="50%" align="center"><b>✂️ 分块编辑与版本历史</b><br/><img src="./docs/images/kb-chunk-edit.png" alt="分块编辑与版本历史" width="100%"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" align="center"><b>📁 文件夹树与批量操作</b><br/><img src="./docs/images/kb-document-list.png" alt="知识库文件夹树与批量操作" width="100%"></td>
|
||||
<td width="50%" align="center"><b>🔭 监控可观测性 · Langfuse Tracing</b><br/><img src="./docs/images/langfuse.png" alt="Langfuse Tracing" width="100%"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 🏗️ 架构设计
|
||||
|
||||

|
||||
|
||||
从文档解析、向量化、检索到大模型推理,全流程模块化解耦,组件可灵活替换与扩展。支持本地 / 私有云部署,数据完全自主可控,零门槛 Web UI 快速上手。
|
||||
|
||||
## 🧩 功能概览
|
||||
|
||||
**智能对话**
|
||||
|
||||
| 能力 | 详情 |
|
||||
|------|------|
|
||||
| 智能推理 | ReACT 渐进式多步推理,自主编排知识检索、MCP 工具、技能沙箱与网络搜索 |
|
||||
| 快速问答 | 基于知识库的 RAG 问答,快速准确地回答问题 |
|
||||
| Wiki 模式 | Agent 驱动从原始文档中自动生成并维护结构化、相互链接的 Markdown Wiki 知识页面;支持浏览器内人工编辑、页面版本历史、行级 diff 与一键回滚 |
|
||||
| 技能目录与沙箱 | 空间技能目录(ClawHub / SkillHub / git / zip)安装到会话级 Docker / E2B / Cube 沙箱;`shell_exec`、文件工具、产物收集、按配置网络策略;已移除 Local 宿主机进程后端 |
|
||||
| 长期记忆 | 跨会话记忆(profile / preference / fact / task / interest),支持自动抽取、用户确认与按需 `search_memory` |
|
||||
| 工具调用 | 内置工具、MCP 工具(含 OAuth2 远程服务、会话内 OAuth 授权)、网络搜索;支持 `@Skill / @MCP` 提及以按轮次范围化 Agent 运行时 |
|
||||
| 对话策略 | 在线 Prompt 编辑、检索阈值调节、多轮上下文感知、按 Agent 引用输出开关 |
|
||||
| 推荐问题 | 基于知识库内容自动生成推荐问题与答后追问 |
|
||||
| 临时附件 | 会话级临时上传图片 / 文档,异步解析后用于一次性问答,支持图片与附件合并限额 |
|
||||
| 引用与 RAG 进度 | 对话内引用浮层与引用抽屉(区分网络 / 知识库来源)、统一 Markdown 渲染、RAG 流水线分阶段进度展示 |
|
||||
| 会话管理 | 侧边栏按来源(Web / IM / 嵌入)筛选与分组会话,支持会话标题内联重命名 |
|
||||
|
||||
**知识管理**
|
||||
|
||||
| 能力 | 详情 |
|
||||
|------|------|
|
||||
| 知识库类型 | FAQ / 文档 / Wiki,支持文件夹导入、URL 导入、多标签管理、在线录入 |
|
||||
| 文件夹树 | 文件夹上传保留原始目录结构,侧栏树形浏览、重命名文件夹、把文档重新归档到其他文件夹 |
|
||||
| 分块编辑与版本历史 | 在界面直接编辑检索分块,保留逐版本快照,支持 diff 与一键回滚,编辑后自动重建索引;生成问题可增删改与重新生成;支持文档自定义元数据 |
|
||||
| 按批次解析配置 | 上传确认对话框或 `process_config` API 覆盖解析引擎、分块、多模态(VLM / ASR)、图谱抽取与问题生成;支持 reparse 时调整配置 |
|
||||
| 批量重新解析 | 一次为多篇文档重新排队解析,可携带批次级 `process_config` |
|
||||
| 数据源导入 | 飞书知识库 / 飞书云盘 / Lark / GitLab / 腾讯 IMA / Notion / 语雀 / RSS 订阅自动同步(更多数据源开发中),支持增量与全量同步 |
|
||||
| 文档格式 | PDF / Word / Txt / Markdown / HTML / EPUB / MHTML / 图片 / CSV / Excel / PPT / JSON / XMind |
|
||||
| 自动打标签 | 解析完成后从知识库已有标签中选择匹配项增量关联,不创建新标签、不覆盖人工标签 |
|
||||
| 检索策略 | BM25 稀疏召回 / Dense 稠密召回 / GraphRAG 图谱增强 / 父子分块 / pgvector HNSW 加速(1024 维)/ 多维度索引 |
|
||||
| 批量选择与打标签 | 知识库文档列表支持框选(marquee)多选,可批量重新解析、批量打标签(自动预选公共标签) |
|
||||
| 端到端测试 | 检索+生成全链路可视化,评估召回命中率、BLEU / ROUGE 等指标 |
|
||||
|
||||
**集成与扩展**
|
||||
|
||||
| 能力 | 详情 |
|
||||
|------|------|
|
||||
| 模型厂商 | OpenAI / Azure OpenAI / Anthropic(Claude)/ DeepSeek / Qwen(阿里云)/ 智谱 / 混元 / 豆包(火山引擎)/ Gemini / MiniMax / NVIDIA / Novita AI / SiliconFlow / OpenRouter / Requesty / LiteLLM / Ollama |
|
||||
| 向量数据库 | PostgreSQL (pgvector) / Elasticsearch / OpenSearch / Milvus / Weaviate / Qdrant / Apache Doris / 腾讯云 VectorDB |
|
||||
| Embedding | Ollama / BGE / GTE / 智谱 / OpenAI 兼容接口 |
|
||||
| 对象存储 | 本地 / 腾讯云COS / 火山引擎 TOS / MinIO / AWS S3(支持 IAM Role / IRSA 默认凭据链)/ 阿里云 OSS / 金山云 KS3 / 华为云 OBS;支持**每空间多实例存储后端**,不同知识库可绑定不同实例并设置默认实例 |
|
||||
| IM 集成 | 企业微信 / 飞书 / Lark(飞书国际版)/ QQBot / Slack / Telegram / 钉钉 / Mattermost / 微信 / 云之家 |
|
||||
| 网站嵌入 | 通过嵌入 Widget 发布智能体,支持域名白名单、限流与安全模式 Token 交换 |
|
||||
| 网络搜索 | DuckDuckGo / Bing / Google / Tavily / Baidu / Ollama / SearXNG / Keenable / 智谱 AI / Exa / Metaso |
|
||||
| API 集成 | 权限范围 API Key(能力级授权 + 按 KB 限制 + 节流的 last_used 追踪)与 API 集成调试台;MCP OAuth 与嵌入会话按 Principal 隔离;`resource_urls=public` 直接返回可加载的文件/图片直链,免去二次鉴权代理调用 |
|
||||
| MCP Server | 官方 PyPI 包 `tencent-weknora-mcp`,29 个工具,支持 stdio / SSE / HTTP 三种传输 |
|
||||
|
||||
|
||||
**平台能力**
|
||||
|
||||
| 能力 | 详情 |
|
||||
|------|------|
|
||||
| 部署 | 本地 / Docker / Kubernetes (Helm),支持私有化离线部署 |
|
||||
| 界面 | Web UI / RESTful API / 命令行(`weknora`)/ Chrome Extension / 网站嵌入 Widget / 微信小程序 |
|
||||
| 权限控制 | 空间 RBAC 四级角色矩阵(Owner / Admin / Contributor / Viewer),按知识库的资源归属,每空间审计日志,invite-only 准入,无空间预置与受控自助创建工作区,管理员密码重置(会话吊销),跨空间超级管理员,权限范围 API Key |
|
||||
| 安全 | API Key 与 MCP / 数据源凭据 AES-256-GCM 静态加密、支持平滑密钥轮换;app ↔ docreader gRPC TLS + Token;Redis TLS;防 SSRF HTTP 客户端(覆盖数据源、URL 导入、重定向链等);密钥响应脱敏;技能沙箱隔离(Docker 需开启 / E2B / Cube)与按配置网络策略;OIDC ID Token JWKS 验签;可选复杂密码策略 |
|
||||
| 可观测性 | 集成 Langfuse(唯一追踪后端)以追踪 ReAct 循环、Token 消耗、工具调用和任务流水线;内置 Langfuse 风格的文档解析追踪时间线,逐阶段展示解析进度;系统管理员运行时任务队列面板(队列深度、按模型并发、失败任务排查与手动重试) |
|
||||
| 任务管理 | MQ 异步任务,分阶段独立 Worker 池治理(core / 后处理 / enrichment / maintenance + 弹性共享池,Wiki 独立池)与按模型后台并发治理;版本升级自动数据库迁移 |
|
||||
| 模型管理 | 集中配置,YAML 声明式内置模型配置,知识库级别模型选择,按模型思考模式与 Embedding 维度覆盖,交互式模型调试器,多空间共享内置模型,WeKnora Cloud 托管模型与文档解析 |
|
||||
|
||||
## 🧩 Chrome 插件
|
||||
|
||||
[**WeKnora Chrome 插件**](https://chromewebstore.google.com/detail/jpemjbopikggjlmikmclgbmkhhopjdgd)支持在浏览器中直接将网页内容采集到 WeKnora 知识库。选中文本、图片或整个页面,一键保存为知识条目,无需复制粘贴或手动上传文件。
|
||||
|
||||
|
||||
## 📱 微信小程序
|
||||
|
||||
[**WeKnora 微信小程序**](./miniprogram/README.md) 提供轻量移动端客户端,支持配置 WeKnora API、选择知识库、导入 URL,并在微信内向知识库提问。
|
||||
|
||||
|
||||
## 🦞 ClawHub Skill
|
||||
|
||||
[**WeKnora ClawHub Skill**](https://clawhub.ai/lyingbug/weknora) 是 WeKnora 发布在 ClawHub 平台上的技能。安装后,可通过 WeKnora REST API 上传文档(文件 / URL / Markdown)、执行混合检索(向量 + 关键词)以及管理知识条目。
|
||||
|
||||
- **文档导入** — 通过 Agent 上传文件、导入网页或写入 Markdown 知识
|
||||
- **混合检索** — 在单个或多个知识库中进行向量 + 关键词混合搜索
|
||||
- **知识管理** — 以编程方式浏览、编辑和删除知识条目
|
||||
|
||||
## 🐋 DeepSeek Harness 插件
|
||||
|
||||
[**`@wxg-prc-cpg/dsh-weknora`**](https://www.npmjs.com/package/@wxg-prc-cpg/dsh-weknora) 是官方的 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(`dsh`)插件([说明](./packages/dsh-weknora/README_CN.md))。harness 自身不带任何检索、向量或知识库能力,这个插件把你的文档接进编码 Agent:`dsh plugin --profile web add @wxg-prc-cpg/dsh-weknora`,指向一个部署,Agent 的工具集里就会出现四个只读工具。
|
||||
|
||||
- **`weknora_search`** — 混合检索,返回原文片段,每条都带可复用的 `knowledge_id`
|
||||
- **`weknora_read_document`** — 把单个文档的分块按序拼回正文,支持翻页
|
||||
- **`weknora_ask`** — WeKnora 自己带引用的成稿答案,走 RAG 或 ReAct 流水线
|
||||
- **`weknora_list_knowledge_bases`** — 知识库名称与 id,便于 Agent 自己缩小检索范围
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 🛠 环境要求
|
||||
|
||||
- [Docker](https://www.docker.com/) & [Docker Compose](https://docs.docker.com/compose/)
|
||||
- [Git](https://git-scm.com/)
|
||||
|
||||
### 📦 安装与启动
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Tencent/WeKnora.git
|
||||
cd WeKnora
|
||||
cp .env.example .env # 按需编辑 .env,详见文件内注释
|
||||
docker compose pull # 拉取最新镜像
|
||||
docker compose up -d # 启动核心服务
|
||||
```
|
||||
|
||||
启动成功后访问 **http://localhost** 即可使用。
|
||||
|
||||
> 如需使用本地 Ollama 模型,请先运行 `ollama serve > /dev/null 2>&1 &`
|
||||
|
||||
### 🔄 版本升级
|
||||
|
||||
若已有部署并下载了更新的 release:
|
||||
|
||||
```bash
|
||||
# 在 .env 中将 WEKNORA_VERSION 设为目标版本(如 0.7.0),或保持 latest
|
||||
docker compose pull # 拉取与 WEKNORA_VERSION 匹配的镜像
|
||||
docker compose up -d # 用新镜像重建容器
|
||||
```
|
||||
|
||||
> 仅执行 `docker compose up -d` 会复用本地缓存镜像,可能导致 Web UI 显示版本与下载的 release 不一致。
|
||||
|
||||
### 🔧 可选服务(Docker Compose Profile)
|
||||
|
||||
按需添加 `--profile` 启动额外组件,多个 profile 可叠加使用:
|
||||
|
||||
| Profile | 说明 | 启动命令 |
|
||||
|---------|------|----------|
|
||||
| _(默认)_ | 核心服务 | `docker compose pull && docker compose up -d` |
|
||||
| `full` | 全部功能 | `docker compose --profile full pull && docker compose --profile full up -d` |
|
||||
| `neo4j` | 知识图谱 (Neo4j) | `docker compose --profile neo4j pull && docker compose --profile neo4j up -d` |
|
||||
| `minio` | 对象存储 (MinIO) | `docker compose --profile minio pull && docker compose --profile minio up -d` |
|
||||
| `langfuse` | 链路追踪 (Langfuse) | `docker compose --profile langfuse pull && docker compose --profile langfuse up -d` |
|
||||
|
||||
组合示例:`docker compose --profile neo4j --profile minio pull && docker compose --profile neo4j --profile minio up -d`
|
||||
|
||||
停止服务:`docker compose down`
|
||||
|
||||
### 🌐 服务地址
|
||||
|
||||
| 服务 | 地址 |
|
||||
|------|------|
|
||||
| Web UI | `http://localhost` |
|
||||
| 后端 API | `http://localhost:8080` |
|
||||
| 链路追踪 (Langfuse) | `http://localhost:3000` |
|
||||
|
||||
## 文档知识图谱
|
||||
|
||||
WeKnora 支持将文档转化为知识图谱,展示文档中不同段落之间的关联关系。开启知识图谱功能后,系统会分析并构建文档内部的语义关联网络,不仅帮助用户理解文档内容,还为索引和检索提供结构化支撑,提升检索结果的相关性和广度。
|
||||
|
||||
具体配置请参考 [知识图谱配置说明](./docs/KnowledgeGraph.md) 进行相关配置。
|
||||
|
||||
## 配套MCP服务器
|
||||
|
||||
请参考 [MCP配置说明](./mcp-server/MCP_CONFIG.md) 进行相关配置。
|
||||
|
||||
## 🔌 使用微信对话开放平台
|
||||
|
||||
WeKnora 作为[微信对话开放平台](https://chatbot.weixin.qq.com)的核心技术框架,提供更简便的使用方式:
|
||||
|
||||
- **零代码部署**:只需上传知识,即可在微信生态中快速部署智能问答服务,实现"即问即答"的体验
|
||||
- **高效问题管理**:支持高频问题的独立分类管理,提供丰富的数据工具,确保回答精准可靠且易于维护
|
||||
- **微信生态覆盖**:通过微信对话开放平台,WeKnora 的智能问答能力可无缝集成到公众号、小程序等微信场景中,提升用户交互体验
|
||||
|
||||
|
||||
## 📘 文档
|
||||
|
||||
**官方产品文档**:[`website-docs/`](./website-docs/README.md) —— 按「入门 → 架构 → 功能 → API → 客户端 → 开发」六个板块组织的完整文档,覆盖约 360 个 API 端点、约 150 个环境变量与 9 大扩展点。本目录同时是一个 VitePress 站点,`cd website-docs && npm install && npm run dev` 即可本地预览,也可用目录内的 `Dockerfile` 独立部署。
|
||||
|
||||
常见问题排查:[常见问题排查](./docs/QA.md)
|
||||
|
||||
详细接口说明请参考:[API 文档](./docs/api/README.md)
|
||||
|
||||
产品规划与计划:[路线图 (Roadmap)](./docs/ROADMAP.md)
|
||||
|
||||
## 🧭 开发指南
|
||||
|
||||
### ⚡ 快速开发模式(推荐)
|
||||
|
||||
如果你需要频繁修改代码,**不需要每次重新构建 Docker 镜像**!使用快速开发模式:
|
||||
|
||||
```bash
|
||||
# 启动基础设施
|
||||
make dev-start
|
||||
|
||||
# 启动后端(新终端)
|
||||
make dev-app
|
||||
|
||||
# 启动前端(新终端)
|
||||
make dev-frontend
|
||||
```
|
||||
|
||||
**开发优势:**
|
||||
|
||||
- ✅ 前端修改自动热重载(无需重启)
|
||||
- ✅ 后端修改快速重启(5-10秒,支持 Air 热重载)
|
||||
- ✅ 无需重新构建 Docker 镜像
|
||||
- ✅ 支持 IDE 断点调试
|
||||
|
||||
**详细文档:** [开发环境快速入门](./docs/开发指南.md)
|
||||
|
||||
|
||||
## 🤝 贡献指南
|
||||
|
||||
欢迎通过 [Issue](https://github.com/Tencent/WeKnora/issues) 反馈问题或提交 Pull Request。
|
||||
|
||||
**流程:** Fork → 新建分支 → 提交更改 → 创建 PR
|
||||
|
||||
**规范:** 使用 `gofmt` 格式化代码,遵循 [Conventional Commits](https://www.conventionalcommits.org/) 提交(`feat:` / `fix:` / `docs:` / `test:` / `refactor:`)
|
||||
|
||||
### 验证方式
|
||||
|
||||
对于范围集中的 PR,优先验证本次改动涉及的文件和包:
|
||||
|
||||
```bash
|
||||
git fetch origin main
|
||||
git diff --check origin/main...HEAD
|
||||
golangci-lint run --new-from-rev=origin/main ./...
|
||||
go test ./path/to/changed/package -count=1
|
||||
```
|
||||
|
||||
提交前请对改动过的 Go 文件运行 `gofmt`。对于前端改动,请在 `frontend/` 目录运行相关测试;如果改动涉及 TypeScript 或 Vue 组件,还应运行 `npm run type-check`。
|
||||
|
||||
维护者使用的全仓验证命令仍然是:
|
||||
|
||||
```bash
|
||||
make fmt
|
||||
make lint
|
||||
make test
|
||||
```
|
||||
|
||||
`make fmt` 会格式化整个 Go 仓库,因此请仅在工作区干净时运行,并检查产生的 diff。部分全量测试依赖本地基础设施或服务配置。如果全仓检查因无关的基线问题或环境依赖失败,请在 PR 中写明具体命令和错误,同时提供本次改动范围内通过的定向测试。
|
||||
|
||||
## 🔒 安全声明
|
||||
|
||||
**重要提示:** 从 v0.1.3 版本开始,WeKnora 提供了登录鉴权功能,以增强系统安全性。在生产环境部署时,我们强烈建议:
|
||||
|
||||
- 将 WeKnora 服务部署在内网/私有网络环境中,而非公网环境
|
||||
- 避免将服务直接暴露在公网上,以防止重要信息泄露风险
|
||||
- 为部署环境配置适当的防火墙规则和访问控制
|
||||
- 定期更新到最新版本以获取安全补丁和改进
|
||||
|
||||
## 👥 贡献者
|
||||
|
||||
感谢以下优秀的贡献者们:
|
||||
|
||||
[](https://github.com/Tencent/WeKnora/graphs/contributors)
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
本项目基于 [MIT](./LICENSE) 协议发布。
|
||||
你可以自由使用、修改和分发本项目代码,但需保留原始版权声明。
|
||||
+335
@@ -0,0 +1,335 @@
|
||||
<p align="center">
|
||||
<picture>
|
||||
<img src="./docs/images/logo.png" alt="WeKnora Logo" height="120"/>
|
||||
</picture>
|
||||
</p>
|
||||
<p align="center">
|
||||
<picture>
|
||||
<a href="https://trendshift.io/repositories/15289" target="_blank">
|
||||
<img src="https://trendshift.io/api/badge/repositories/15289" alt="Tencent/WeKnora | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/>
|
||||
</a>
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://weknora.weixin.qq.com" target="_blank">
|
||||
<img alt="公式サイト" src="https://img.shields.io/badge/公式サイト-WeKnora-4e6b99">
|
||||
</a>
|
||||
<a href="https://chatbot.weixin.qq.com" target="_blank">
|
||||
<img alt="WeChat対話オープンプラットフォーム" src="https://img.shields.io/badge/WeChat対話オープンプラットフォーム-5ac725">
|
||||
</a>
|
||||
<a href="https://chromewebstore.google.com/detail/jpemjbopikggjlmikmclgbmkhhopjdgd" target="_blank">
|
||||
<img alt="Chrome 拡張機能" src="https://img.shields.io/badge/Chrome 拡張機能-WeKnora-4285F4">
|
||||
</a>
|
||||
<a href="https://clawhub.ai/lyingbug/weknora" target="_blank">
|
||||
<img alt="ClawHub Skill" src="https://img.shields.io/badge/ClawHub Skill-WeKnora-ff6b35">
|
||||
</a>
|
||||
<a href="https://www.npmjs.com/package/@wxg-prc-cpg/dsh-weknora" target="_blank">
|
||||
<img alt="npm @wxg-prc-cpg/dsh-weknora" src="https://img.shields.io/npm/v/@wxg-prc-cpg/dsh-weknora?label=dsh-weknora">
|
||||
</a>
|
||||
<a href="https://github.com/Tencent/WeKnora/blob/main/LICENSE">
|
||||
<img src="https://img.shields.io/badge/License-MIT-ffffff?labelColor=d4eaf7&color=2e6cc4" alt="License">
|
||||
</a>
|
||||
<a href="./CHANGELOG.md">
|
||||
<img alt="バージョン" src="https://img.shields.io/badge/version-0.8.0-2e6cc4?labelColor=d4eaf7">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
| <a href="./README.md"><b>English</b></a> | <a href="./README_CN.md"><b>简体中文</b></a> | <b>日本語</b> | <a href="./README_KO.md"><b>한국어</b></a> |
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<h4 align="center">
|
||||
|
||||
[プロジェクト紹介](#-プロジェクト紹介) • [アーキテクチャ設計](#️-アーキテクチャ設計) • [コア機能](#-コア機能) • [クイックスタート](#-クイックスタート) • [ドキュメント](#-ドキュメント) • [開発ガイド](#-開発ガイド)
|
||||
|
||||
</h4>
|
||||
</p>
|
||||
|
||||
# 💡 WeKnora — ドキュメントを「生きたナレッジ」へ:RAG・Agent 推論・自動 Wiki を統合した LLM ナレッジフレームワーク
|
||||
|
||||
## 📌 プロジェクト紹介
|
||||
|
||||
[**WeKnora(ウィーノラ)**](https://weknora.weixin.qq.com) は、大規模言語モデル(LLM)をベースとしたオープンソースのナレッジフレームワークで、エンタープライズ級の文書理解、セマンティック検索、自律推論シナリオ向けに設計されています。
|
||||
|
||||
本フレームワークは **3 つのコア能力** を中心に構築されています:日常的な検索に最適な **RAG ベースのクイック Q&A**、ナレッジ検索・MCP ツール・**スキルカタログ**・セッション永続の **Docker / E2B / Cube サンドボックス**・Web 検索を自律的にオーケストレーションし複雑なマルチステップタスクを処理する **ReAct Agent 推論**、そして Agent が生のドキュメントから相互リンクされた Markdown ナレッジベースとインタラクティブなナレッジグラフを自律生成・維持する全く新しい **Wiki モード**(手動編集・バージョン履歴・ワンクリックロールバック対応)。**クロスセッション長期メモリ**は「あなたが誰で、何を繰り返し尋ねるか」を覚えます。ナレッジの整備も細かく制御可能で、**ツリー型フォルダー**がアップロード時のディレクトリ構造を保持し、**チャンク編集とバージョン履歴**により検索チャンクをドキュメントと同様に編集・差分比較・ロールバックできます。さらに、多様なデータソース連携(Feishu ナレッジベース / Feishu クラウドドライブ / GitLab / Tencent IMA / Notion / Yuque / RSS、随時拡充中)、**ウェブサイト埋め込み Widget** による外部サイトへのエージェント公開、プログラム連携向けの**スコープ付き API キーと Principal モデル**、ワークスペースごとの**マルチインスタンスストレージバックエンド**、20 以上の LLM プロバイダー統合(LiteLLM 含む)、Langfuse による全体可観測性と**ランタイムタスクキューダッシュボード + Worker プール統治**、**エンタープライズ向けマルチテナント RBAC(4 階層ロールマトリクス + リソース所有権 + テナント監査ログ)**、完全セルフホスト可能なモジュラーアーキテクチャと組み合わせることで、WeKnora は散在する文書を「検索可能・推論可能・継続的に進化する」専用ナレッジ資産へと昇華させます。
|
||||
|
||||
Feishu、GitLab、Tencent IMA、Notion、Yuqueなどの外部プラットフォームからのナレッジ自動同期(他のデータソースも順次対応中)に対応し、PDF、Word、画像、Excel、XMindなど10以上の文書フォーマットをサポート。WeChat Work、Feishu、Slack、TelegramなどのIMチャネルから直接Q&Aサービスを提供できます。モデル層ではOpenAI、DeepSeek、Qwen(Alibaba Cloud)、Zhipu、Hunyuan、Gemini、MiniMax、NVIDIA、LiteLLM、Ollamaなど主要プロバイダーに対応。Office 文書は **anydoc** でプロセス内解析できます。全プロセスをモジュラー設計し、大規模モデル、ベクトルデータベース、ストレージなどのコンポーネントを柔軟に差し替え可能。ローカルおよびプライベートクラウドデプロイに対応し、データは完全に自己管理可能です。さらにWeKnoraは **Langfuse** とシームレスに統合され、Agentの推論、トークン消費、パイプラインに対する包括的な可観測性(オブザーバビリティ)を提供します。
|
||||
|
||||
## ✨ 最新アップデート
|
||||
|
||||
- **v0.8.0** — **スキルサンドボックス実行環境**(セッション永続の Docker / E2B / Cube バックエンド、テナント単位のネットワークポリシー;Local ホストプロセスバックエンドを削除;Docker はオプトイン);**テナントスキルカタログ**(ClawHub / SkillHub / git / zip からインストール、サンドボックス単位のスナップショット、ライブ進捗、ファイル閲覧/編集、個人・ワークスペース環境変数);**クロスセッション長期メモリ**(profile / preference / fact / task / interest、確認付き自動抽出、`search_memory`);プロセス内 **anydoc** Office パーサー;公式 **DeepSeek Harness プラグイン** `@wxg-prc-cpg/dsh-weknora`;GitLab と Tencent IMA データソース;LiteLLM;Exa と Metaso Web 検索;XMind 解析;チャット成果物、質問アウトライン、タイムスタンプ;コンテキスト圧縮とプロバイダー Prompt Cache マーカー。加えて OIDC JWKS 検証、任意の複雑パスワード、ドキュメント自動タグ付け、広範なサンドボックス/セキュリティ強化。詳細は [`CHANGELOG.md`](./CHANGELOG.md)。
|
||||
- **v0.7.2** — **公式製品ドキュメントサイト**を公開(VitePress、6 セクション約 50 ページで約 360 の API エンドポイントと約 150 の環境変数を網羅、独立した Docker/Nginx デプロイ・クイックスタート用サンプルデータ・ローカル MCP デモを同梱);**ナレッジベースのフォルダーツリー**(アップロードパスを独立したデータとして保存し、ファイルマネージャーのように参照・リネーム・再配置);**チャンク編集とバージョン履歴**(UI で検索チャンクを編集、バージョン単位の差分とロールバック、編集後のインデックス自動再構築、ドキュメントのカスタムメタデータ);**Wiki ページのバージョン履歴**(スナップショット + 行単位差分 + ワンクリックロールバック + ブラウザ内手動編集);**ファイル直リンクモード** `resource_urls=public` / `RESOURCE_URL_MODE`(サードパーティアプリが認証プロキシへの二次リクエストなしで画像とファイルを表示);**Feishu クラウドドライブデータソース**と docx の blocks API 同期;ドキュメントの一括タグ付け;**MCP Server 1.1.x**(mcp 2.x の高レベル API へ移行、公式 PyPI パッケージ `tencent-weknora-mcp`、`create_knowledge_from_text` と `list_shared_knowledge_bases` を追加し計 29 ツール);AWS S3 デフォルト資格情報チェーン(IAM Role / IRSA);ローカル HTML アップロード解析;QQBot の Markdown 返信;app / frontend / docreader / mcp-server の PR CI チェック追加。加えて router と `modelcontext` の大規模リファクタリング、リランクとチャンキングの品質改善、広範な安定性修正。詳細は [`CHANGELOG.md`](./CHANGELOG.md)。
|
||||
- **v0.7.1** — 新しい**Yunzhijia(云之家)IM 連携**(WebSocket + 画像メッセージ + Markdown 返信);**Volcengine Rerank** プロバイダー(リクエストの自動分割)と **Zhipu AI Web 検索**プロバイダー;コントロールプレーン自動化向けの**プラットフォームスコープ API キー**(テナント管理、システム設定、ランタイムキュー、監査ログ);**KB 単位のアクティビティ監査証跡**;FAQ 管理の強化(フィルタリング、タグ付け、エクスポート、インポート結果追跡);**Langfuse OTLP/OTel トレーシング**への移行と W3C traceparent 伝播;チャットヘッダーアクションによるワンクリック **Markdown エクスポート**、参照ドロワーへの Wiki ツール結果表示;プロンプトキャッシュ可観測性;セッションチャネルガバナンス(IM/埋め込み/API セッションを管理者スコープで分離);Feishu 大規模 Wiki 同期の堅牢化;レガシー Neo4j 会話メモリ依存の削除。加えて広範な slug 整合性・SSRF トランスポート・状態同期の強化。詳細は [`CHANGELOG.md`](./CHANGELOG.md)。
|
||||
- **v0.7.0** — きめ細かい**スコープ付き API キーと Principal モデル**(能力単位の付与 + KB 単位の制限 + API 連携プレイグラウンド);**ランタイムタスクキュー可観測ダッシュボードと Worker プール統治**(ステージ別プール + モデル別並行度ガバナー + 失敗タスクの調査/再試行);**マルチインスタンスストレージバックエンド**(ワークスペースごとに複数のストレージインスタンス、KB 単位のバインド、デフォルトインスタンス);**セッションスコープの一時添付**(画像/ドキュメントの非同期解析 + 合算上限);推奨質問とフォローアップ;安定リソースレジストリと LLM コンテキストのエイリアス圧縮;`@Skill / @MCP` メンションによるスコープ化 Agent ランタイム;会話中の MCP OAuth;QQBot と Lark(Feishu 国際版)IM 連携;Redis TLS;Requesty モデルプロバイダー + Keenable Web 検索;テナントレスプロビジョニングと制御されたセルフサービスワークスペース;管理者パスワードリセット;ナレッジベース複製フロー;`weknora` CLI v0.10。加えて大規模なセキュリティ強化(SSRF、シークレットのマスキング、SQL 検証、IDOR)。詳細は [`CHANGELOG.md`](./CHANGELOG.md)。
|
||||
- **v0.6.3** — ウェブサイト埋め込み Widget と統合センター(セキュアモード Token 交換 + レート制限);チャット体験の全面刷新(引用ポップオーバー、RAG パイプライン進捗、ストリーミング Markdown);ドキュメント複数タグと一括 reparse;Wiki フォルダーと階層ナビゲーション;RSS データソース;MCP OAuth2;EPUB / MHTML 解析;Agent モデル準備状態チェック;モデルデバッガー;セッションソースフィルター;ワークスペース削除 UI。詳細は [`CHANGELOG.md`](./CHANGELOG.md)。
|
||||
- **v0.6.2** — アップロード単位の解析設定(`process_config`)+ アップロード確認ダイアログ;reparse 時の設定上書き;`weknora` CLI v0.9(同梱 Agent Skills、`session stop`、auth/profile 統合);KB マーキー複数選択;pgvector 1024 次元 HNSW インデックス;チャットリソース Store 刷新;Langfuse のみのトレーシング(Jaeger 削除)。詳細は [`CHANGELOG.md`](./CHANGELOG.md)。
|
||||
- **v0.6.1** — ドキュメント解析トレースタイムライン(Langfuse 風の Span ツリー、ステージごとの進捗表示 + 解析中止);OpenSearch ベクター DB ドライバー;YAML 宣言型ビルトインモデル設定;システム管理者と統合プラットフォーム設定 + 監査ログ;新規ユーザーオンボーディングガイド;設定 UI 刷新;`weknora` CLI v0.7 / v0.8(Agent ファースト ワイヤープロトコル、NDJSON、`--dry-run`);OpenDataLoader と PaddleOCR-VL 解析エンジン;MCP サーバーのマルチトランスポート(stdio / SSE / HTTP);モデル単位の思考モード設定;Tencent LKEAP リランク + ネイティブ Gemini Embedding + MiniMax-M3。詳細は [`CHANGELOG.md`](./CHANGELOG.md) を参照。
|
||||
- **v0.6.0** — テナント RBAC(4 階層ロールマトリクス `Owner` / `Admin` / `Contributor` / `Viewer` + KB 単位の所有 + テナントごとの監査ログ)、テナントメンバー管理とマルチワークスペース UX、セルフサービスでのワークスペース作成;`weknora` CLI v0.4 GA + `mcp serve`;KB 検索の複数ベクター DB ファンアウト;MCP / データソース資格情報の AES-256-GCM 暗号化 + docreader gRPC TLS + Token;Zhipu Embedder と華為雲 OBS の追加;サーバーサイドユーザー設定;Go 1.26.0。詳細は [`docs/RBAC说明.md`](./docs/RBAC说明.md) と [`CHANGELOG.md`](./CHANGELOG.md) を参照。
|
||||
- **v0.5.2** — Wiki インジェストが万件規模 KB に対応(タスクキュー + DLQ);MCP 工具人機審批;Anthropic / Apache Doris / Tencent VectorDB / 金山雲 KS3 / SearXNG バックエンド;適応型 3 段階チャンキング + ライブプレビュー;グローバル ⌘K コマンドパレット;Yuque コネクタ + WeChat ミニプログラム;`weknora` CLI プレビュー版。
|
||||
- **v0.5.1** — KB 一括管理;テナント全体の IM チャネル概観;セッション検索 + ユーザー単位ピン留め;モデル / Web 検索 / MCP 統一カード設定;Agent ごとの LLM タイムアウト;デスクトップ版テナント切替。
|
||||
- **v0.5.0** — Wiki モード GA — Agent が原文書から構造化・相互リンクされた Markdown Wiki ページとナレッジグラフを自動生成、Wiki ブラウザと可視化グラフを UI に搭載。
|
||||
- **v0.4.0** — WeKnora Cloud(ホスティング LLM + 解析);Chrome 拡張機能;ClawHub Skill;WeChat IM;添付ファイル処理;Azure OpenAI / Alibaba OSS;Notion コネクタ;Baidu + Ollama Web 検索;VectorStore 管理。
|
||||
- **v0.3.6** — ASR(音声);Feishu データソース自動同期;OIDC;IM 引用返信 + スレッドベースセッション;ドキュメント自動要約;Tavily 検索;並列ツール呼び出し;Agent @メンション範囲制限。
|
||||
- **v0.3.5** — Telegram / DingTalk / Mattermost IM;IM スラッシュコマンド + QA キュー;推奨質問;VLM による MCP ツール画像自動説明;Novita AI;チャネルトラッキング。
|
||||
- **v0.3.4** — 企業 WeChat / Feishu / Slack IM;マルチモーダル画像;NVIDIA モデル API;Weaviate;AWS S3;AES-256-GCM API キー暗号化;組み込み MCP サービス;ハイブリッド検索最適化;`final_answer` ツール。
|
||||
- **v0.3.3** — 親子チャンキング;KB ピン留め;フォールバック応答;Rerank パッセージクリーニング;ストレージバケット自動作成;Milvus。
|
||||
- **v0.3.2** — ナレッジ検索エントリ;ソース別パーサー / ストレージエンジン設定;ローカルストレージ画像レンダリング;ドキュメントプレビュー;Volcengine TOS;Mermaid レンダリング;対話バッチ管理;メモリグラフプレビュー。
|
||||
- **v0.3.0** — 共有スペース;Agent Skills + サンドボックス実行;カスタム Agent;データ分析 Agent;思考モード;Bing / Google 検索;API Key 認証;Helm Chart;韓国語 i18n;Qdrant。
|
||||
- **v0.2.0** — Agent モード(ReACT);複数タイプのナレッジベース(FAQ + ドキュメント);対話戦略設定;DuckDuckGo Web 検索;MCP ツール統合;新 UI + Agent モード切替;MQ 非同期タスク管理。
|
||||
|
||||
|
||||
## 📱 機能デモ
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td colspan="2" align="center"><b>🛠️ スキルサンドボックス対話 · Word を生成してプレビュー</b><br/><img src="./docs/images/skill-sandbox-chat.png" alt="スキルサンドボックス対話:Word 文書の生成とプレビュー" width="100%"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" align="center"><b>📦 スキルカタログ · E2B サンドボックスへインストール</b><br/><img src="./docs/images/skill-catalog.png" alt="ワークスペースのスキルカタログ(docx / pptx / pdf を E2B にインストール)" width="100%"></td>
|
||||
<td width="50%" align="center"><b>🤖 Agent モード · 検索、スキル読取、サンドボックスへ書き込み</b><br/><img src="./docs/images/agent-qa.png" alt="Agent が知識ベースを検索し、docx スキルを読み、サンドボックスにスクリプトを書き込む" width="100%"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" align="center"><b>💬 インテリジェント Q&A 対話</b><br/><img src="./docs/images/qa.png" alt="インテリジェント Q&A 対話" width="100%"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" align="center"><b>📖 Wiki ブラウザ</b><br/><img src="./docs/images/wiki-browser.png" alt="Wiki ブラウザ" width="100%"></td>
|
||||
<td width="50%" align="center"><b>🕸️ Wiki ナレッジグラフ</b><br/><img src="./docs/images/wiki-graph.png" alt="Wiki ナレッジグラフ" width="100%"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" align="center"><b>🕘 Wiki ページのバージョン履歴とロールバック</b><br/><img src="./docs/images/wiki-revision-history.png" alt="Wiki ページのバージョン履歴とロールバック" width="100%"></td>
|
||||
<td width="50%" align="center"><b>✂️ チャンク編集とバージョン履歴</b><br/><img src="./docs/images/kb-chunk-edit.png" alt="チャンク編集とバージョン履歴" width="100%"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" align="center"><b>📁 フォルダーツリーと一括操作</b><br/><img src="./docs/images/kb-document-list.png" alt="ナレッジベースのフォルダーツリーと一括操作" width="100%"></td>
|
||||
<td width="50%" align="center"><b>🔭 可観測性 · Langfuse Tracing</b><br/><img src="./docs/images/langfuse.png" alt="Langfuse Tracing" width="100%"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 🏗️ アーキテクチャ設計
|
||||
|
||||

|
||||
|
||||
文書解析・ベクトル化・検索から大規模モデル推論まで、全パイプラインをモジュラー分離。各コンポーネントは柔軟に差し替え・拡張可能。ローカル / プライベートクラウドデプロイに対応し、データ完全自己管理、ゼロバリアの Web UI で即座に利用開始。
|
||||
|
||||
|
||||
## 🧩 機能概要
|
||||
|
||||
**インテリジェント対話**
|
||||
|
||||
| 機能 | 詳細 |
|
||||
|------|------|
|
||||
| インテリジェント推論 | ReACT プログレッシブ・マルチステップ推論、ナレッジ検索・MCP ツール・スキルサンドボックス・Web 検索を自律的にオーケストレーション |
|
||||
| クイック Q&A | ナレッジベースベースの RAG Q&A、迅速かつ正確な回答 |
|
||||
| Wiki モード | Agent主導で生のドキュメントから構造化された相互リンク済みMarkdown Wikiページを自動生成・保守;ブラウザ内手動編集、ページのバージョン履歴、行単位差分とワンクリックロールバック |
|
||||
| スキルカタログとサンドボックス | ワークスペースのスキルカタログ(ClawHub / SkillHub / git / zip)をセッション永続の Docker / E2B / Cube サンドボックスへインストール;`shell_exec`、ファイルツール、成果物、設定単位のネットワークポリシー;Local ホストプロセスバックエンドは削除 |
|
||||
| 長期メモリ | クロスセッションメモリ(profile / preference / fact / task / interest)。自動抽出、ユーザー確認、オンデマンド `search_memory` |
|
||||
| ツール呼び出し | 組み込みツール、MCP ツール(OAuth2 リモートサービス・会話中 OAuth 含む)、Web 検索;`@Skill / @MCP` メンションでターン単位に Agent ランタイムを範囲化 |
|
||||
| 対話戦略 | オンライン Prompt 編集、検索閾値チューニング、マルチターン文脈認識、Agent 単位の引用出力トグル |
|
||||
| 推奨質問 | ナレッジベースの内容に基づく質問の自動生成と回答後のフォローアップ |
|
||||
| 一時添付 | セッションスコープで画像 / ドキュメントをアップロードし、非同期解析して一回限りの Q&A に使用(画像 + 添付の合算上限) |
|
||||
| 引用と RAG 進捗 | インライン引用ポップオーバーと引用ドロワー(Web / KB ソースの区別)、統一 Markdown レンダリング、RAG パイプラインの段階別進捗表示 |
|
||||
| セッション管理 | サイドバーでソース別(Web / IM / 埋め込み)にセッションをフィルター・グループ化、セッションタイトルのインラインリネーム対応 |
|
||||
|
||||
**ナレッジ管理**
|
||||
|
||||
| 機能 | 詳細 |
|
||||
|------|------|
|
||||
| ナレッジベースタイプ | FAQ / ドキュメント / Wiki、フォルダーインポート・URL インポート・複数タグ管理・オンライン入力 |
|
||||
| フォルダーツリー | フォルダーアップロード時の元のディレクトリ構造を保持し、サイドバーのツリーで参照、フォルダーのリネーム、ドキュメントの別フォルダーへの再配置に対応 |
|
||||
| チャンク編集とバージョン | UI から検索チャンクを直接編集、バージョン単位のスナップショット・差分・ワンクリックロールバック、編集後のインデックス自動再構築;生成質問の追加・編集・削除・再生成;ドキュメントのカスタムメタデータ対応 |
|
||||
| アップロード単位の解析設定 | アップロード確認ダイアログまたは `process_config` API でパーサー・チャンキング・マルチモーダル(VLM / ASR)・グラフ抽出・質問生成をバッチ単位で上書き;reparse 時も設定変更可能 |
|
||||
| 一括 reparse | 複数ドキュメントの解析を一度に再キュー、バッチ単位の `process_config` 対応 |
|
||||
| データソースインポート | Feishu ナレッジベース / Feishu クラウドドライブ / Lark / GitLab / Tencent IMA / Notion / Yuque / RSS フィードの自動同期(他のデータソースも開発中)、増分・全量同期対応 |
|
||||
| 文書フォーマット | PDF / Word / Txt / Markdown / HTML / EPUB / MHTML / 画像 / CSV / Excel / PPT / JSON / XMind |
|
||||
| 自動タグ付け | 解析後、ナレッジベース既存タグから一致するものを増分付与(新規タグ作成や手動タグの上書きはしない) |
|
||||
| 検索戦略 | BM25 疎検索 / Dense 密検索 / GraphRAG グラフ強化 / 親子チャンキング / pgvector HNSW 加速(1024 次元)/ 多次元インデックス |
|
||||
| 一括選択とタグ付け | KB リストでマーキー(ドラッグ)複数選択し、一括 reparse と一括タグ付け(共通タグを自動プリセット)を実行 |
|
||||
| E2E テスト | 検索+生成の全パイプライン可視化、リコール的中率・BLEU / ROUGE 指標評価 |
|
||||
|
||||
**連携と拡張**
|
||||
|
||||
| 機能 | 詳細 |
|
||||
|------|------|
|
||||
| 大規模モデル | OpenAI / Azure OpenAI / Anthropic (Claude) / DeepSeek / Qwen (Alibaba Cloud) / Zhipu / Hunyuan / Doubao (Volcengine) / Gemini / MiniMax / NVIDIA / Novita AI / SiliconFlow / OpenRouter / Requesty / LiteLLM / Ollama |
|
||||
| Embedding | Ollama / BGE / GTE / OpenAI 互換 API |
|
||||
| ベクトル DB | PostgreSQL (pgvector) / Elasticsearch / OpenSearch / Milvus / Weaviate / Qdrant / Apache Doris / Tencent VectorDB |
|
||||
| オブジェクトストレージ | ローカル / MinIO / AWS S3(IAM Role / IRSA のデフォルト資格情報チェーン対応)/ 火山引擎 TOS / Alibaba Cloud OSS / 金山雲 KS3 / 華為雲 OBS;**ワークスペースごとに複数のストレージインスタンス**、KB 単位のバインドとデフォルトインスタンス |
|
||||
| IM 統合 | WeChat Work / Feishu / Lark(Feishu 国際版)/ QQBot / Slack / Telegram / DingTalk / Mattermost / WeChat / Yunzhijia |
|
||||
| ウェブ埋め込み | 埋め込み Widget でエージェントを公開、ドメイン許可リスト・レート制限・セキュアモード Token 交換 |
|
||||
| Web 検索 | DuckDuckGo / Bing / Google / Tavily / Baidu / Ollama / SearXNG / Keenable / Zhipu AI / Exa / Metaso |
|
||||
| API 連携 | スコープ付き API キー(能力単位の付与 + KB 単位の制限 + 節流付き last_used 追跡)と API 連携プレイグラウンド;MCP OAuth と埋め込みセッションを Principal 単位で分離;`resource_urls=public` で直接読み込み可能なファイル / 画像 URL を返却し、認証プロキシへの二次リクエストを不要に |
|
||||
| MCP Server | 公式 PyPI パッケージ `tencent-weknora-mcp`、29 ツール、stdio / SSE / HTTP の 3 トランスポート対応 |
|
||||
|
||||
**プラットフォーム**
|
||||
|
||||
| 機能 | 詳細 |
|
||||
|------|------|
|
||||
| デプロイ | ローカル / Docker / Kubernetes (Helm)、プライベート化・オフラインデプロイ対応 |
|
||||
| UI | Web UI / RESTful API / CLI (`weknora`) / Chrome Extension / ウェブ埋め込み Widget / WeChat ミニプログラム |
|
||||
| 可観測性 | Langfuse(唯一のトレーシングバックエンド)で ReAct ループ・トークン消費・ツール呼び出し・パイプライン追跡;Langfuse 風のドキュメント解析トレースタイムラインを内蔵し、ステージごとの進捗を表示;システム管理者向けランタイムタスクキューダッシュボード(キュー深度・モデル別並行度・失敗タスクの調査と手動再試行) |
|
||||
| タスク管理 | MQ 非同期タスク、ステージ別 Worker プール統治(core / 後処理 / enrichment / maintenance + 弾性共有プール、Wiki は独立プール)とモデル別バックグラウンド並行度ガバナー;バージョンアップ時の DB 自動マイグレーション |
|
||||
| モデル管理 | 集中設定、YAML 宣言型ビルトインモデル設定、ナレッジベース単位のモデル選択、モデル単位の思考モード・Embedding 次元上書き、インタラクティブモデルデバッガー、マルチテナント組み込みモデル共有、WeKnora Cloud ホスティングモデルとドキュメント解析 |
|
||||
|
||||
## 🧩 Chrome 拡張機能
|
||||
|
||||
[**WeKnora Chrome 拡張機能**](https://chromewebstore.google.com/detail/jpemjbopikggjlmikmclgbmkhhopjdgd)を使えば、ブラウザからWebコンテンツをWeKnoraナレッジベースに直接取り込めます。テキスト、画像、ページ全体を選択してワンクリックでナレッジエントリとして保存——コピペやファイルアップロード不要です。
|
||||
|
||||
## 🦞 ClawHub Skill
|
||||
|
||||
[**WeKnora ClawHub Skill**](https://clawhub.ai/lyingbug/weknora)はClawHubプラットフォームで公開されたWeKnoraスキルです。インストール後、WeKnora REST APIを通じてドキュメントのアップロード(ファイル / URL / Markdown)、ハイブリッド検索(ベクトル + キーワード)、ナレッジエントリの管理が可能になります。
|
||||
|
||||
- **ドキュメントインポート** — エージェント経由でファイルアップロード、Webページインポート、Markdownナレッジの作成
|
||||
- **ハイブリッド検索** — 単一または複数のナレッジベースをベクトル + キーワードで横断検索
|
||||
- **ナレッジ管理** — プログラムによるナレッジエントリの閲覧、編集、削除
|
||||
|
||||
## 🐋 DeepSeek Harness プラグイン
|
||||
|
||||
[**`@wxg-prc-cpg/dsh-weknora`**](https://www.npmjs.com/package/@wxg-prc-cpg/dsh-weknora) は公式の [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(`dsh`)プラグインです([ドキュメント](./packages/dsh-weknora/README.md))。harness 自体は検索・埋め込み・ナレッジベースの機能を持たないため、このプラグインがコーディングエージェントに自社ドキュメントを与えます。`dsh plugin --profile web add @wxg-prc-cpg/dsh-weknora` でインストールしてデプロイ先を指定すると、4 つの読み取り専用ツールがエージェントのツールセットに現れます。
|
||||
|
||||
- **`weknora_search`** — ハイブリッド検索。原文のパッセージをそのまま返し、各件に再利用可能な `knowledge_id` が付く
|
||||
- **`weknora_read_document`** — 1 つのドキュメントのチャンクを順番に再構成、ページング対応
|
||||
- **`weknora_ask`** — WeKnora 自身が引用付きで作成した回答(RAG または ReAct パイプライン)
|
||||
- **`weknora_list_knowledge_bases`** — ナレッジベースの名前と id。エージェントが自分で検索範囲を絞れる
|
||||
|
||||
|
||||
## 🚀 クイックスタート
|
||||
|
||||
### 🛠 環境要件
|
||||
|
||||
- [Docker](https://www.docker.com/) & [Docker Compose](https://docs.docker.com/compose/)
|
||||
- [Git](https://git-scm.com/)
|
||||
|
||||
### 📦 インストール・起動
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Tencent/WeKnora.git
|
||||
cd WeKnora
|
||||
cp .env.example .env # 必要に応じて .env を編集(詳細はファイル内のコメント参照)
|
||||
docker compose pull # 最新イメージを取得
|
||||
docker compose up -d # コアサービスを起動
|
||||
```
|
||||
|
||||
起動後、**http://localhost** にアクセスして利用開始。
|
||||
|
||||
> ローカル Ollama モデルを使用する場合は、先に `ollama serve > /dev/null 2>&1 &` を実行してください。
|
||||
|
||||
### 🔄 アップグレード
|
||||
|
||||
既存のデプロイがあり、新しい release をダウンロードした場合:
|
||||
|
||||
```bash
|
||||
# .env の WEKNORA_VERSION を対象バージョン(例: 0.7.0)に設定、または latest のまま
|
||||
docker compose pull # WEKNORA_VERSION に一致するイメージを取得
|
||||
docker compose up -d # 新しいイメージでコンテナを再作成
|
||||
```
|
||||
|
||||
> `docker compose up -d` のみではローカルキャッシュのイメージが再利用され、Web UI の表示バージョンがダウンロードした release と一致しない場合があります。
|
||||
|
||||
### 🔧 オプションサービス(Docker Compose Profile)
|
||||
|
||||
`--profile` フラグで追加コンポーネントを有効化。複数の profile を組み合わせ可能:
|
||||
|
||||
| Profile | 説明 | コマンド |
|
||||
|---------|------|---------|
|
||||
| _(デフォルト)_ | コアサービス | `docker compose pull && docker compose up -d` |
|
||||
| `full` | 全機能 | `docker compose --profile full pull && docker compose --profile full up -d` |
|
||||
| `neo4j` | ナレッジグラフ (Neo4j) | `docker compose --profile neo4j pull && docker compose --profile neo4j up -d` |
|
||||
| `minio` | オブジェクトストレージ (MinIO) | `docker compose --profile minio pull && docker compose --profile minio up -d` |
|
||||
| `langfuse` | トレーシング (Langfuse) | `docker compose --profile langfuse pull && docker compose --profile langfuse up -d` |
|
||||
|
||||
組み合わせ例:`docker compose --profile neo4j --profile minio pull && docker compose --profile neo4j --profile minio up -d`
|
||||
|
||||
サービス停止:`docker compose down`
|
||||
|
||||
### 🌐 サービスアドレス
|
||||
|
||||
| サービス | URL |
|
||||
|---------|-----|
|
||||
| Web UI | `http://localhost` |
|
||||
| バックエンド API | `http://localhost:8080` |
|
||||
| Langfuse トレーシング | `http://localhost:3000` |
|
||||
|
||||
## 文書ナレッジグラフ
|
||||
|
||||
WeKnoraは文書をナレッジグラフに変換し、文書内の異なる段落間の関連関係を表示することをサポートします。ナレッジグラフ機能を有効にすると、システムは文書内部の意味関連ネットワークを分析・構築し、ユーザーが文書内容を理解するのを助けるだけでなく、インデックスと検索に構造化サポートを提供し、検索結果の関連性と幅を向上させます。
|
||||
|
||||
詳細な設定については、[ナレッジグラフ設定ガイド](./docs/KnowledgeGraph.md)をご参照ください。
|
||||
|
||||
## 対応するMCPサーバー
|
||||
|
||||
[MCP設定ガイド](./mcp-server/MCP_CONFIG.md) をご参照のうえ、必要な設定を行ってください。
|
||||
|
||||
|
||||
## 🔌 WeChat対話オープンプラットフォームの使用
|
||||
|
||||
WeKnoraは[WeChat対話オープンプラットフォーム](https://chatbot.weixin.qq.com)のコア技術フレームワークとして、より簡単な使用方法を提供します:
|
||||
|
||||
- **ノーコードデプロイメント**:知識をアップロードするだけで、WeChatエコシステムで迅速にインテリジェントQ&Aサービスをデプロイし、「即座に質問して即座に回答」の体験を実現
|
||||
- **効率的な問題管理**:高頻度の問題の独立した分類管理をサポートし、豊富なデータツールを提供して、正確で信頼性が高く、メンテナンスが容易な回答を保証
|
||||
- **WeChatエコシステムカバレッジ**:WeChat対話オープンプラットフォームを通じて、WeKnoraのインテリジェントQ&A能力を公式アカウント、ミニプログラムなどのWeChatシナリオにシームレスに統合し、ユーザーインタラクション体験を向上
|
||||
|
||||
|
||||
## 📘 ドキュメント
|
||||
|
||||
**公式製品ドキュメント**:[`website-docs/`](./website-docs/README.md) — 「入門 → アーキテクチャ → 機能 → API → クライアント → 開発」の 6 セクションで構成された完全なドキュメントセット。約 360 の API エンドポイント、約 150 の環境変数、9 つの拡張ポイントを網羅しています。このディレクトリは VitePress サイトでもあり、`cd website-docs && npm install && npm run dev` でローカルプレビュー、同ディレクトリの `Dockerfile` で単独デプロイも可能です。
|
||||
|
||||
よくある問題の解決:[よくある問題](./docs/QA.md)
|
||||
|
||||
詳細なAPIドキュメントは:[APIドキュメント](./docs/api/README.md)を参照してください
|
||||
|
||||
製品計画と今後の機能:[Roadmap](./docs/ROADMAP.md)
|
||||
|
||||
## 🧭 開発ガイド
|
||||
|
||||
### ⚡ 高速開発モード(推奨)
|
||||
|
||||
コードを頻繁に変更する必要がある場合、**Dockerイメージを毎回再構築する必要はありません**!高速開発モードを使用してください:
|
||||
|
||||
```bash
|
||||
# インフラストラクチャを起動
|
||||
make dev-start
|
||||
|
||||
# バックエンドを起動(新しいターミナル)
|
||||
make dev-app
|
||||
|
||||
# フロントエンドを起動(新しいターミナル)
|
||||
make dev-frontend
|
||||
```
|
||||
|
||||
**開発の利点:**
|
||||
- ✅ フロントエンドの変更は自動ホットリロード(再起動不要)
|
||||
- ✅ バックエンドの変更は高速再起動(5-10秒、Airホットリロードをサポート)
|
||||
- ✅ Dockerイメージを再構築する必要がない
|
||||
- ✅ IDEブレークポイントデバッグをサポート
|
||||
|
||||
**詳細ドキュメント:** [開発環境クイックスタート](./docs/开发指南.md)
|
||||
|
||||
## 🤝 貢献ガイド
|
||||
|
||||
[Issue](https://github.com/Tencent/WeKnora/issues) や Pull Request の提出を歓迎します。
|
||||
|
||||
**フロー:** Fork → ブランチ作成 → 変更をコミット → PR を作成
|
||||
|
||||
**規約:** `gofmt` でコードをフォーマット、[Conventional Commits](https://www.conventionalcommits.org/) に従う(`feat:` / `fix:` / `docs:` / `test:` / `refactor:`)
|
||||
|
||||
## 🔒 セキュリティ通知
|
||||
|
||||
**重要:** v0.1.3バージョンより、WeKnoraにはシステムセキュリティを強化するためのログイン認証機能が含まれています。v0.2.0では、さらに多くの機能強化と改善が追加されました。本番環境でのデプロイメントにおいて、以下を強く推奨します:
|
||||
|
||||
- WeKnoraサービスはパブリックインターネットではなく、内部/プライベートネットワーク環境にデプロイしてください
|
||||
- 重要な情報漏洩を防ぐため、サービスを直接パブリックネットワークに公開することは避けてください
|
||||
- デプロイメント環境に適切なファイアウォールルールとアクセス制御を設定してください
|
||||
- セキュリティパッチと改善のため、定期的に最新バージョンに更新してください
|
||||
|
||||
## 👥 コントリビューター
|
||||
|
||||
素晴らしいコントリビューターに感謝します:
|
||||
|
||||
[](https://github.com/Tencent/WeKnora/graphs/contributors)
|
||||
|
||||
## 📄 ライセンス
|
||||
|
||||
このプロジェクトは[MIT](./LICENSE)ライセンスの下で公開されています。
|
||||
このプロジェクトのコードを自由に使用、変更、配布できますが、元の著作権表示を保持する必要があります。
|
||||
+344
@@ -0,0 +1,344 @@
|
||||
<p align="center">
|
||||
<picture>
|
||||
<img src="./docs/images/logo.png" alt="WeKnora Logo" height="120"/>
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<picture>
|
||||
<a href="https://trendshift.io/repositories/15289" target="_blank">
|
||||
<img src="https://trendshift.io/api/badge/repositories/15289" alt="Tencent/WeKnora | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/>
|
||||
</a>
|
||||
</picture>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://weknora.weixin.qq.com" target="_blank">
|
||||
<img alt="공식 웹사이트" src="https://img.shields.io/badge/공식_웹사이트-WeKnora-4e6b99">
|
||||
</a>
|
||||
<a href="https://chatbot.weixin.qq.com" target="_blank">
|
||||
<img alt="WeChat 대화 오픈 플랫폼" src="https://img.shields.io/badge/WeChat_대화_오픈_플랫폼-5ac725">
|
||||
</a>
|
||||
<a href="https://chromewebstore.google.com/detail/jpemjbopikggjlmikmclgbmkhhopjdgd" target="_blank">
|
||||
<img alt="Chrome 확장 프로그램" src="https://img.shields.io/badge/Chrome_확장_프로그램-WeKnora-4285F4">
|
||||
</a>
|
||||
<a href="https://clawhub.ai/lyingbug/weknora" target="_blank">
|
||||
<img alt="ClawHub Skill" src="https://img.shields.io/badge/ClawHub_Skill-WeKnora-ff6b35">
|
||||
</a>
|
||||
<a href="https://www.npmjs.com/package/@wxg-prc-cpg/dsh-weknora" target="_blank">
|
||||
<img alt="npm @wxg-prc-cpg/dsh-weknora" src="https://img.shields.io/npm/v/@wxg-prc-cpg/dsh-weknora?label=dsh-weknora">
|
||||
</a>
|
||||
<a href="https://github.com/Tencent/WeKnora/blob/main/LICENSE">
|
||||
<img src="https://img.shields.io/badge/License-MIT-ffffff?labelColor=d4eaf7&color=2e6cc4" alt="License">
|
||||
</a>
|
||||
<a href="./CHANGELOG.md">
|
||||
<img alt="버전" src="https://img.shields.io/badge/version-0.8.0-2e6cc4?labelColor=d4eaf7">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
| <a href="./README.md"><b>English</b></a> | <a href="./README_CN.md"><b>简体中文</b></a> | <a href="./README_JA.md"><b>日本語</b></a> | <b>한국어</b> |
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<h4 align="center">
|
||||
|
||||
[개요](#-개요) • [아키텍처](#️-아키텍처) • [핵심 기능](#-핵심-기능) • [시작하기](#-시작하기) • [API 레퍼런스](#-api-레퍼런스) • [개발자 가이드](#-개발자-가이드)
|
||||
|
||||
</h4>
|
||||
</p>
|
||||
|
||||
# 💡 WeKnora — 문서를 살아있는 지식으로: RAG · Agent 추론 · 자동 Wiki 통합 LLM 지식 프레임워크
|
||||
|
||||
## 📌 개요
|
||||
|
||||
[**WeKnora**](https://weknora.weixin.qq.com)는 엔터프라이즈급 문서 이해, 시맨틱 검색, 자율 추론 시나리오를 위해 설계된 오픈소스 LLM 기반 지식 프레임워크입니다.
|
||||
|
||||
본 프레임워크는 **세 가지 핵심 역량**을 중심으로 구성됩니다. 일상 검색에 최적화된 **RAG 기반 빠른 Q&A**, 지식 검색·MCP 도구·**스킬 카탈로그**·세션 지속 **Docker / E2B / Cube 샌드박스**·웹 검색을 자율적으로 오케스트레이션하여 복잡한 다단계 작업을 처리하는 **ReAct Agent 추론**, 그리고 Agent가 원본 문서에서 상호 연결된 마크다운 지식베이스와 인터랙티브 지식 그래프를 스스로 생성·유지하는 완전히 새로운 **Wiki 모드**(수동 편집·버전 이력·원클릭 롤백 지원)입니다. **크로스 세션 장기 메모리**는 당신이 누구이며 무엇을 반복해서 묻는지 기억합니다. 지식 가공 과정도 세밀하게 제어할 수 있습니다. **트리형 폴더**가 업로드 시의 디렉터리 구조를 그대로 유지하고, **청크 편집 및 버전 이력**을 통해 검색 청크를 문서처럼 수정·비교·롤백할 수 있습니다. 다양한 데이터 소스 연동(Feishu 지식베이스 / Feishu 클라우드 드라이브 / GitLab / Tencent IMA / Notion / Yuque / RSS, 지속 확장 중), **웹사이트 임베드 Widget**으로 외부 사이트에 에이전트 게시, 프로그램 연동을 위한 **범위 지정 API 키 및 Principal 모델**, 워크스페이스별 **다중 인스턴스 스토리지 백엔드**, 20개 이상의 LLM 프로바이더 통합(LiteLLM 포함), Langfuse 기반 풀스택 관측 가능성과 **런타임 작업 큐 대시보드 + Worker 풀 거버넌스**, **엔터프라이즈 멀티 테넌트 RBAC(4단계 역할 매트릭스 + 리소스 소유권 + 테넌트 감사 로그)**, 완전 셀프호스팅이 가능한 모듈형 아키텍처를 결합하여, WeKnora는 흩어진 문서를 검색·추론 가능하며 지속적으로 진화하는 전용 지식 자산으로 탈바꿈시킵니다.
|
||||
|
||||
Feishu, GitLab, Tencent IMA, Notion, Yuque 등 외부 플랫폼에서 지식 자동 동기화를 지원하며(추가 데이터 소스 개발 중), PDF, Word, 이미지, Excel, XMind 등 10가지 이상의 문서 포맷을 처리합니다. WeChat Work, Feishu, Slack, Telegram 등의 IM 채널을 통해 Q&A 서비스를 직접 제공할 수 있습니다. 모델 레이어에서 OpenAI, DeepSeek, Qwen(Alibaba Cloud), Zhipu, Hunyuan, Gemini, MiniMax, NVIDIA, LiteLLM, Ollama 등 주요 프로바이더를 지원합니다. Office 문서는 **anydoc**으로 프로세스 내 파싱할 수 있습니다. 전체 프로세스가 모듈화 설계되어 LLM, 벡터 DB, 스토리지 등 구성 요소를 유연하게 교체 가능하며, 로컬 및 프라이빗 클라우드 배포를 지원하여 데이터 완전 자체 관리가 가능합니다. 또한 WeKnora는 **Langfuse**와 원활하게 통합되어 Agent 추론, 토큰 사용량 및 파이프라인에 대한 포괄적인 관측 가능성(Observability)을 제공합니다.
|
||||
|
||||
## ✨ 최신 업데이트
|
||||
|
||||
- **v0.8.0** — **스킬 샌드박스 런타임**(세션 지속 Docker / E2B / Cube 백엔드, 테넌트 단위 네트워크 정책; Local 호스트 프로세스 백엔드 제거; Docker는 옵트인); **테넌트 스킬 카탈로그**(ClawHub / SkillHub / git / zip에서 설치, 샌드박스별 스냅샷, 실시간 진행률, 파일 탐색/편집, 개인·워크스페이스 환경 변수); **크로스 세션 장기 메모리**(profile / preference / fact / task / interest, 확인이 필요한 자동 추출, `search_memory`); 프로세스 내 **anydoc** Office 파서; 공식 **DeepSeek Harness 플러그인** `@wxg-prc-cpg/dsh-weknora`; GitLab 및 Tencent IMA 데이터 소스; LiteLLM; Exa 및 Metaso 웹 검색; XMind 파싱; 채팅 산출물, 질문 개요, 타임스탬프; 컨텍스트 압축 및 프로바이더 Prompt Cache 마커. 또한 OIDC JWKS 검증, 선택적 복잡한 비밀번호, 문서 자동 태깅, 광범위한 샌드박스/보안 강화. 자세한 내용은 [`CHANGELOG.md`](./CHANGELOG.md).
|
||||
- **v0.7.2** — **공식 제품 문서 사이트** 공개(VitePress, 6개 섹션 약 50편으로 약 360개 API 엔드포인트와 약 150개 환경 변수를 다루며, 독립 Docker/Nginx 배포·빠른 시작 샘플 데이터·로컬 MCP 데모 포함); **지식베이스 폴더 트리**(업로드 경로를 독립 데이터로 저장하여 파일 관리자처럼 탐색·이름 변경·문서 재배치); **청크 편집 및 버전 이력**(UI에서 검색 청크 직접 편집, 버전별 diff와 롤백, 편집 후 인덱스 자동 재구축, 문서 커스텀 메타데이터); **Wiki 페이지 버전 이력**(스냅샷 + 라인 단위 diff + 원클릭 롤백 + 브라우저 내 수동 편집); **파일 직접 링크 모드** `resource_urls=public` / `RESOURCE_URL_MODE`(서드파티 앱이 인증 프록시를 다시 호출하지 않고 이미지와 파일을 렌더링); **Feishu 클라우드 드라이브 데이터 소스** 및 docx blocks API 동기화; 문서 일괄 태깅; **MCP Server 1.1.x**(mcp 2.x 고수준 API로 마이그레이션, 공식 PyPI 패키지 `tencent-weknora-mcp`, `create_knowledge_from_text`와 `list_shared_knowledge_bases` 추가로 총 29개 도구); AWS S3 기본 자격 증명 체인(IAM Role / IRSA); 로컬 HTML 업로드 파싱; QQBot Markdown 응답; app / frontend / docreader / mcp-server PR CI 검사 추가. 또한 router와 `modelcontext` 대규모 리팩터링, 리랭크·청킹 품질 개선 및 광범위한 안정성 수정. 자세한 내용은 [`CHANGELOG.md`](./CHANGELOG.md).
|
||||
- **v0.7.1** — 새로운 **Yunzhijia(云之家) IM 통합**(WebSocket + 이미지 메시지 + Markdown 응답); **Volcengine Rerank** 제공자(요청 자동 분할)와 **Zhipu AI 웹 검색** 제공자; 컨트롤 플레인 자동화를 위한 **플랫폼 범위 API 키**(테넌트 관리, 시스템 설정, 런타임 큐, 감사 로그); **KB 단위 활동 감사 추적**; FAQ 관리 강화(필터링, 태깅, 내보내기, 가져오기 결과 추적); **Langfuse OTLP/OTel 트레이싱** 마이그레이션 및 W3C traceparent 전파; 채팅 헤더 액션을 통한 원클릭 **Markdown 내보내기** 및 참조 드로어의 Wiki 도구 결과 표시; 프롬프트 캐시 가시성; 세션 채널 거버넌스(IM/임베드/API 세션을 관리자 범위로 분리); Feishu 대규모 Wiki 동기화 견고화; 레거시 Neo4j 대화 메모리 의존성 제거. 또한 광범위한 slug 무결성, SSRF 전송, 상태 동기화 강화. 자세한 내용은 [`CHANGELOG.md`](./CHANGELOG.md).
|
||||
- **v0.7.0** — 세분화된 **범위 지정 API 키 및 Principal 모델**(능력 단위 권한 + KB 단위 제한 + API 통합 플레이그라운드); **런타임 작업 큐 가시성 대시보드 및 Worker 풀 거버넌스**(단계별 풀 + 모델별 동시성 거버너 + 실패 작업 조사/재시도); **다중 인스턴스 스토리지 백엔드**(워크스페이스당 여러 스토리지 인스턴스, KB 단위 바인딩, 기본 인스턴스); **세션 범위 임시 첨부**(이미지/문서 비동기 파싱 + 통합 한도); 추천 질문 및 후속 질문; 안정적인 리소스 레지스트리 및 LLM 컨텍스트 별칭 압축; `@Skill / @MCP` 멘션 기반 범위 지정 Agent 런타임; 대화 중 MCP OAuth; QQBot 및 Lark(Feishu 국제판) IM 통합; Redis TLS; Requesty 모델 제공자 + Keenable 웹 검색; 테넌트리스 프로비저닝 및 제어된 셀프서비스 워크스페이스; 관리자 비밀번호 재설정; 지식 베이스 복제 플로우; `weknora` CLI v0.10. 또한 대규모 보안 강화(SSRF, 비밀 마스킹, SQL 검증, IDOR). 자세한 내용은 [`CHANGELOG.md`](./CHANGELOG.md).
|
||||
- **v0.6.3** — 웹사이트 임베드 Widget 및 통합 센터(보안 모드 Token 교환 + 속도 제한); 채팅 경험 전면 개편(인용 팝오버, RAG 파이프라인 진행, 스트리밍 Markdown); 문서 다중 태그 및 일괄 reparse; Wiki 폴더 및 계층 탐색; RSS 데이터 소스; MCP OAuth2; EPUB / MHTML 파싱; Agent 모델 준비 상태 검사; 모델 디버거; 세션 소스 필터; 워크스페이스 삭제 UI. 자세한 내용은 [`CHANGELOG.md`](./CHANGELOG.md).
|
||||
- **v0.6.2** — 업로드 단위 파싱 설정(`process_config`) + 업로드 확인 대화상자; reparse 시 설정 덮어쓰기; `weknora` CLI v0.9(번들 Agent Skills, `session stop`, auth/profile 통합); KB 마키 선택 다중 선택; pgvector 1024차원 HNSW 인덱스; 채팅 리소스 Store 리팩터; Langfuse 단일 추적(Jaeger 제거). 자세한 내용은 [`CHANGELOG.md`](./CHANGELOG.md).
|
||||
- **v0.6.1** — 문서 파싱 추적 타임라인(Langfuse 스타일 Span 트리, 단계별 진행 표시 + 파싱 중단); OpenSearch 벡터 저장소 드라이버; YAML 선언형 내장 모델 구성; 시스템 관리자와 통합 플랫폼 설정 + 감사 로그; 신규 사용자 온보딩 가이드; 설정 UI 리디자인; `weknora` CLI v0.7 / v0.8(Agent 우선 와이어 프로토콜, NDJSON, `--dry-run`); OpenDataLoader 및 PaddleOCR-VL 파싱 엔진; MCP 서버 멀티 트랜스포트(stdio / SSE / HTTP); 모델별 사고 모드 설정; Tencent LKEAP 리랭크 + 네이티브 Gemini 임베딩 + MiniMax-M3. 자세한 내용은 [`CHANGELOG.md`](./CHANGELOG.md) 참고.
|
||||
- **v0.6.0** — 테넌트 RBAC(4단계 역할 매트릭스 `Owner` / `Admin` / `Contributor` / `Viewer` + KB 단위 소유 + 테넌트별 감사 로그), 테넌트 멤버 관리와 멀티 워크스페이스 UX, 셀프 서비스 워크스페이스 생성; `weknora` CLI v0.4 GA + `mcp serve`; 여러 벡터 저장소에 걸친 KB 검색 팬아웃; MCP / 데이터 소스 자격 증명 AES-256-GCM 암호화 + docreader gRPC TLS + Token; Zhipu 임베더와 화웨이 클라우드 OBS 추가; 서버 사이드 사용자 환경설정; Go 1.26.0. 자세한 내용은 [`docs/RBAC说明.md`](./docs/RBAC说明.md)과 [`CHANGELOG.md`](./CHANGELOG.md) 참고.
|
||||
- **v0.5.2** — Wiki 인제스트가 만 건 규모 KB 지원(작업 큐 + DLQ); MCP 휴먼인더루프 도구 승인; Anthropic / Apache Doris / Tencent VectorDB / Kingsoft Cloud KS3 / SearXNG 백엔드; 적응형 3단계 청킹 + 라이브 미리보기; 글로벌 ⌘K 명령 팔레트; Yuque 커넥터 + WeChat 미니프로그램; `weknora` CLI 프리뷰.
|
||||
- **v0.5.1** — 지식베이스 일괄 관리; 테넌트 전체 IM 채널 개요; 세션 검색 + 사용자 단위 핀; 모델 / 웹 검색 / MCP 통일 카드 설정; Agent별 LLM 타임아웃; 데스크탑 테넌트 전환.
|
||||
- **v0.5.0** — Wiki 모드 GA — Agent가 원본 문서에서 구조화·상호 연결된 Markdown Wiki 페이지와 지식 그래프 자동 생성, Wiki 브라우저 및 시각화 그래프를 UI에 탑재.
|
||||
- **v0.4.0** — WeKnora Cloud(호스팅 LLM + 파싱); Chrome 확장 프로그램; ClawHub Skill; WeChat IM; 첨부 처리; Azure OpenAI / Alibaba OSS; Notion 커넥터; Baidu + Ollama 웹 검색; VectorStore 관리.
|
||||
- **v0.3.6** — ASR(음성); Feishu 데이터 소스 자동 동기화; OIDC; IM 인용 회신 + 스레드 기반 세션; 문서 자동 요약; Tavily 검색; 병렬 도구 호출; Agent @멘션 범위 제한.
|
||||
- **v0.3.5** — Telegram / DingTalk / Mattermost IM; IM 슬래시 커맨드 + QA 큐; 추천 질문; VLM에 의한 MCP 도구 이미지 자동 설명; Novita AI; 채널 추적.
|
||||
- **v0.3.4** — 기업 WeChat / Feishu / Slack IM; 멀티모달 이미지; NVIDIA 모델 API; Weaviate; AWS S3; AES-256-GCM API 키 암호화; 내장 MCP 서비스; 하이브리드 검색 최적화; `final_answer` 도구.
|
||||
- **v0.3.3** — 부모-자식 청킹; KB 핀; 폴백 응답; Rerank 패시지 클리닝; 스토리지 버킷 자동 생성; Milvus.
|
||||
- **v0.3.2** — 지식 검색 진입점; 소스별 파서 / 스토리지 엔진 설정; 로컬 스토리지 이미지 렌더링; 문서 미리보기; Volcengine TOS; Mermaid 렌더링; 대화 일괄 관리; 메모리 그래프 미리보기.
|
||||
- **v0.3.0** — 공유 스페이스; Agent Skills + 샌드박스 실행; 커스텀 Agent; 데이터 분석 Agent; 사고 모드; Bing / Google 검색; API Key 인증; Helm Chart; 한국어 i18n; Qdrant.
|
||||
- **v0.2.0** — Agent 모드(ReACT); 다중 타입 지식베이스(FAQ + 문서); 대화 전략 설정; DuckDuckGo 웹 검색; MCP 도구 통합; 새 UI + Agent 모드 전환; MQ 비동기 작업 관리.
|
||||
|
||||
|
||||
## 📱 기능 데모
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td colspan="2" align="center"><b>🛠️ 스킬 샌드박스 대화 · Word 생성 및 미리보기</b><br/><img src="./docs/images/skill-sandbox-chat.png" alt="스킬 샌드박스 대화: Word 문서 생성 및 미리보기" width="100%"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" align="center"><b>📦 스킬 카탈로그 · E2B 샌드박스에 설치</b><br/><img src="./docs/images/skill-catalog.png" alt="워크스페이스 스킬 카탈로그: docx / pptx / pdf가 E2B에 설치됨" width="100%"></td>
|
||||
<td width="50%" align="center"><b>🤖 Agent 모드 · 검색, 스킬 읽기, 샌드박스 파일 쓰기</b><br/><img src="./docs/images/agent-qa.png" alt="Agent가 지식베이스를 검색하고 docx 스킬을 읽은 뒤 샌드박스에 스크립트를 씀" width="100%"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" align="center"><b>💬 지능형 Q&A 대화</b><br/><img src="./docs/images/qa.png" alt="지능형 Q&A 대화" width="100%"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" align="center"><b>📖 Wiki 브라우저</b><br/><img src="./docs/images/wiki-browser.png" alt="Wiki 브라우저" width="100%"></td>
|
||||
<td width="50%" align="center"><b>🕸️ Wiki 지식 그래프</b><br/><img src="./docs/images/wiki-graph.png" alt="Wiki 지식 그래프" width="100%"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" align="center"><b>🕘 Wiki 페이지 버전 이력 및 롤백</b><br/><img src="./docs/images/wiki-revision-history.png" alt="Wiki 페이지 버전 이력 및 롤백" width="100%"></td>
|
||||
<td width="50%" align="center"><b>✂️ 청크 편집 및 버전 이력</b><br/><img src="./docs/images/kb-chunk-edit.png" alt="청크 편집 및 버전 이력" width="100%"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" align="center"><b>📁 폴더 트리 및 일괄 작업</b><br/><img src="./docs/images/kb-document-list.png" alt="지식베이스 폴더 트리 및 일괄 작업" width="100%"></td>
|
||||
<td width="50%" align="center"><b>🔭 관측 가능성 · Langfuse Tracing</b><br/><img src="./docs/images/langfuse.png" alt="Langfuse Tracing" width="100%"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 🏗️ 아키텍처
|
||||
|
||||

|
||||
|
||||
문서 파싱, 벡터화, 검색부터 LLM 추론까지 전체 파이프라인을 모듈화하여 각 구성 요소를 유연하게 교체·확장 가능합니다. 로컬 / 프라이빗 클라우드 배포를 지원하며, 데이터 완전 자체 관리와 진입 장벽 없는 Web UI로 빠르게 시작할 수 있습니다.
|
||||
|
||||
## 📊 적용 시나리오
|
||||
|
||||
| 시나리오 | 적용 사례 | 핵심 가치 |
|
||||
|---------|----------|----------|
|
||||
| **기업 지식 관리** | 내부 문서 검색, 규정 Q&A, 운영 매뉴얼 조회 | 지식 탐색 효율 향상, 교육 비용 절감 |
|
||||
| **학술 연구 분석** | 논문 검색, 연구 리포트 분석, 학술 자료 정리 | 문헌 조사 가속, 연구 의사결정 지원 |
|
||||
| **제품 기술 지원** | 제품 매뉴얼 Q&A, 기술 문서 검색, 트러블슈팅 | 고객 지원 품질 향상, 지원 부담 감소 |
|
||||
| **법무/컴플라이언스 검토** | 계약 조항 검색, 규제 정책 조회, 사례 분석 | 컴플라이언스 효율 향상, 법적 리스크 감소 |
|
||||
| **의료 지식 지원** | 의학 문헌 검색, 진료 가이드라인 조회, 증례 분석 | 임상 의사결정 지원, 진단 품질 향상 |
|
||||
|
||||
## 🧩 기능 개요
|
||||
|
||||
**지능형 대화**
|
||||
|
||||
| 기능 | 상세 |
|
||||
|------|------|
|
||||
| 지능형 추론 | ReACT 점진적 멀티스텝 추론, 지식 검색·MCP 도구·스킬 샌드박스·웹 검색을 자율 오케스트레이션 |
|
||||
| 빠른 Q&A | 지식베이스 기반 RAG Q&A, 빠르고 정확한 답변 |
|
||||
| Wiki 모드 | Agent가 주도하여 원본 문서에서 구조화된 마크다운 Wiki 페이지를 자동 생성 및 유지 관리; 브라우저 내 수동 편집, 페이지 버전 이력, 라인 단위 diff 및 원클릭 롤백 |
|
||||
| 스킬 카탈로그와 샌드박스 | 워크스페이스 스킬 카탈로그(ClawHub / SkillHub / git / zip)를 세션 지속 Docker / E2B / Cube 샌드박스에 설치; `shell_exec`, 파일 도구, 산출물, 설정 단위 네트워크 정책; Local 호스트 프로세스 백엔드 제거 |
|
||||
| 장기 메모리 | 크로스 세션 메모리(profile / preference / fact / task / interest), 자동 추출, 사용자 확인, 온디맨드 `search_memory` |
|
||||
| 도구 호출 | 내장 도구, MCP 도구(OAuth2 원격 서비스·대화 중 OAuth 포함), 웹 검색; `@Skill / @MCP` 멘션으로 턴 단위 Agent 런타임 범위 지정 |
|
||||
| 대화 전략 | 온라인 프롬프트 편집, 검색 임계값 조정, 멀티턴 문맥 인식, Agent별 인용 출력 토글 |
|
||||
| 추천 질문 | 지식베이스 콘텐츠 기반 질문 자동 생성 및 답변 후 후속 질문 |
|
||||
| 임시 첨부 | 세션 범위로 이미지 / 문서를 업로드하고 비동기 파싱하여 일회성 Q&A에 사용(이미지 + 첨부 통합 한도) |
|
||||
| 인용 및 RAG 진행 | 인라인 인용 팝오버 및 인용 드로어(웹 / KB 소스 구분), 통합 Markdown 렌더링, RAG 파이프라인 단계별 진행 표시 |
|
||||
| 세션 관리 | 사이드바에서 소스별(Web / IM / 임베드) 세션 필터 및 그룹화, 세션 제목 인라인 이름 변경 지원 |
|
||||
|
||||
**지식 관리**
|
||||
|
||||
| 기능 | 상세 |
|
||||
|------|------|
|
||||
| 지식베이스 타입 | FAQ / 문서 / Wiki, 폴더 임포트·URL 임포트·다중 태그 관리·온라인 입력 |
|
||||
| 폴더 트리 | 폴더 업로드 시 원본 디렉터리 구조를 유지하고, 사이드바 트리 탐색·폴더 이름 변경·문서를 다른 폴더로 재배치 지원 |
|
||||
| 청크 편집 및 버전 | UI에서 검색 청크를 직접 편집하고 버전별 스냅샷·diff·원클릭 롤백, 편집 후 인덱스 자동 재구축; 생성 질문 추가·수정·삭제·재생성; 문서 커스텀 메타데이터 지원 |
|
||||
| 업로드 단위 파싱 설정 | 업로드 확인 대화상자 또는 `process_config` API로 파서·청킹·멀티모달(VLM / ASR)·그래프 추출·질문 생성을 배치 단위로 덮어쓰기; reparse 시 설정 변경 지원 |
|
||||
| 일괄 reparse | 여러 문서의 파싱을 한 번에 재큐잉, 배치 단위 `process_config` 지원 |
|
||||
| 데이터 소스 임포트 | Feishu 지식베이스 / Feishu 클라우드 드라이브 / Lark / GitLab / Tencent IMA / Notion / Yuque / RSS 피드 자동 동기화(추가 데이터 소스 개발 중), 증분·전체 동기화 지원 |
|
||||
| 문서 포맷 | PDF / Word / Txt / Markdown / HTML / EPUB / MHTML / 이미지 / CSV / Excel / PPT / JSON / XMind |
|
||||
| 자동 태깅 | 파싱 후 지식베이스 기존 태그에서 일치 항목을 증분 연결(새 태그 생성·수동 태그 덮어쓰기 없음) |
|
||||
| 검색 전략 | BM25 희소 / Dense 밀집 / GraphRAG 그래프 강화 / 부모-자식 청킹 / pgvector HNSW 가속(1024차원) / 다차원 인덱싱 |
|
||||
| 일괄 선택 및 태깅 | KB 목록에서 마키(드래그) 다중 선택으로 일괄 reparse 및 일괄 태깅(공통 태그 자동 선택) |
|
||||
| E2E 테스트 | 전체 파이프라인 시각화, 리콜 적중률·BLEU / ROUGE 지표 평가 |
|
||||
|
||||
**연동 및 확장**
|
||||
|
||||
| 기능 | 상세 |
|
||||
|------|------|
|
||||
| LLM | OpenAI / Azure OpenAI / Anthropic (Claude) / DeepSeek / Qwen (Alibaba Cloud) / Zhipu / Hunyuan / Doubao (Volcengine) / Gemini / MiniMax / NVIDIA / Novita AI / SiliconFlow / OpenRouter / Requesty / LiteLLM / Ollama |
|
||||
| Embedding | Ollama / BGE / GTE / OpenAI 호환 API |
|
||||
| 벡터 DB | PostgreSQL (pgvector) / Elasticsearch / OpenSearch / Milvus / Weaviate / Qdrant / Apache Doris / Tencent VectorDB |
|
||||
| 오브젝트 스토리지 | 로컬 / MinIO / AWS S3(IAM Role / IRSA 기본 자격 증명 체인 지원) / Volcengine TOS / Alibaba Cloud OSS / Kingsoft Cloud KS3 / Huawei Cloud OBS; **워크스페이스당 여러 스토리지 인스턴스**, KB 단위 바인딩 및 기본 인스턴스 |
|
||||
| IM 통합 | WeChat Work / Feishu / Lark(Feishu 국제판) / QQBot / Slack / Telegram / DingTalk / Mattermost / WeChat / Yunzhijia |
|
||||
| 웹사이트 임베드 | 임베드 Widget으로 에이전트 게시, 도메인 허용 목록·속도 제한·보안 모드 Token 교환 |
|
||||
| 웹 검색 | DuckDuckGo / Bing / Google / Tavily / Baidu / Ollama / SearXNG / Keenable / Zhipu AI / Exa / Metaso |
|
||||
| API 통합 | 범위 지정 API 키(능력 단위 권한 + KB 단위 제한 + 스로틀링된 last_used 추적)와 API 통합 플레이그라운드; MCP OAuth 및 임베드 세션을 Principal 단위로 격리; `resource_urls=public`으로 바로 로드 가능한 파일 / 이미지 URL을 반환하여 인증 프록시 재호출 제거 |
|
||||
| MCP Server | 공식 PyPI 패키지 `tencent-weknora-mcp`, 29개 도구, stdio / SSE / HTTP 3가지 트랜스포트 지원 |
|
||||
|
||||
**플랫폼**
|
||||
|
||||
| 기능 | 상세 |
|
||||
|------|------|
|
||||
| 배포 | 로컬 / Docker / Kubernetes (Helm), 프라이빗/오프라인 배포 지원 |
|
||||
| UI | Web UI / RESTful API / CLI (`weknora`) / Chrome Extension / 웹사이트 임베드 Widget / WeChat 미니 프로그램 |
|
||||
| 관측 가능성 | Langfuse(단일 추적 백엔드)로 ReAct 루프·토큰 소비·도구 호출·파이프라인 추적; Langfuse 스타일의 문서 파싱 추적 타임라인 내장으로 단계별 진행 표시; 시스템 관리자용 런타임 작업 큐 대시보드(큐 깊이·모델별 동시성·실패 작업 조사 및 수동 재시도) |
|
||||
| 작업 관리 | MQ 비동기 작업, 단계별 Worker 풀 거버넌스(core / 후처리 / enrichment / maintenance + 탄력적 공유 풀, Wiki 독립 풀)와 모델별 백그라운드 동시성 거버너; 버전 업그레이드 시 자동 DB 마이그레이션 |
|
||||
| 모델 관리 | 중앙 설정, YAML 선언형 내장 모델 구성, 지식베이스별 모델 선택, 모델별 사고 모드·Embedding 차원 덮어쓰기, 대화형 모델 디버거, 멀티테넌트 내장 모델 공유, WeKnora Cloud 호스팅 모델 및 문서 파싱 |
|
||||
|
||||
## 🧩 Chrome 확장 프로그램
|
||||
|
||||
[**WeKnora Chrome 확장 프로그램**](https://chromewebstore.google.com/detail/jpemjbopikggjlmikmclgbmkhhopjdgd)을 사용하면 브라우저에서 웹 콘텐츠를 WeKnora 지식베이스에 직접 캡처할 수 있습니다. 텍스트, 이미지 또는 전체 페이지를 선택하고 원클릭으로 지식 항목으로 저장 — 복사/붙여넣기나 파일 업로드 불필요.
|
||||
|
||||
|
||||
## 🦞 ClawHub Skill
|
||||
|
||||
[**WeKnora ClawHub Skill**](https://clawhub.ai/lyingbug/weknora)은 ClawHub 플랫폼에 게시된 WeKnora 스킬입니다. 설치 후 WeKnora REST API를 통해 문서 업로드(파일 / URL / Markdown), 하이브리드 검색(벡터 + 키워드), 지식 항목 관리가 가능합니다.
|
||||
|
||||
- **문서 임포트** — 에이전트를 통한 파일 업로드, 웹페이지 임포트, Markdown 지식 작성
|
||||
- **하이브리드 검색** — 단일 또는 다중 지식베이스에서 벡터 + 키워드 통합 검색
|
||||
- **지식 관리** — 프로그래밍 방식으로 지식 항목 조회, 편집, 삭제
|
||||
|
||||
## 🐋 DeepSeek Harness 플러그인
|
||||
|
||||
[**`@wxg-prc-cpg/dsh-weknora`**](https://www.npmjs.com/package/@wxg-prc-cpg/dsh-weknora)는 공식 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(`dsh`) 플러그인입니다([문서](./packages/dsh-weknora/README.md)). harness 자체에는 검색·임베딩·지식베이스 기능이 없으므로, 이 플러그인이 코딩 에이전트에 사내 문서를 제공합니다. `dsh plugin --profile web add @wxg-prc-cpg/dsh-weknora`로 설치하고 배포 주소를 지정하면 네 개의 읽기 전용 도구가 에이전트 도구 목록에 나타납니다.
|
||||
|
||||
- **`weknora_search`** — 하이브리드 검색. 원문 구절을 그대로 반환하며 각 항목에 재사용 가능한 `knowledge_id` 포함
|
||||
- **`weknora_read_document`** — 한 문서의 청크를 순서대로 재조합, 페이징 지원
|
||||
- **`weknora_ask`** — WeKnora가 직접 작성한 인용 포함 답변(RAG 또는 ReAct 파이프라인)
|
||||
- **`weknora_list_knowledge_bases`** — 지식베이스 이름과 id로 에이전트가 검색 범위를 스스로 좁힘
|
||||
|
||||
|
||||
## 🚀 시작하기
|
||||
|
||||
### 🛠 사전 준비
|
||||
|
||||
- [Docker](https://www.docker.com/) & [Docker Compose](https://docs.docker.com/compose/)
|
||||
- [Git](https://git-scm.com/)
|
||||
|
||||
### 📦 설치 및 실행
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Tencent/WeKnora.git
|
||||
cd WeKnora
|
||||
cp .env.example .env # 필요에 따라 .env 편집 (파일 내 주석 참고)
|
||||
docker compose pull # 최신 이미지 가져오기
|
||||
docker compose up -d # 코어 서비스 시작
|
||||
```
|
||||
|
||||
시작 후 **http://localhost** 에 접속하여 바로 사용 가능합니다.
|
||||
|
||||
> 로컬 Ollama 모델을 사용하려면 먼저 `ollama serve > /dev/null 2>&1 &` 를 실행하세요.
|
||||
|
||||
### 🔄 업그레이드
|
||||
|
||||
기존 배포가 있고 새 release를 다운로드한 경우:
|
||||
|
||||
```bash
|
||||
# .env에서 WEKNORA_VERSION을 대상 버전(예: 0.7.0)으로 설정하거나 latest 유지
|
||||
docker compose pull # WEKNORA_VERSION에 맞는 이미지 가져오기
|
||||
docker compose up -d # 새 이미지로 컨테이너 재생성
|
||||
```
|
||||
|
||||
> `docker compose up -d`만 실행하면 로컬 캐시 이미지가 재사용되어 Web UI 버전이 다운로드한 release와 일치하지 않을 수 있습니다.
|
||||
|
||||
### 🔧 선택 서비스 (Docker Compose Profile)
|
||||
|
||||
`--profile` 플래그로 추가 컴포넌트를 활성화합니다. 여러 profile 조합 가능:
|
||||
|
||||
| Profile | 설명 | 명령어 |
|
||||
|---------|------|--------|
|
||||
| _(기본)_ | 코어 서비스 | `docker compose pull && docker compose up -d` |
|
||||
| `full` | 전체 기능 | `docker compose --profile full pull && docker compose --profile full up -d` |
|
||||
| `neo4j` | 지식 그래프 (Neo4j) | `docker compose --profile neo4j pull && docker compose --profile neo4j up -d` |
|
||||
| `minio` | 오브젝트 스토리지 (MinIO) | `docker compose --profile minio pull && docker compose --profile minio up -d` |
|
||||
| `langfuse` | 트레이싱 (Langfuse) | `docker compose --profile langfuse pull && docker compose --profile langfuse up -d` |
|
||||
|
||||
조합 예시: `docker compose --profile neo4j --profile minio pull && docker compose --profile neo4j --profile minio up -d`
|
||||
|
||||
서비스 중지: `docker compose down`
|
||||
|
||||
### 🌐 서비스 주소
|
||||
|
||||
| 서비스 | URL |
|
||||
|--------|-----|
|
||||
| Web UI | `http://localhost` |
|
||||
| 백엔드 API | `http://localhost:8080` |
|
||||
| Langfuse 트레이싱 | `http://localhost:3000` |
|
||||
|
||||
## 문서 지식 그래프
|
||||
|
||||
WeKnora는 문서를 지식 그래프로 변환해 문서 내 서로 다른 섹션 간 관계를 시각화할 수 있습니다. 지식 그래프 기능을 활성화하면 문서 내부의 시맨틱 연관 네트워크를 분석/구성하여 문서 이해를 돕고, 인덱싱과 검색에 구조화된 지원을 제공해 검색 결과의 관련성과 폭을 향상시킵니다.
|
||||
|
||||
자세한 설정은 [지식 그래프 설정 가이드](./docs/KnowledgeGraph.md)를 참고하세요.
|
||||
|
||||
## MCP 서버
|
||||
|
||||
필요한 설정은 [MCP 설정 가이드](./mcp-server/MCP_CONFIG.md)를 참고하세요.
|
||||
|
||||
## 🔌 WeChat 대화 오픈 플랫폼 사용
|
||||
|
||||
WeKnora는 [WeChat 대화 오픈 플랫폼](https://chatbot.weixin.qq.com)의 핵심 기술 프레임워크로 사용되며, 보다 간편한 사용 방식을 제공합니다:
|
||||
|
||||
- **노코드 배포**: 지식을 업로드하기만 하면 WeChat 생태계에서 지능형 Q&A 서비스를 빠르게 배포하여 "질문 즉시 응답" 경험을 구현
|
||||
- **효율적인 질문 관리**: 고빈도 질문의 분류 관리 지원, 풍부한 데이터 도구를 통해 정확하고 신뢰할 수 있으며 유지보수하기 쉬운 답변 제공
|
||||
- **WeChat 생태계 통합**: WeChat 공식계정, 미니프로그램 등 다양한 시나리오에 WeKnora의 Q&A 역량을 자연스럽게 통합
|
||||
|
||||
|
||||
## 📘 API 레퍼런스
|
||||
|
||||
**공식 제품 문서**: [`website-docs/`](./website-docs/README.md) — 「입문 → 아키텍처 → 기능 → API → 클라이언트 → 개발」 6개 섹션으로 구성된 완전한 문서 세트로, 약 360개 API 엔드포인트, 약 150개 환경 변수, 9개 확장 지점을 다룹니다. 이 디렉터리는 VitePress 사이트이기도 하여 `cd website-docs && npm install && npm run dev`로 로컬 미리보기가 가능하며, 디렉터리 내 `Dockerfile`로 단독 배포할 수도 있습니다.
|
||||
|
||||
문제 해결 FAQ: [문제 해결 FAQ](./docs/QA.md)
|
||||
|
||||
상세 API 문서: [API Docs](./docs/api/README.md)
|
||||
|
||||
제품 계획 및 예정 기능: [Roadmap](./docs/ROADMAP.md)
|
||||
|
||||
## 🧭 개발자 가이드
|
||||
|
||||
### ⚡ 고속 개발 모드(권장)
|
||||
|
||||
코드를 자주 수정해야 한다면 **매번 Docker 이미지를 다시 빌드할 필요가 없습니다**. 고속 개발 모드를 사용하세요.
|
||||
|
||||
```bash
|
||||
# 인프라 시작
|
||||
make dev-start
|
||||
|
||||
# 백엔드 시작 (새 터미널)
|
||||
make dev-app
|
||||
|
||||
# 프론트엔드 시작 (새 터미널)
|
||||
make dev-frontend
|
||||
```
|
||||
|
||||
**개발 장점:**
|
||||
- ✅ 프론트엔드 변경 자동 핫리로드(재시작 불필요)
|
||||
- ✅ 백엔드 변경 빠른 재시작(5~10초, Air 핫리로드 지원)
|
||||
- ✅ Docker 이미지 재빌드 불필요
|
||||
- ✅ IDE 브레이크포인트 디버깅 지원
|
||||
|
||||
**상세 문서:** [개발 환경 빠른 시작](./docs/开发指南.md)
|
||||
|
||||
## 🤝 기여하기
|
||||
|
||||
[Issue](https://github.com/Tencent/WeKnora/issues) 또는 Pull Request를 환영합니다.
|
||||
|
||||
**절차:** Fork → 브랜치 생성 → 변경사항 커밋 → PR 생성
|
||||
|
||||
**규칙:** `gofmt`로 코드 포맷팅, [Conventional Commits](https://www.conventionalcommits.org/) 준수 (`feat:` / `fix:` / `docs:` / `test:` / `refactor:`)
|
||||
|
||||
## 🔒 보안 공지
|
||||
|
||||
**중요:** v0.1.3부터 WeKnora는 시스템 보안 강화를 위해 로그인 인증 기능을 포함합니다. 운영 환경 배포 시 아래 사항을 강력히 권장합니다.
|
||||
|
||||
- WeKnora 서비스를 공용 인터넷이 아닌 내부/사설 네트워크 환경에 배포
|
||||
- 잠재적 정보 유출 방지를 위해 서비스를 공용 네트워크에 직접 노출하지 않기
|
||||
- 배포 환경에 적절한 방화벽 규칙 및 접근 제어 구성
|
||||
- 보안 패치와 개선 사항 적용을 위해 최신 버전으로 정기 업데이트
|
||||
|
||||
## 👥 기여자
|
||||
|
||||
멋진 기여자 여러분께 감사드립니다:
|
||||
|
||||
[](https://github.com/Tencent/WeKnora/graphs/contributors)
|
||||
|
||||
## 📄 라이선스
|
||||
|
||||
이 프로젝트는 [MIT License](./LICENSE)로 배포됩니다.
|
||||
적절한 저작권 고지를 유지하는 조건으로 코드를 자유롭게 사용, 수정, 배포할 수 있습니다.
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
# Security Policy
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
The WeKnora team takes security vulnerabilities seriously.
|
||||
We appreciate your efforts to responsibly disclose any security issues you discover.
|
||||
|
||||
⚠️ **Please do NOT report security vulnerabilities through public GitHub issues.**
|
||||
|
||||
### Preferred reporting method
|
||||
|
||||
We recommend reporting security vulnerabilities using GitHub’s private vulnerability reporting feature:
|
||||
|
||||
1. Go to the **Security** tab of this repository
|
||||
2. Click **“Report a vulnerability”**
|
||||
3. Fill in the details and submit the report
|
||||
|
||||
This allows us to discuss, investigate, and fix the issue privately.
|
||||
|
||||
### Alternative contact
|
||||
|
||||
If you are unable to use GitHub’s Security Advisory feature, you may contact the maintainers through the repository owners.
|
||||
|
||||
> Please avoid sharing sensitive information publicly.
|
||||
|
||||
### What to include in your report
|
||||
|
||||
To help us understand and resolve the issue quickly, please include:
|
||||
|
||||
- A clear description of the vulnerability
|
||||
- Steps to reproduce (proof-of-concept if available)
|
||||
- The affected version(s)
|
||||
- Potential impact and severity
|
||||
- Any suggested mitigations or fixes (if known)
|
||||
|
||||
### Response timeline
|
||||
|
||||
We aim to:
|
||||
- Acknowledge receipt of your report within **48 hours**
|
||||
- Provide a status update as the investigation progresses
|
||||
|
||||
### Coordinated disclosure
|
||||
|
||||
We kindly ask reporters to follow responsible disclosure practices and allow us reasonable time to address the issue before any public disclosure.
|
||||
|
||||
Thank you for helping keep **WeKnora** and its users secure.
|
||||
@@ -0,0 +1,64 @@
|
||||
# Native workbench implementation contract
|
||||
|
||||
Brand: 私域Agent. Base API: `/api/v1/workbench`. All routes use the existing
|
||||
WeKnora auth middleware and tenant RBAC; no FastAPI calls or shared admin JWT.
|
||||
|
||||
## HTTP
|
||||
|
||||
- `GET /modules`: enabled module metadata.
|
||||
- `GET /resources/:kind`: `{success:true,data:Resource[],total:number}`; page/page_size/q.
|
||||
- `POST /resources/:kind`: `{name,data}` -> Resource.
|
||||
- `GET /resources/:kind/:id`: Resource.
|
||||
- `PUT /resources/:kind/:id`: `{name,data,version}` -> Resource; optimistic locking.
|
||||
- `DELETE /resources/:kind/:id?detach=true`: delete owned resource, optional explicit detach.
|
||||
- `POST /resources/:kind/:id/actions/:action`: `{version, advice?, day?, segment_id?, agent_id?}`
|
||||
-> `{success:true,data:Job}` for asynchronous AI work, Resource for synchronous work.
|
||||
- `GET /jobs/:id`: Job including progress, stage, error and generation usage metadata.
|
||||
- `GET /notifications`, `POST /notifications/read-all`.
|
||||
|
||||
Resource: `{id,tenant_id,owner_id,owner_name,kind,name,status,version,data,created_at,updated_at}`.
|
||||
Kinds in this package: `training-maps`, `content-tasks`, `flows`.
|
||||
Digital humans are implemented separately as typed `/api/v1/digital-humans` routes;
|
||||
skills and memories reuse native modules and receive extensions separately.
|
||||
IDs are UUIDs, owner/tenant derived from auth. Defaults private. Admin can inspect all
|
||||
resources in the active tenant, while mutations, deletion, queued AI actions and
|
||||
bindings remain owner-only. Data is versioned JSON with per-kind validation.
|
||||
|
||||
Job: `{id,resource_id,kind,action,status,progress,stage,error,generation,created_at,updated_at}`.
|
||||
Status queued/running/succeeded/failed/cancelled. Progress measures completed stages;
|
||||
the UI must not display invented countdowns. Concurrent writes use resource version.
|
||||
|
||||
## Payloads
|
||||
|
||||
- training-maps: `{topic,days_count,agent_id,knowledge_base_ids:[],knowledge_ids:[],days:[{day,title,prerequisite,hook,nodes:[{id,label,children:[]}],framework,confirmed}],advice}`.
|
||||
Actions generate/revise (optional day)/generate-day (day)/confirm-day (day)/confirm.
|
||||
- flows: `{nodes:[{id,agent_id,label}],edges:[{source,target}],description}`.
|
||||
- content-tasks: `{map_id,day,flow_id,agent_id,knowledge_base_ids:[],segments:[{id,title,agent_id,status,content,review,source_ids:[]}],review,composed}`.
|
||||
Actions decompose/generate (segment_id)/accept/skip/revise (segment_id + advice)/review/compose.
|
||||
- digital-humans: `{description,source_text,agent_id,model_id,profile,versions:[]}`.
|
||||
Actions distill (advice optional)/approve. Linked native CustomAgent created automatically.
|
||||
- instruction skills: `/api/v1/skills/instructions` lists/reads creator-owned Markdown rules;
|
||||
`POST /instructions/import` accepts UTF-8 `.md` (256 KB, 16k-token rule limit), and
|
||||
`POST /instructions/mount` accepts `agent_ids`, `skill_ids` and `mode` (`add`, `remove`,
|
||||
`replace`). Rules are creator-private, never executed as code, and are injected into
|
||||
native chat and workbench generation after access checks. Executable sandbox skills
|
||||
remain governed by the native catalog and are separate from these rules.
|
||||
- memories: `{agent_id,content,source}`.
|
||||
|
||||
## Go package contract
|
||||
|
||||
Package `internal/workbench`. Root owns `service.go`, `models.go`, `handler.go`,
|
||||
`jobs.go`, `generation.go`, content/map operations, router/container wiring.
|
||||
Resources have `Data JSON` (`map[string]any` implementing SQL Scanner/Valuer).
|
||||
Service fields: `db *gorm.DB`, `models interfaces.ModelService`,
|
||||
`agents interfaces.CustomAgentService`, `sessions interfaces.SessionService`.
|
||||
Helpers: `s.get(ctx,kind,id) (*Resource,error)`, `s.save(ctx,*Resource,expectedVersion) error`,
|
||||
`s.complete(ctx,agentID,system,prompt string) (string,error)`,
|
||||
`s.progress(ctx,jobID string,percent int,stage string) error`.
|
||||
Digital humans use separate types/interfaces/repository/service/handler and migration
|
||||
000091. Their default Agent is private and created transactionally; review syncs its
|
||||
persona, and deletion cleans its default Agent, bindings and IM channels. Workbench
|
||||
generation resolves mounted instruction skills through an injected resolver.
|
||||
|
||||
Implementation status must be recorded honestly. Persisted CRUD alone is not a
|
||||
complete port of a legacy workflow. Keep original data until verified ETL and parity.
|
||||
@@ -0,0 +1,4 @@
|
||||
bin/
|
||||
coverage.out
|
||||
ROADMAP.md
|
||||
ROADMAP.html
|
||||
+622
@@ -0,0 +1,622 @@
|
||||
# AGENTS.md
|
||||
|
||||
This is the WeKnora CLI (`weknora`), a command-line client for the WeKnora RAG server. The module path is `github.com/Tencent/WeKnora/cli`.
|
||||
|
||||
The wire contract for AI agents *consuming* `weknora` output (JSON shape, exit codes, error format) is documented below and in [README.md](README.md). Read this file if you're integrating with the CLI binary — build / test / architecture details follow the wire contract sections.
|
||||
|
||||
## Wire contract for AI agents
|
||||
|
||||
This CLI's primary consumers include AI agents (Claude Code, Cursor, Gemini CLI,
|
||||
etc.). Output format is the agent-facing API. **Every error message and every
|
||||
JSON field you write becomes part of an agent's decision-making input.**
|
||||
|
||||
### Stdout (success path)
|
||||
|
||||
All `--format json` (default) commands emit a symmetric envelope. Optional
|
||||
fields are `omitempty` — they only appear when populated:
|
||||
|
||||
```json
|
||||
// list (kb list, doc list, ...) — data is an array, meta carries count
|
||||
{
|
||||
"ok": true,
|
||||
"data": [ {"id": "kb_abc", "name": "prod"} ],
|
||||
"meta": {"count": 1},
|
||||
"profile": "prod"
|
||||
}
|
||||
|
||||
// single resource (kb view, doc view, ...) — data is an object
|
||||
{
|
||||
"ok": true,
|
||||
"data": {"id": "kb_abc", "name": "prod", "description": "..."},
|
||||
"profile": "prod"
|
||||
}
|
||||
|
||||
// mutation success with no payload (some delete / edit paths)
|
||||
{"ok": true, "profile": "prod"}
|
||||
```
|
||||
|
||||
`data` is omitted on mutation-only success (no payload). `meta` carries list
|
||||
counters (`count`, `has_more`) and batch successes/failures, and is omitted
|
||||
when empty. `meta.next_cursor`, `meta.total_count`, and `meta.request_id` are
|
||||
reserved — not currently populated; planned for v0.8 when the SDK exposes
|
||||
pagination cursors and response headers. `_notice` is reserved — open-map
|
||||
infrastructure is in place for deprecation / version_skew / security notices;
|
||||
the field is omitted until a producer is wired in v0.8. `profile` echoes the
|
||||
resolved profile name and is omitted when no profile is configured.
|
||||
|
||||
### Stderr (error path)
|
||||
|
||||
Errors emit an error envelope on stderr (`--format json`) or prose
|
||||
`code: message\nhint: ...\nretry: ...` (`--format text`):
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"error": {
|
||||
"type": "auth.unauthenticated",
|
||||
"message": "fetch current user: HTTP error 401",
|
||||
"hint": "run `weknora auth login`",
|
||||
"retry_argv": ["weknora", "auth", "login"],
|
||||
"retry_after_seconds": 0,
|
||||
"risk": {"level": "destructive", "action": "noun.verb"},
|
||||
"detail": {}
|
||||
},
|
||||
"_notice": {}
|
||||
}
|
||||
```
|
||||
|
||||
`type` is the typed code (see [Error code reference](#error-code-reference)
|
||||
below). `hint` is prose; `retry_argv` is the suggested next argv as a JSON
|
||||
string array. For non-destructive errors agents may execute it; on exit-10
|
||||
(`input.confirmation_required`) it is informational only — the human must
|
||||
approve the destructive write explicitly. See "Exit-10 anti-patterns" for
|
||||
details.
|
||||
`retry_after_seconds` mirrors HTTP `Retry-After`. `risk` tags high-risk writes.
|
||||
`detail` carries structured per-error context (e.g. `unknown_subcommand`'s
|
||||
`available[]` list).
|
||||
|
||||
### NDJSON event stream (chat / session ask)
|
||||
|
||||
`--format json` and `--format ndjson` both produce one JSON event per line —
|
||||
no envelope wrapping. The CLI injects exactly one event (`init`) at the head;
|
||||
all subsequent events pass through verbatim from the SDK:
|
||||
|
||||
```
|
||||
{"type":"init","session_id":"...","kb_id":"...","profile":"...","agent_id":"..."}
|
||||
{"type":"thinking","content":"..."}
|
||||
{"type":"answer","content":"Hello"}
|
||||
{"type":"tool_call","name":"...","input":{}}
|
||||
{"type":"complete","done":true}
|
||||
```
|
||||
|
||||
For prose rendering, pass `--format text`.
|
||||
|
||||
### `_notice` evolution policy
|
||||
|
||||
`_notice` is an open map. New keys are **additive non-breaking**; agents MUST
|
||||
ignore unknown keys. v0.7 reserves three keys: `deprecation` / `version_skew` /
|
||||
`security`. New keys follow snake_case convention. The `_notice` field is
|
||||
currently always empty — producer wiring is planned for v0.8 when the SDK
|
||||
exposes version metadata. The wire infrastructure is in place so adding a
|
||||
producer in v0.8 will not change the envelope shape.
|
||||
|
||||
### CLI vs server SDK contract boundary
|
||||
|
||||
CLI 1.0 contract covers:
|
||||
- ✅ Envelope wire shape (success + error)
|
||||
- ✅ CLI-injected events (`init` only)
|
||||
- ✅ NDJSON line shape (bare `{type:...,...}`)
|
||||
- ✅ Passthrough discipline (CLI doesn't rename SDK events)
|
||||
|
||||
CLI 1.0 contract does NOT cover:
|
||||
- ❌ Specific SDK event names (`answer` / `tool_call` / `complete` / ...)
|
||||
- ❌ SDK event field shapes (server's own version contract)
|
||||
|
||||
The CLI contract and the server's wire contract are versioned independently:
|
||||
agents pin against the CLI surface, while server-side event shape evolves on
|
||||
its own track.
|
||||
|
||||
### The one rule
|
||||
|
||||
Every error message you write will be parsed by an AI to decide its next action.
|
||||
Make errors structured, actionable, and specific.
|
||||
|
||||
### Environment variables
|
||||
|
||||
| Variable | Purpose |
|
||||
|---|---|
|
||||
| `WEKNORA_PROFILE` | Active profile name. Equivalent to the global `--profile <name>` flag; overridden by `--profile`. Useful in CI scripts that cannot pass global flags. |
|
||||
| `WEKNORA_FORMAT` | Default `--format` value (`text \| json \| ndjson`). Overridden by explicit `--format`. Invalid values ignored silently. |
|
||||
| `WEKNORA_KB_ID` | Default KB ID for commands that accept `--kb`. Overridden by `--kb`. |
|
||||
| `WEKNORA_LOG_LEVEL` | SDK debug log level (`error \| warn \| info \| debug`). Overridden by `--log-level`. |
|
||||
| `WEKNORA_AGENT_HELP` | Set to `1` to emit structured JSON agent-help (machine-readable) instead of human help text when `--help` is invoked. |
|
||||
|
||||
> **Note for agents — machine-readable version and help:** The `--version` flag
|
||||
> and `--help` flag on the root command bypass `--format json` and always emit
|
||||
> prose (cobra built-in paths). For machine-readable output use the `version`
|
||||
> subcommand (`weknora version --format json`) or `WEKNORA_AGENT_HELP=1
|
||||
> weknora <cmd> --help`. Planned fix for v0.8.
|
||||
|
||||
## Design decisions worth flagging
|
||||
|
||||
Five design decisions readers may want context on: where WeKnora picks an
|
||||
opinionated default, what the trade-off is, and what mainstream practice it
|
||||
is or isn't aligned with.
|
||||
|
||||
### 1. Channel split: success → stdout, error → stderr
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **WeKnora** | success envelope → stdout; error envelope → stderr |
|
||||
| **Rationale** | `weknora ... --format json \| jq '.data[]'` must not mix error objects into the data stream. Channel split lets pipeline consumers suppress errors with `2>/dev/null` and still get clean JSON on stdout. |
|
||||
|
||||
### 2. `weknora api DELETE` triggers exit-10 confirmation
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **WeKnora** | DELETE triggers exit-10 (`input.confirmation_required`); user bypasses with `-y/--yes` |
|
||||
| **Rationale** | DELETE is irreversible. Most raw-API CLI commands rely on restricted credentials for safety, but self-hosted deployments may not have restricted-credential infrastructure available. Defensive default because agents are common consumers. |
|
||||
|
||||
### 3. `retry_argv` distinct from `hint`
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **WeKnora** | two separate fields: `retry_argv` (suggested next argv as a string array, directly executable for non-destructive errors; informational only on exit-10) + `hint` (prose) |
|
||||
| **Rationale** | Agents don't regex-extract argv from prose — known fragility. Trade-off: one extra envelope field. On exit-10, the user must approve the destructive write; agents surface `retry_argv` for human review, not auto-execution. |
|
||||
|
||||
### 4. NDJSON event stream has no envelope wrapping
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **WeKnora** | streaming commands (`chat`, `session ask`) emit bare `{type:...}` per line; no envelope |
|
||||
| **Rationale** | This matches established practice across NDJSON-emitting CLIs and webhook protocols. A streaming envelope requires unwrap before dispatch — net burden with no benefit. |
|
||||
|
||||
### 5. No `schema_version` field in payload
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Mainstream** | some APIs (Anthropic / OpenAI) embed a `version` field in payload |
|
||||
| **WeKnora** | version identity via CLI binary semver + CHANGELOG `### BREAKING` + skill `tested_against` + CI parity tests |
|
||||
| **Rationale** | Mainstream CLIs don't embed version in payload. Agents have complete version awareness via `weknora --version` and skill version binding. |
|
||||
|
||||
## Pre-1.0 breaking policy
|
||||
|
||||
CLI is in `v0.x` pre-release. Breaking changes ship together in concentrated
|
||||
batches (v0.5 / v0.6 / v0.7) rather than scattered across patch releases.
|
||||
After 1.0, any breaking change requires a 2-version deprecation period.
|
||||
|
||||
`CHANGELOG.md` `### BREAKING` section is authoritative.
|
||||
|
||||
For agents: pin the CLI version in your skill's `tested_against` field; bump
|
||||
`tested_against` only after manually validating against the new CLI.
|
||||
|
||||
## Build, Test, and Lint
|
||||
|
||||
```bash
|
||||
go build -o weknora . # build (from cli/)
|
||||
go test -count=1 ./... # unit + contract tests
|
||||
go test -run TestFoo ./internal/format/ # single test
|
||||
go test ./acceptance/contract/ -args -update # refresh wire goldens
|
||||
go test -tags acceptance_e2e ./acceptance/e2e/... # live-server e2e (gated by env)
|
||||
go vet ./...
|
||||
```
|
||||
|
||||
Both `go test -count=1 ./...` and `go vet ./...` must pass before committing.
|
||||
|
||||
## Architecture
|
||||
|
||||
Entry point: `cmd/main.go` → `cmd.Execute()` → `cmd.NewRootCmd(cmdutil.New())`.
|
||||
|
||||
Key packages:
|
||||
|
||||
- `cmd/<name>/` — cobra command implementations, one subdir per top-level command
|
||||
- `internal/cmdutil/` — `Factory`, `FormatOptions`, typed `Error`, exit-code mapping, destructive-write confirm, KB id-or-name resolve
|
||||
- `internal/format/` — bare JSON emitter (`WriteJSON` / `WriteJSONFiltered`)
|
||||
- `internal/iostreams/` — global IO singleton + TTY detection + `SetForTest` swap
|
||||
- `internal/secrets/` — `Store` interface; `KeyringStore` primary, `FileStore` 0600 fallback, `MemStore` for tests
|
||||
- `internal/prompt/` — `TTYPrompter` (password no-echo) + `AgentPrompter` (non-TTY no-prompt sentinel)
|
||||
- `internal/sse/` — `Accumulator` for chat / session ask SSE streams
|
||||
- `internal/mcp/` — curated 10-tool stdio MCP server (wired by `cmd/mcp/serve.go`); see [MCP tool surface](#mcp-tool-surface) for the curation rationale and inventory
|
||||
- `client/` (parent module) — generated SDK
|
||||
|
||||
## Command Structure
|
||||
|
||||
A command `weknora foo bar` lives in `cmd/foo/bar.go` with `bar_test.go`.
|
||||
|
||||
### Canonical Examples
|
||||
|
||||
- **Command + tests**: `cmd/kb/list.go` and `list_test.go`
|
||||
- **Destructive write + confirm protocol**: `cmd/kb/delete.go`
|
||||
- **SSE streaming command**: `cmd/chat/chat.go`
|
||||
- **Factory wiring**: `internal/cmdutil/factory.go`
|
||||
|
||||
### The Options + Narrow Service Pattern
|
||||
|
||||
Every command follows this structure (see `cmd/kb/list.go`):
|
||||
|
||||
1. `Options` struct with flag-bound fields
|
||||
2. `Service` interface declaring only the SDK methods this command calls. `*sdk.Client` satisfies it implicitly via duck typing.
|
||||
3. `NewCmd<Verb>(f *cmdutil.Factory) *cobra.Command` constructor — flag registration + `cmdutil.AddFormatFlag`
|
||||
4. Separate `run<Verb>(ctx, opts, fopts, svc, args...)` with the business logic — the test injection point
|
||||
|
||||
Key rules:
|
||||
|
||||
- Each command owns its own `Service` interface; do NOT share interfaces across `cmd/*` packages. Per-file dependency graph is the goal.
|
||||
- Lazy-init `f.Client()` / `f.Secrets()` / `f.Prompter()` inside `RunE`, not the constructor (else `--help` forces auth).
|
||||
- Required flags: `_ = cmd.MarkFlagRequired("name")` — cobra returns the error only on registration-time typo.
|
||||
- New subtrees register in `cmd/root.go NewRootCmd`. Verb subtrees register their leaves in the subtree's own `NewCmd`.
|
||||
|
||||
### Command Examples and Help Text
|
||||
|
||||
Use a Go raw string with `weknora` as the example prefix. Keep one-line `Short` ≤ 70 chars; `Long` may run multi-paragraph; `Example` always includes `weknora` so copy-paste works:
|
||||
|
||||
```go
|
||||
Example: ` weknora kb view <id>
|
||||
weknora kb view kb_abc --format json
|
||||
weknora kb view kb_abc --format json --jq '{id, name}'`,
|
||||
```
|
||||
|
||||
### JSON Output
|
||||
|
||||
Add `--format` / `--jq` via `cmdutil.AddFormatFlag(cmd, fieldNames...)`. In `RunE`:
|
||||
|
||||
```go
|
||||
fopts, err := cmdutil.CheckFormatFlag(c)
|
||||
if err != nil { return err }
|
||||
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
|
||||
// ...
|
||||
if fopts.WantsJSON() {
|
||||
return fopts.Emit(iostreams.IO.Out, result)
|
||||
}
|
||||
```
|
||||
|
||||
`Emit` is the single source for the bare-JSON contract — it honors `--format json|ndjson` and `--jq <expr>` filtering. Never call `format.WriteJSON*` directly from a command. See `cmd/kb/list.go`.
|
||||
|
||||
### Destructive Writes
|
||||
|
||||
Commands that delete / empty / overwrite call `cmdutil.ConfirmDestructive(p, opts.Yes, fopts.WantsJSON(), what, id)` before mutation. In non-TTY OR JSON-output mode without `-y`, it returns `CodeInputConfirmationRequired` → exit 10. See `internal/cmdutil/confirm.go`.
|
||||
|
||||
## Testing
|
||||
|
||||
### Narrow Service Fakes
|
||||
|
||||
Each command's `runX(ctx, opts, fopts, svc, ...)` takes its interface, not `*sdk.Client`. Tests inject plain-struct fakes:
|
||||
|
||||
```go
|
||||
type fakeBarSvc struct {
|
||||
gotID string
|
||||
resp *sdk.Bar
|
||||
err error
|
||||
}
|
||||
func (f *fakeBarSvc) GetBar(_ context.Context, id string) (*sdk.Bar, error) {
|
||||
f.gotID = id
|
||||
return f.resp, f.err
|
||||
}
|
||||
```
|
||||
|
||||
No mocking library; the narrow-interface design makes fakes 5 lines each.
|
||||
|
||||
### IOStreams in Tests
|
||||
|
||||
```go
|
||||
out, errBuf := iostreams.SetForTest(t) // bytes.Buffer sinks, non-TTY
|
||||
ios, _ := iostreams.SetForTestWithTTY(t) // simulate terminal
|
||||
```
|
||||
|
||||
### Confirm Prompts
|
||||
|
||||
Use `testutil.ConfirmPrompter{Answer: bool, Err: error}` from `internal/testutil/`. Single source for the prompt double — do NOT re-define `confirmPrompter` per package.
|
||||
|
||||
### Assertions
|
||||
|
||||
Use `testify`. Prefer `require` (not `assert`) for error checks so the test halts immediately, and `assert` for value comparisons:
|
||||
|
||||
```go
|
||||
require.NoError(t, err)
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, "expected", actual)
|
||||
```
|
||||
|
||||
### Acceptance: Wire-Shape Goldens
|
||||
|
||||
`acceptance/contract/wire_test.go` drives the in-process cobra tree against `httptest.Server` fixtures and compares stdout to `acceptance/testdata/wire/<case>.json`. Error-path cases also assert stderr contains the typed code substring (e.g. `auth.unauthenticated`). Update goldens with `go test ./acceptance/contract/ -args -update`.
|
||||
|
||||
### Table-Driven Tests
|
||||
|
||||
Use for flag validation, error classification, parser edge cases. See `internal/cmdutil/exit_test.go` and `cmd/kb/list_test.go`.
|
||||
|
||||
```go
|
||||
tests := []struct{ name string; ...}{
|
||||
{name: "descriptive case", ...},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) { /* arrange, act, assert */ })
|
||||
}
|
||||
```
|
||||
|
||||
## Code Style
|
||||
|
||||
- Add godoc to every exported function, type, and constant. Explain *why*, not *what* — the name already says *what*.
|
||||
- Don't comment to restate the code. Delete comments that narrate the next line.
|
||||
- Don't reference task numbers, commit SHAs, or version tags in inline comments — they belong in CHANGELOG or git log.
|
||||
- Never paste em-dashes (—) into Go source; use ASCII `-` or rewrite. (Markdown docs may use em-dashes.)
|
||||
- Don't add a helper for a single caller — inline.
|
||||
|
||||
## Error Handling
|
||||
|
||||
Typed error helpers in `internal/cmdutil/errors.go`:
|
||||
|
||||
- `cmdutil.NewError(code, msg)` — fresh typed error
|
||||
- `cmdutil.WrapHTTP(err, format, args...)` — wrap an SDK error + classify from HTTP status (404 → `resource.not_found`, 401 → `auth.unauthenticated`, …). Use at every SDK call site.
|
||||
- `cmdutil.Wrapf(code, err, format, args...)` — explicit wrap with a chosen code
|
||||
- `cmdutil.NewFlagError(err)` — flag / argument problem → exit 2
|
||||
- `cmdutil.SilentError` — exit 1 without printing (when output already emitted)
|
||||
- `cmd.MarkFlagsMutuallyExclusive("a", "b")` — cobra-level mutex
|
||||
|
||||
Errors print to STDERR via `cmdutil.PrintError(w, err)` as `code: msg\nhint: ...`. STDOUT stays bare JSON or empty on failure, so `--json | jq` pipelines never have to filter error shapes.
|
||||
|
||||
User-facing exit-code mapping lives in [README.md "Exit codes"](README.md#exit-codes). When adding a new `ErrorCode` constant, also append to `AllCodes()` so the acceptance contract picks it up.
|
||||
|
||||
## Error code reference
|
||||
|
||||
> **Audience:** AI agents and scripted callers parsing `weknora` stderr.
|
||||
> Code authors writing new error sites — see [`## Error Handling`](#error-handling) above.
|
||||
|
||||
When `weknora` exits non-zero, stderr carries a structured triplet:
|
||||
|
||||
```
|
||||
<code>: <message>
|
||||
hint: <actionable next step>
|
||||
```
|
||||
|
||||
Agents parse the first colon to extract the typed code. The exit code class (see [`README.md` "Exit codes"](README.md#exit-codes)) controls retry / surface decisions; the typed code disambiguates within a class.
|
||||
|
||||
<!-- ERROR_REFERENCE_START -->
|
||||
<!-- DO NOT EDIT manually below this marker. Add new codes to
|
||||
internal/cmdutil/errors.go + register in AllCodes() + add a row here.
|
||||
The CI scan in errors_doc_test.go enforces parity. -->
|
||||
|
||||
| Code | Exit | Retryable | Default hint |
|
||||
|---|---|---|---|
|
||||
| `auth.unauthenticated` | 3 | no (run `auth login`) | run `weknora auth login` |
|
||||
| `auth.token_expired` | 3 | yes (after refresh) | your session expired; run `weknora auth login` to re-authenticate |
|
||||
| `auth.bad_credential` | 3 | no (re-login) | run `weknora auth login` |
|
||||
| `auth.forbidden` | 3 | no | active profile lacks permission for this resource |
|
||||
| `auth.cross_tenant_blocked` | 3 | no | verify tenant context with `weknora auth status` |
|
||||
| `auth.tenant_mismatch` | 3 | no | verify tenant context with `weknora auth status` |
|
||||
| `input.invalid_argument` | 5 | no | see `weknora <command> --help` for valid usage |
|
||||
| `input.missing_flag` | 5 | no | see `weknora <command> --help` for valid usage |
|
||||
| `input.confirmation_required` | 10 | **NO automatic retry** | high-risk write - re-run with `-y/--yes` after the user explicitly approves |
|
||||
| `input.unknown_subcommand` | 5 | no | invocation reached a command path with no matching subcommand. Detail includes `available` list; retry with `<path> --help`. |
|
||||
| `resource.not_found` | 4 | no | verify the resource ID and try again |
|
||||
| `resource.already_exists` | 1 | no | use a different name or fetch the existing resource |
|
||||
| `resource.locked` | 1 | maybe (transient lock) | (no canonical hint; check resource state) |
|
||||
| `server.error` | 7 | yes (with backoff for 5xx) | (no canonical hint) |
|
||||
| `server.timeout` | 7 | yes (with backoff) | request timed out; retry, or run `weknora doctor` to check connectivity |
|
||||
| `server.rate_limited` | 6 | yes (back off, then retry) | rate-limited; retry after a few seconds |
|
||||
| `server.session_create_failed` | 1 | yes (with backoff) | could not create a chat session; pass `--session` to reuse an existing session |
|
||||
| `server.incompatible_version` | 7 | no (upgrade required) | run `weknora doctor` to see version skew details |
|
||||
| `network.error` | 7 | yes (with backoff) | check base URL reachability with `weknora doctor` |
|
||||
| `operation.timeout` | 124 | yes (raise `--timeout`) | wait timed out; raise `--timeout` or check the underlying job |
|
||||
| `operation.failed` | 1 | no (target reached terminal failure) | one or more targets reached a terminal failure (e.g. doc parse_status=failed) |
|
||||
| `operation.cancelled` | 1 (main overrides to 130) | no | command interrupted by SIGINT / SIGTERM. The typed code maps to exit 1, but `main` raises the exit to 130 when the root context was signal-cancelled so the user-visible exit follows Unix signal convention. |
|
||||
| `local.config_corrupt` | 1 | no (manual fix) | remove `~/.config/weknora/config.yaml` and re-run `weknora auth login` |
|
||||
| `local.profile_not_found` | 1 | no | (no canonical hint; check `weknora profile list`) |
|
||||
| `local.file_io` | 1 | no | check file permissions under `$XDG_CONFIG_HOME/weknora/` |
|
||||
| `local.kb_id_required` | 1 | no | run `weknora link` to bind this directory to a knowledge base, or pass `--kb` |
|
||||
| `local.kb_not_found` | 1 | no | list available with `weknora kb list` |
|
||||
| `local.keychain_denied` | 1 | no (system-level) | verify keyring access; falls back to file storage |
|
||||
| `local.project_link_corrupt` | 1 | no | remove `.weknora/project.yaml` and run `weknora link` again |
|
||||
| `local.sse_stream_aborted` | 1 | yes (rerun chat / session ask) | the streaming answer was cut off mid-flight; retry, or pass `--format json` to buffer the full response |
|
||||
| `local.unimplemented` | 1 | no | (planned in a future release) |
|
||||
| `local.upload_file_not_found` | 1 | no | verify the path is correct and readable |
|
||||
| `local.user_aborted` | 1 | no (user said no) | no action taken; pass `-y/--yes` to skip the confirmation prompt |
|
||||
| `internal.error` | 1 | no | catch-all for an untyped error that reached the top (a bug or unmapped dependency error); a recurring one is a classification gap worth reporting |
|
||||
|
||||
<!-- ERROR_REFERENCE_END -->
|
||||
|
||||
### AI agent decision shortcuts
|
||||
|
||||
For common retry patterns, AI agents can hardcode:
|
||||
|
||||
- `network.*` → retry with exponential backoff
|
||||
- `auth.token_expired` → run `weknora auth refresh`, then retry once
|
||||
- `server.rate_limited` → back off (Retry-After if present) then retry
|
||||
- `operation.timeout` → raise `--timeout` and retry, or surface to user
|
||||
- `input.confirmation_required` → **NEVER** auto-pass `-y` without explicit user authorization
|
||||
- `*.invalid_argument` / `*.missing_flag` → surface to user (don't retry)
|
||||
|
||||
## Exit-10 anti-patterns
|
||||
|
||||
Exit code 10 (`input.confirmation_required`) marks a destructive write where the
|
||||
CLI refused to proceed without explicit user approval. The retry envelope includes
|
||||
`retry_argv` showing the exact argv that would proceed. AI agents must NEVER
|
||||
auto-retry this exit code — every exit 10 is a user-in-the-loop decision.
|
||||
|
||||
**Don't do these:**
|
||||
|
||||
1. **Auto-add `-y/--yes` and retry.** The flag exists for the user, not the agent.
|
||||
Surface the exit-10 envelope to the user verbatim and wait for explicit go-ahead.
|
||||
|
||||
2. **Execute `retry_argv`.** The argv is *informational* --
|
||||
showing what *would* execute. Running it without user input collapses two steps
|
||||
the user is supposed to see.
|
||||
|
||||
3. **Wrap the call in a retry-with-backoff loop.** Exit 10 is not transient. It's
|
||||
a "this needs human approval" signal, not a transient transport error.
|
||||
|
||||
4. **Treat exit 10 as a generic error and fall back to a less-destructive verb.**
|
||||
The user asked for the destructive verb. If they want something else they'll
|
||||
say so. Don't substitute.
|
||||
|
||||
5. **Auto-add `-y` because the *previous* exit-10 was approved.** Each invocation
|
||||
stands on its own. The user's prior approval doesn't extend to similar calls.
|
||||
|
||||
6. **Skip the prompt by switching to `--format json`.** JSON mode still emits
|
||||
`input.confirmation_required` (just in envelope form). It's the same gate.
|
||||
|
||||
## Stream recovery
|
||||
|
||||
The `weknora session resume <session-id> --message <msg-id>` command resumes an SSE event stream for an existing assistant message. Use cases: network-blip recovery, long-running agent invocation polling, completed-stream inspection.
|
||||
|
||||
### Server semantics: replay-from-0, not cursor-resume
|
||||
|
||||
The server **replays all stored events from the start** of the assistant message, then **tails new events**. This is NOT a cursor-from-disconnect resume. Agents reconnecting mid-stream will receive ALL previously-emitted events again.
|
||||
|
||||
### Agent contract
|
||||
|
||||
1. **Dedupe by message_id** (or maintain a per-message event hash set). Naively processing all received events causes duplicate side effects (re-running tool calls, re-rendering answers).
|
||||
2. **Capture message_id from the init event** of the original `chat` or `session ask` invocation — the CLI injects `{"event":"init", "session_id":"...", "message_id":"..."}` as the first NDJSON line.
|
||||
3. **Handle `local.sse_stream_aborted` typed error**: server-side buffer expired (TTL exceeded) or process restarted (memory mode). The message is no longer recoverable; restart the original query.
|
||||
|
||||
### Server-side buffer TTL
|
||||
|
||||
| Mode | TTL |
|
||||
|---|---|
|
||||
| `STREAM_MANAGER_TYPE=redis` | **1 hour** (server-side; not configurable from the CLI) |
|
||||
| `STREAM_MANAGER_TYPE=memory` (default) | **Process lifetime** (server restart = data loss; no explicit cleanup logic) |
|
||||
|
||||
After TTL, `weknora session resume` returns the typed error `local.sse_stream_aborted`, which maps to exit code 1 per the Error code reference.
|
||||
|
||||
## Dry-run contract
|
||||
|
||||
The `--dry-run` flag is available on every mutation cobra command (`kb create/edit/delete`, `agent create/edit/delete`, `doc create/upload/fetch/delete`, `chunk delete`, `session delete`, `auth refresh/logout`, `link/unlink`, `profile add/remove`) and on `weknora api` (POST/PUT/PATCH/DELETE only; GET rejected with FlagError exit 2).
|
||||
|
||||
### Envelope shape on dry-run
|
||||
|
||||
Success envelope (exit 0) with `data` field omitted (omitempty) and `meta.dry_run=true`:
|
||||
|
||||
```json
|
||||
{"ok":true,"meta":{"dry_run":true,"plan":{"action":"kb.create","args":{"name":"foo","description":"bar"}}},"profile":"prod"}
|
||||
```
|
||||
|
||||
`api` command additionally includes `method` / `path` / `body` in plan:
|
||||
|
||||
```json
|
||||
{"ok":true,"meta":{"dry_run":true,"plan":{"action":"api.post","method":"POST","path":"/api/v1/knowledge-bases","body":{"name":"foo"}}}}
|
||||
```
|
||||
|
||||
### Side effects suppressed
|
||||
|
||||
The dry-run path is **offline** — no SDK calls, no Factory.Client() init, no ResolveKB network query, no writes (keyring / `.weknora/project.yaml` / files). Works without active profile or network access.
|
||||
|
||||
### Interactions
|
||||
|
||||
| Combination | Behavior |
|
||||
|---|---|
|
||||
| `--dry-run` (destructive cmd, no `-y`) | NO exit-10; emits plan + exit 0 (preview implies no execution, so the confirmation gate is irrelevant) |
|
||||
| `--dry-run` + `-y` | Equivalent to single `--dry-run`; `-y` is no-op (dry-run early-exits before ConfirmDestructive) |
|
||||
| `--dry-run` + `api -X GET` (or default GET) | FlagError exit 2: "--dry-run requires explicit -X POST/PUT/PATCH/DELETE; default GET is read-only with no side effect to preview" |
|
||||
| `--dry-run` + `--jq <expr>` | jq applied to envelope output normally |
|
||||
| `--dry-run` + `kb edit my-kb` | plan.args contains user raw input (NOT ResolveKB-resolved); agent verifies kb name correctness |
|
||||
| `--dry-run` + fetch-then-update (`kb edit / agent edit`) | plan.args contains user-explicit fields ONLY; agent infers server-side fetch-then-update preserves unmentioned fields |
|
||||
| `--dry-run` + body containing secrets (`--input` payload) | **plan.body echoes the full body to stdout** so the agent can verify what would be sent; avoid piping secret-bearing bodies through dry-run for inspection |
|
||||
|
||||
### Streaming commands explicitly excluded
|
||||
|
||||
`session ask` and `chat` do NOT support `--dry-run` (streaming and dry-run have a semantic mismatch). For prompt-formation preview, use:
|
||||
|
||||
```bash
|
||||
echo '{"query":"...","kb":"..."}' | weknora api -X POST /api/v1/sessions/<id>/agent-qa --input - --dry-run
|
||||
```
|
||||
|
||||
## Risk metadata
|
||||
|
||||
Agents see the same `risk.action` string (in the form `noun.verb`) on three independent surfaces:
|
||||
|
||||
1. **Error envelope** — `envelope.error.risk.action` on an exit-10 confirmation-required error, so the agent can decide whether to escalate to the user. 11 unique values: `kb.delete`, `kb.edit`, `agent.delete`, `agent.edit`, `doc.delete`, `doc.delete_all`, `session.delete`, `chunk.delete`, `profile.remove`, `auth.logout`, `api.delete`.
|
||||
|
||||
2. **Help text** — a `Risk: <action> (destructive)` line prepended to the top of `--help` output on the 9 destructive cobra commands. `weknora api` is intentionally excluded: it is a generic HTTP passthrough whose risk depends on the method, so a static Risk: line would mislead for non-DELETE methods.
|
||||
|
||||
3. **MCP tool annotations** — `Tool.Annotations.destructiveHint` / `readOnlyHint` / `idempotentHint` / `openWorldHint` on every tool returned by `weknora mcp serve`.
|
||||
|
||||
The three surfaces do not auto-sync: each is wired separately so agents that only consume one surface still get the signal, but contributors adding a new destructive command must touch all three.
|
||||
|
||||
## MCP Tool Surface
|
||||
|
||||
WeKnora's MCP server exposes a curated read-only tool surface. Many MCP servers in the wild ship write / mutation operations on by default and rely on credential-scope or sandbox restrictions for safety. WeKnora opts for curation instead: the server side doesn't yet enforce per-token scope, so an agent holding a user's token has full write access. Until server-side scope ships, the CLI keeps mutation tools out of the MCP surface as a belt-and-braces second line of defense. When server scope arrives this stance can loosen.
|
||||
|
||||
The curated 10 tools (`cli/internal/mcp/tools.go`):
|
||||
|
||||
| Tool | Purpose |
|
||||
| --- | --- |
|
||||
| `kb_list` | list knowledge bases |
|
||||
| `kb_view` | fetch a knowledge base by id |
|
||||
| `doc_list` | list documents in a kb (paginated, status-filterable) |
|
||||
| `doc_view` | fetch a document by id |
|
||||
| `doc_download` | download raw bytes (1 MiB cap, base64 for binary) |
|
||||
| `chunk_list` | list chunks of a document for RAG retrieval debug |
|
||||
| `search_chunks` | hybrid (vector + keyword) retrieval |
|
||||
| `chat` | stream a RAG answer; auto-creates a session if absent |
|
||||
| `agent_list` | list custom agents |
|
||||
| `session_ask` | run a query through a custom agent |
|
||||
|
||||
Adding a tool is a deliberate API expansion — the AI-agent-callable surface is the reason this CLI ships an MCP server, not its CLI command list, so the registration list in `registerTools` is maintained by hand.
|
||||
|
||||
## Command surface design SOP
|
||||
|
||||
Before specifying any CLI command, do this in order:
|
||||
|
||||
1. `grep -A 50 "type Foo struct" client/foo.go` — dump SDK request/response schemas.
|
||||
2. List every field with type and source line.
|
||||
3. For each field, decide: hot-path flag / config-file only / hidden / never-expose.
|
||||
4. Cross-check pagination signatures: an SDK `(ctx, id, page, pageSize)` shape demands `--limit` + `--all-pages` + `--page-size` on the CLI side.
|
||||
5. ONLY THEN consult mainstream CLI conventions to choose flag names, positionals, mutex, and confirm semantics.
|
||||
6. Decide which fields are "top use case" (flag) / "advanced" (`--config-file` or escape hatch via `weknora api`). Don't try to flag-cover every SDK field — mature CLIs that curate ship a tighter surface; CLIs that 1:1 mirror their API pay the UX cost.
|
||||
|
||||
Rationale: earlier drafts produced three categories of schema errors — fields that didn't exist on the underlying SDK, wrong field counts in user-facing docs, and missing pagination flags — that all stemmed from "design from convention, not from SDK." The fix is canonical: the SDK schema is the ground truth; convention decides names and shapes around that ground truth.
|
||||
|
||||
## CRUD command flag conventions
|
||||
|
||||
CRUD commands follow the **hard-required-flags** pattern: every required input is a flag or positional, and a missing one yields an immediate `input.invalid_argument` exit. The contrast is **TTY-prompts-fill**, where missing input opens an interactive prompt; that pattern is reserved for `auth login` (the one command where a human must be at the terminal).
|
||||
|
||||
Required-input idioms in this codebase:
|
||||
|
||||
- Positional required: `cobra.ExactArgs(N)` or `cobra.MinimumNArgs(1)`
|
||||
- Flag required: `cmd.MarkFlagRequired("flag")`
|
||||
- Custom required (e.g., `agent edit` needs at-least-one-edit-flag): RunE-level validation that returns `input.invalid_argument`
|
||||
- Mutex: `cmd.MarkFlagsMutuallyExclusive("a", "b")`
|
||||
|
||||
Reasons hard-required-flags is the v0.5+ default:
|
||||
|
||||
- Admin / debug commands have no natural human-interactive prompt to lean on.
|
||||
- Agent-friendly: MCP callers do not stall waiting for stdin prompts.
|
||||
- Consistent with every existing non-auth WeKnora command.
|
||||
|
||||
- **Agent help blob**: Commands MAY call
|
||||
`cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{...})` to expose a stable
|
||||
JSON used_for / required_flags / examples / output / warnings shape.
|
||||
Activated by `WEKNORA_AGENT_HELP=1` at `--help` time. Warnings are
|
||||
always rendered in human help (stderr, not env-gated). Applied to
|
||||
`chat`, `kb list`, `session ask`, and all destructive commands.
|
||||
Extending to another command requires touching only that command's `NewCmd`.
|
||||
|
||||
## Status / check verb pair pattern
|
||||
|
||||
When a resource has both a cheap "is it alive?" probe and a deeper
|
||||
"verify its dependencies / aggregate state" probe, expose them as two
|
||||
verbs so the verb itself communicates cost:
|
||||
|
||||
- `status <id>` — single HTTP, returns reachable + cheap fields.
|
||||
- `check <id>` — 1 + N HTTP, adds derived state that needs follow-up
|
||||
calls (e.g., aggregating `failed_count` via doc-list page-walk,
|
||||
probing every KB in an agent's scope).
|
||||
|
||||
Current pairs: `kb status` / `kb check`, `agent status` / `agent check`.
|
||||
The deep verb's `Long` help text must enumerate the extra HTTP calls so
|
||||
cost is predictable.
|
||||
|
||||
## Long-poll wait commands
|
||||
|
||||
`doc wait <doc-id> [<doc-id>...]` is the model for any future
|
||||
`wait` command:
|
||||
|
||||
- Always wait-all on multi-target (no fail-fast flag); compose in shell
|
||||
(`wait id1 && wait id2`) when fail-fast is needed.
|
||||
- Exponential backoff with jitter (initial `--interval`, cap 15s).
|
||||
- Concurrency capped (5 in flight); large fan-out via `xargs -P`.
|
||||
- Exit-code priority: failed (1) > timeout (124) > completed (0). The
|
||||
failed bucket is `operation.failed`, not `server.error` — a target's
|
||||
own terminal failure is not a transient transport issue.
|
||||
- Validate `--format` / `--jq` before polling so an invalid flag does
|
||||
not cost the caller a multi-minute poll.
|
||||
@@ -0,0 +1,575 @@
|
||||
# Changelog — `weknora` CLI
|
||||
|
||||
All notable changes to the `weknora` CLI (the binary under `cli/` in this
|
||||
repository) will be documented in this file.
|
||||
|
||||
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
|
||||
and the CLI follows [Semantic Versioning](https://semver.org/) independently
|
||||
of the WeKnora server / frontend release cadence.
|
||||
|
||||
CLI history before v0.3 is recorded in the project root
|
||||
[CHANGELOG.md](../CHANGELOG.md) under the release that introduced the CLI.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Breaking
|
||||
- `chat` and `session ask` now distinguish JSON from NDJSON: the default
|
||||
`--format json` buffers a bounded answer-event projection into one
|
||||
`{ok:true,data:{events:[...]}}` envelope; use `--format ndjson` for the
|
||||
complete raw event stream.
|
||||
- JSON, text, and MCP chat/session output hide reasoning, tools, lifecycle
|
||||
frames, and references by default. `--reference` adds bounded `kb_id` /
|
||||
`chunk_id` / `parent_chunk_id` indexes; `--verbose` adds execution events.
|
||||
- `session continue-stream` renamed to `session resume`.
|
||||
- `kb init` renamed to `kb config set`.
|
||||
- Error envelope: `retry_command` (a shell string) replaced by `retry_argv`
|
||||
(a directly-executable argv array — no shell-splitting or quoting).
|
||||
|
||||
### Added
|
||||
- `chat` / `session ask --reference` includes indexed citations, while
|
||||
`--verbose` includes reasoning, tools, and lifecycle events. MCP `chat` /
|
||||
`session_ask` expose the same controls through `reference` / `verbose` inputs.
|
||||
- Buffered chat/session errors include the auto-created `session_id` in
|
||||
`error.detail` so interrupted sessions remain recoverable.
|
||||
- `error.exit_code` embeds the process exit code in the JSON error envelope,
|
||||
disambiguating the two `input.invalid_argument` cases (parse error → 2,
|
||||
typed-value error → 5).
|
||||
- `kb status` / `kb check` / `kb create` report `retrieval_ready` (whether an
|
||||
embedding model is bound); `kb create` hints the fix when it is false.
|
||||
- `model create` / `model update` / `model delete` (`update` rotates key /
|
||||
base-url in place, preserving the id).
|
||||
- Stateless env-credential auth: `WEKNORA_API_KEY` / `WEKNORA_TOKEN` +
|
||||
`WEKNORA_HOST`, a zero-disk path for headless / agent use. `auth logout` now
|
||||
keeps the profile registered (use `profile remove` to delete it entirely).
|
||||
- `meta.total_count` on paginated list / search output (full result size before
|
||||
client-side `--limit` truncation).
|
||||
|
||||
### Changed
|
||||
- JSON, text, and MCP now share one event projector and filtering policy.
|
||||
- Projected references contain lookup indexes only; fetch full
|
||||
passages with `chunk view <chunk_id>` or `chunk view <parent_chunk_id>`.
|
||||
- NDJSON remains an unmodified SDK event trace, including reasoning and full
|
||||
reference payloads.
|
||||
|
||||
### Fixed
|
||||
- Streaming SDK calls are no longer cut off by the client's default 30-second
|
||||
timeout (explicit `WithTimeout` values remain honored), and SSE data lines
|
||||
up to 4 MiB are accepted.
|
||||
- Terminal `response_type=error, done=true` frames now end SDK stream calls
|
||||
even when the server leaves the HTTP connection open.
|
||||
- Agent accumulation now waits for `response_type=complete` instead of treating
|
||||
per-event `done:true` markers as completion of the whole run.
|
||||
- Reference `knowledge_base_id`, `parent_chunk_id`, and `sub_chunk_id` fields
|
||||
now survive SDK unmarshal.
|
||||
- E2E chat step parses the bounded JSON envelope; MCP stream errors include
|
||||
`session_id` in `error.detail`; terminal SSE errors classify as
|
||||
`server.error` instead of `network.error` or `local.sse_stream_aborted`.
|
||||
|
||||
## [0.9.0] - 2026-06-10
|
||||
|
||||
### v0.9 — auth/profile model harmonization + flag cleanup
|
||||
|
||||
#### Added
|
||||
- `weknora session stop <session-id>` command to abort an in-flight agent run.
|
||||
- `profile add --use`: switch to the newly-added profile immediately (instead of only auto-selecting the first profile added).
|
||||
- `-L` shorthand on `session view` (alias for `--limit`).
|
||||
- `doc download --format json` now emits a success envelope (was bare).
|
||||
- `SetAgentHelp` coverage extended across create / list / search commands.
|
||||
|
||||
#### Changed
|
||||
- `auth login` now authenticates the **active profile** (resolved from config / global `--profile`) instead of creating a profile. Re-login MERGES credential refs into the existing record — host and an existing user are preserved, never clobbered.
|
||||
|
||||
#### Breaking
|
||||
- **`--kb` now accepts a knowledge-base name *or* id** on `doc delete --all` and `search chunks` / `search docs`; it stays required (no silent project-link fallback on these commands).
|
||||
- **`agent create --kb` renamed to `--attach-kb`** to disambiguate from the global `--kb` scope flag.
|
||||
- **MCP tool `agent_invoke` renamed to `session_ask`** (clean rename — external MCP clients must update cached tool schemas).
|
||||
- **`auth login` drops `--host` and `--name`.** It authenticates the active profile; create it first with `weknora profile add <name> --host <h> --use`. Target a non-active profile with the global `weknora --profile <name> auth login`.
|
||||
- **`auth logout` and `auth refresh` drop `--name`.** They act on the active profile; target another with the global `--profile <name>`.
|
||||
|
||||
#### Removed
|
||||
- Dead MCP error codes `mcp.readonly_mode`, `mcp.tool_not_allowed`, and `mcp.schema_unknown_command` (never emitted by the current tool surface).
|
||||
|
||||
---
|
||||
|
||||
### v0.8 — Agent safety nets + MCP annotations
|
||||
|
||||
#### Added
|
||||
- `--dry-run` flag on every mutation cobra command (`kb create/edit/delete`, `agent create/edit/delete`, `doc create/upload/fetch/delete`, `chunk delete`, `session delete`, `auth refresh/logout`, `link/unlink`, `profile add/remove`) and on `weknora api` (POST/PUT/PATCH/DELETE only; GET returns FlagError exit 2).
|
||||
- envelope `meta.dry_run: true` + `meta.plan: {action, args | method+path+body}` open-map fields (omitempty in non-dry-run envelopes).
|
||||
- `weknora session continue-stream <session-id> --message <msg-id>` command for SSE event stream replay/recovery.
|
||||
- MCP `Tool.Annotations` on all 10 MCP serve tools (`destructiveHint` / `readOnlyHint` / `idempotentHint` / `openWorldHint` + `Title`) per MCP spec 2025-06-18.
|
||||
- `cli/internal/cmdutil/risk.go`: `SetRisk(cmd, action)` helper + `RiskDestructive` const + `GetRisk(cmd)` reader.
|
||||
- Help output "Risk: <action> (<level>)" line at top of 9 destructive commands' `--help` (via modified `SetAgentHelp` wrapper).
|
||||
- `cli/AGENTS.md` sections: Stream recovery / Dry-run contract / Risk metadata.
|
||||
- `cli/README.md` sections: Dry-run preview / Resuming streams.
|
||||
|
||||
#### Changed
|
||||
- `SetAgentHelp` wrapper in `cli/internal/cmdutil/agenthelp.go` now prepends "Risk:" line in default (non-JSON) help branch when `cmd.Annotations["risk.action"]` is set. WEKNORA_AGENT_HELP=1 JSON path unchanged.
|
||||
- 9 destructive commands' `SetAgentHelp` Warnings standardized: line 1 is a verbatim exit-10 / `-y` reminder; line 2 carries per-command destructive context.
|
||||
- `cli/cmd/api/api.go` now has `SetAgentHelp` with runtime exit-10 note for `-X DELETE/PUT/PATCH`.
|
||||
- `cli/cmd/doc/delete.go` Warnings adds a 3rd line describing `--all` blast radius.
|
||||
- Bumped `github.com/modelcontextprotocol/go-sdk` v1.6.0 → v1.6.1 (patch; opt-in `MCPGODEBUG` env var).
|
||||
|
||||
#### Breaking
|
||||
*(none — v0.8 is fully additive on top of v0.7 envelope shape, NDJSON vocab, and typed code contracts; existing consumers continue to work and the new fields are optional via `omitempty`)*
|
||||
|
||||
---
|
||||
|
||||
### v0.7 — Agent-first wire contract + command-surface cleanup
|
||||
|
||||
#### BREAKING (v0.6 → v0.7)
|
||||
- **All JSON output now wrapped in symmetric envelope.**
|
||||
- Success on stdout: `{ok:true, data?:<T>, meta?, _notice?, profile?}` (`data`
|
||||
omitted on mutation-only success).
|
||||
- Error on stderr (json mode): `{ok:false, error:{type, message, hint?,
|
||||
retry_command?, retry_after_seconds?, risk?, detail?}, _notice?}`.
|
||||
- `meta.count` / `meta.has_more` surface list totals and server-side
|
||||
pagination state. `meta.next_cursor` / `meta.total_count` /
|
||||
`meta.request_id` are reserved — populated when the SDK exposes them
|
||||
(planned for v0.8).
|
||||
- Migration: replace `jq '.[]'` with `jq '.data[]'`; `.id` → `.data.id`;
|
||||
list-count consumers read `.meta.count`.
|
||||
- **`--format` default flips to `json` regardless of TTY.**
|
||||
- v0.6: smart default (text on TTY, json on pipe).
|
||||
- v0.7: always json; TTY only affects indent (compact in pipe). Enum
|
||||
`text | json | ndjson` unchanged.
|
||||
- Migration: humans on a TTY pass `--format text` (or set
|
||||
`WEKNORA_FORMAT=text` env) for the prior auto-text behavior.
|
||||
- **`chat` / `session ask` default to NDJSON event-stream (SDK passthrough).**
|
||||
- v0.6: TTY rendered a live SSE animation; `--format json` produced a buffered
|
||||
object; NDJSON was opt-in.
|
||||
- v0.7: `--format json` and `--format ndjson` both emit one JSON event per line
|
||||
(no envelope wrapping). CLI injects exactly one `init` event at stream head;
|
||||
all subsequent events pass through verbatim from the SDK (`answer` /
|
||||
`tool_call` / `tool_result` / `references` / `thinking` / `reflection` /
|
||||
`error` / `complete` for chat; agent vocab is a subset).
|
||||
- For prose rendering: `--format text`.
|
||||
- **`weknora context` command group renamed to `weknora profile`.**
|
||||
- Subcommands `context list/add/remove/use` → `profile list/add/remove/use`.
|
||||
- Global flag `--context` → `--profile`.
|
||||
- On-disk config `~/.config/weknora/config.yaml` keys `current_context:` /
|
||||
`contexts:` → `current_profile:` / `profiles:` (no backwards-compat
|
||||
alias; delete the file or rename the keys by hand to migrate).
|
||||
- Binding file `.weknora/project.yaml` field `context:` → `profile:`
|
||||
(re-run `weknora link` to regenerate).
|
||||
- `profile use` JSON fields `current_context` / `previous_context` →
|
||||
`current_profile` / `previous_profile`.
|
||||
- `weknora link` JSON field `context` → `profile`.
|
||||
- Rationale: `context` collided with LLM "context window" / RAG "context" /
|
||||
Go `context.Context`. Mainstream multi-credential CLIs (AWS, Stripe,
|
||||
OpenAI, Anthropic) settle on `profile` as the term of art.
|
||||
- **`weknora agent invoke` removed; use `weknora session ask --agent <id>`.**
|
||||
- Server route is `POST /sessions/{session_id}/agent-qa` — session-anchored.
|
||||
- `weknora agent` keeps CRUD only (list / view / create / edit / delete /
|
||||
status / check).
|
||||
- Migration: `weknora agent invoke ag_x "Q"` →
|
||||
`weknora session ask --agent ag_x "Q"` (auto-creates session if none given).
|
||||
- **`weknora doc upload` split into three commands.**
|
||||
- `weknora doc upload <file>` — local file only.
|
||||
- `weknora doc fetch <url>` — server-side remote fetch (was `upload --from-url`).
|
||||
- `weknora doc create --text "..."` — direct text knowledge.
|
||||
- URL-only flags (`--title`, `--file-type`, `--tag-id`) moved to `doc fetch`.
|
||||
- Rationale: `upload --from-url` mixed semantics ("send out" vs "pull in");
|
||||
the three-verb split matches the server's three endpoints and gives each
|
||||
one a single unambiguous shape.
|
||||
- **`weknora kb empty` removed; use `weknora doc delete --all --kb=<id>`.**
|
||||
- Atomic server `ClearKnowledgeBaseContents` (no list-then-delete race).
|
||||
- Same exit-10 `-y/--yes` guard as `kb delete`.
|
||||
- Migration: `weknora kb empty kb_x -y` →
|
||||
`weknora doc delete --all --kb=kb_x -y`.
|
||||
- **`weknora api -d/--data` flag removed; use `--input <file>` or `--input -`
|
||||
(stdin).**
|
||||
- `weknora api` now accepts any non-empty HTTP method (whitelist removed)
|
||||
so the escape hatch can hit endpoints the CLI doesn't natively model.
|
||||
- Migration: `weknora api -d '{"foo":1}' /endpoint` →
|
||||
`echo '{"foo":1}' | weknora api --input - /endpoint`.
|
||||
- **Batch operations envelope shape — per-item `ok` pattern.**
|
||||
- `weknora doc delete id1,id2,id3` and similar multi-id mutations now emit:
|
||||
`{ok, data:[{id, ok, result?|error?}, ...], meta:{count, successes, failures}}`.
|
||||
- Top-level `ok` = AND-aggregate of per-item `ok` (false on partial failure).
|
||||
- All-fail stays in batch shape (not error envelope) — agents can iterate
|
||||
detail per id.
|
||||
- jq pattern: `jq '.data[] | select(.ok == false) | .id'`.
|
||||
- **MCP server tool errors now return `StructuredContent`.**
|
||||
- `CallToolResult{IsError: true, Content:[text-fallback],
|
||||
StructuredContent:{type, message, hint?, retry_command?, risk?, detail?}}`.
|
||||
- Shape mirrors stderr `envelope.error` sub-object — one parser handles both.
|
||||
- **Unknown subcommand emits typed envelope.**
|
||||
- `input.unknown_subcommand` with `detail.{unknown, command_path, available[]}`
|
||||
+ `retry_command: "<parent> --help"`. Replaces v0.6's free-form
|
||||
`"unknown command \"x\" for \"weknora\""` prose.
|
||||
- **`weknora chat` requires the query as a single quoted argument.**
|
||||
- v0.6: `MinimumNArgs(1)` silently joined `weknora chat hello world` into
|
||||
`"hello world"`.
|
||||
- v0.7: `ExactArgs(1)` rejects multi-arg with exit 2; matches
|
||||
`weknora session ask`. Quote the query: `weknora chat "hello world"`.
|
||||
|
||||
#### Added
|
||||
- **`WEKNORA_PROFILE` env var** selects the active profile for a single
|
||||
invocation (equivalent to `--profile <name>` global flag). Overridden by
|
||||
explicit `--profile`. Useful for CI scripts that cannot pass global flags.
|
||||
- **`WEKNORA_FORMAT` env var** sets the default `--format`. Values:
|
||||
`text | json | ndjson`. Overridden by explicit `--format`. Invalid values
|
||||
ignored.
|
||||
- **`error.retry_command`** — directly-executable retry argv, distinct from
|
||||
prose `hint`. Agents read `retry_command` without regex-parsing `hint`.
|
||||
- **`error.retry_after_seconds`** — `server.rate_limited` / `server.timeout`
|
||||
surface server `Retry-After` header verbatim. CLI-direct (`weknora api`)
|
||||
parses HTTP `Retry-After` headers; SDK-mediated paths will gain coverage
|
||||
as the SDK exposes typed transport errors.
|
||||
- **`error.risk.{level, action}`** — destructive writes carry
|
||||
`{level:"destructive", action:"<noun.verb>"}` (e.g. `doc.delete_all`,
|
||||
`kb.delete`). Reserved levels `"read"` / `"write"` not yet emitted.
|
||||
- **`_notice` envelope channel reserved** — open-map infrastructure in place
|
||||
for deprecation / version_skew / security notices. Producer wiring planned
|
||||
for v0.8 when the SDK exposes version metadata. Additive non-breaking;
|
||||
unknown keys must be ignored.
|
||||
- **`meta.count` / `meta.has_more`** on list commands. `meta.next_cursor` /
|
||||
`meta.total_count` / `meta.request_id` reserved — populated when the SDK
|
||||
exposes them (planned for v0.8).
|
||||
- **`weknora doc fetch <url>`** — new command (see split above).
|
||||
- **`weknora doc create --text "..."`** — new command (see split above).
|
||||
- **`weknora session ask --agent <id> "..."`** — new command (replaces
|
||||
`agent invoke`).
|
||||
- **`weknora doc delete --all --kb=<id>`** — new mode of `doc delete`
|
||||
(replaces `kb empty`).
|
||||
- **NDJSON `init` event** at stream head for `chat` / `session ask` —
|
||||
carries `session_id` + optional `kb_id` / `agent_id` / `model` / `profile`.
|
||||
`request_id` field is reserved (not currently populated; planned for v0.8
|
||||
when the SDK exposes response headers).
|
||||
- **`AgentHelp.Warnings`** — destructive commands (`kb delete`, `doc delete`,
|
||||
`agent delete`, `session delete`, `chunk delete`, `profile remove`,
|
||||
`kb edit`, `agent edit`, `auth logout`) render an "AI agents:" warnings
|
||||
block in `--help` to set explicit expectations around `-y/--yes`.
|
||||
|
||||
#### Changed
|
||||
- `AGENTS.md` adds `## Wire contract for AI agents`, `## Deliberate deviations
|
||||
+ mainstream alignments`, `## Pre-1.0 breaking policy`, `## Exit-10
|
||||
anti-patterns` sections.
|
||||
- `README.md` adds `### Agent quick start` under `## Wire contract`.
|
||||
- `chunk` command group help: disambiguation prose vs `search chunks` removed
|
||||
in favour of plain verb descriptions.
|
||||
|
||||
#### Deprecated (will remove in v0.8+)
|
||||
- *(none — pre-release breaking release; no deprecation alias period.)*
|
||||
|
||||
---
|
||||
|
||||
### v0.6 — agent runtime hardening: --format, doc wait, --log-level, status, multi-id delete, paginate
|
||||
|
||||
#### BREAKING (v0.5 → v0.6)
|
||||
- **`--json` flag removed** → use **`--format json`** (with optional
|
||||
`--jq '<expr>'` for projection / filtering). The v0.5 `--json=fields,...`
|
||||
per-field projection drops entirely; rewrite as
|
||||
`--format json --jq '.[] | {id, name}'` (jq is the canonical projection
|
||||
mechanism going forward).
|
||||
- **`--no-stream` flag removed** on `chat` / `agent invoke` → use
|
||||
**`--format json`** to buffer the full answer before printing. The bare
|
||||
text-accumulate use case (TTY but no streaming) is dropped.
|
||||
- **`WEKNORA_SDK_DEBUG=1` env removed** → use **`WEKNORA_LOG_LEVEL=debug`**.
|
||||
- **`kb create --name <name>` flag removed** → use positional
|
||||
**`kb create <name>`** (consistent with `agent create <name>`).
|
||||
|
||||
#### Added
|
||||
- **`--format text|json|ndjson`** flag selecting the stdout serialization.
|
||||
Registered per-command (only commands that honor `--format` register it;
|
||||
others reject it with `unknown flag` / exit 2). Output mode auto-resolved
|
||||
to `text` on a TTY and `json` when stdout was piped (v0.7 promoted the
|
||||
flag to a persistent global and made the default always `json`).
|
||||
- **`--jq '<expr>'`** flag pairs with `--format json|ndjson` to filter or
|
||||
project the JSON output via a jq expression.
|
||||
- **`weknora doc wait <id> [<id>...]`** — block until every document reaches a
|
||||
terminal `parse_status`. Always wait-all — use shell composition
|
||||
(`wait id1 && wait id2`) for fail-fast.
|
||||
- `--timeout DURATION` (default 10m; exit 124 on hit)
|
||||
- `--interval DURATION` (default 2s; exponential backoff to 15s + jitter)
|
||||
- Multi-id concurrent (max 5 parallel); exit code priority 1 > 124 > 0
|
||||
- **`--log-level error|warn|info|debug`** persistent flag + `WEKNORA_LOG_LEVEL`
|
||||
env. Wires into the SDK's debug logger via the additive
|
||||
`client.SetDebugLevel(level string)` function.
|
||||
- **`kb create --storage-provider <local|minio|cos|tos|s3|oss|ks3>`** —
|
||||
sets the new KB's `storage_provider_config.provider` at creation time
|
||||
(server only accepts it on create, not update). Required on self-hosted
|
||||
deployments where the server-side default doesn't pre-populate a
|
||||
provider — without it, subsequent `doc upload` returns `kb not found`.
|
||||
- **`weknora kb status <id>`** — fast health snapshot (1 HTTP). Returns
|
||||
reachable / counts / is_processing.
|
||||
- **`weknora kb check <id>`** — deep verification: status fields + `failed_count`
|
||||
aggregated via doc list page-walk (1 + N HTTP). The verb split between
|
||||
`status` (read state cheaply) and `check` (actively verify) communicates
|
||||
cost to the caller.
|
||||
- **`weknora agent status <id>`** — fast health snapshot (1 HTTP):
|
||||
reachable / model_id.
|
||||
- **`weknora agent check <id>`** — deep verification: status fields +
|
||||
`kb_scope_all_reachable` from probing each KB in scope (1 + N HTTP). Same
|
||||
status/check verb split as kb status/check.
|
||||
- **`weknora doc delete <doc-id> [<doc-id>...]`** — positional multi-id.
|
||||
Default keep-going on failure. Single `-y/--yes` confirms the entire
|
||||
batch; non-TTY without `-y` still exits 10.
|
||||
- **`weknora session delete <session-id> [<session-id>...]`** — positional
|
||||
multi-id with the same keep-going semantics as `doc delete`.
|
||||
- **`weknora chunk delete <chunk-id> [<chunk-id>...] --doc <doc-id>`** — positional
|
||||
multi-id, all chunks share the same `--doc` parent (server route requires it).
|
||||
- **`weknora api <path> --paginate`** — follows weknora's offset-based
|
||||
pagination (`?page=N&page_size=M`) and merges all pages into a single
|
||||
`{data, total}` JSON response.
|
||||
- **MCP `chat` and `agent_invoke` tools** output schemas extended with
|
||||
`thinking` / `tool_calls` / `assistant_message_id`. Tool descriptions
|
||||
callout "server-side accumulated, NOT streaming" (MCP tools/call has
|
||||
no standard partial-response).
|
||||
- **`SetAgentHelp` pattern** — `cmdutil.SetAgentHelp(cmd, AgentHelp{...})`
|
||||
exposes a stable JSON used_for / required_flags / examples / output
|
||||
shape, activated by `WEKNORA_AGENT_HELP=1` at `--help` time. Applied
|
||||
to `chat` and `kb list` as proof-of-pattern; extending to another
|
||||
command requires touching only that command's `NewCmd`.
|
||||
- **`cli/AGENTS.md`** gains an "Error code reference" section (35 typed
|
||||
codes + exit codes + retryable / hint), with `<!-- ERROR_REFERENCE_START -->`
|
||||
markers and CI parity test (`errors_doc_test.go`) — every new typed
|
||||
code in `AllCodes()` must be documented or CI fails.
|
||||
- New `operation.*` typed error namespace for CLI-level wait/poll outcomes:
|
||||
- `operation.timeout` → exit 124 (distinct from `server.timeout` → exit 7;
|
||||
matches the convention from GNU `timeout(1)`). Used by `doc wait` and
|
||||
any future CLI-level wait/poll surfaces.
|
||||
- `operation.failed` → exit 1. Emitted when one or more wait targets
|
||||
reach a terminal failure (`doc wait` finds `parse_status=failed`) or
|
||||
when multi-id `delete` rolls up partial failures. Distinct from
|
||||
`server.error` because the failure is the target's own terminal state,
|
||||
not a transient transport issue — `server.error`'s "retry with backoff"
|
||||
hint would be misleading.
|
||||
- `operation.cancelled` → exit 1, raised to **130** by `main.go` when the
|
||||
root context was signal-cancelled. Surfaced by chat / agent invoke /
|
||||
doc wait on Ctrl-C or SIGTERM. Carries a hint pointing at the signal,
|
||||
not at `-y/--yes` (which would have been the misleading
|
||||
`local.user_aborted` hint).
|
||||
- **Signal-aware root context** — `main.go` wires `signal.NotifyContext` for
|
||||
SIGINT and SIGTERM so long-running commands observe `ctx.Done()` and run
|
||||
their cancellation cleanup (re-emit auto-created session id, return
|
||||
`operation.cancelled`); the process exits 130 whenever the context was
|
||||
signal-cancelled, matching Unix signal convention.
|
||||
- **MCP tool input renames for consistency**: `doc_view` and `doc_download`
|
||||
now accept `doc_id` (was `knowledge_id`) so every MCP tool that
|
||||
references a document uses the same parameter name as `chunk_list` and
|
||||
the CLI's `<doc-id>` positional.
|
||||
- `WriteNDJSON` helper in `internal/format/` (per http://ndjson.org:
|
||||
arrays split per-line, single records emit one line).
|
||||
|
||||
#### Changed
|
||||
- `cli/README.md` "Exit codes" subsection extended with `124`
|
||||
(`operation.timeout`); rows for `1` and `130` now name `operation.failed`
|
||||
and `operation.cancelled` alongside the existing groupings.
|
||||
- `cli/README.md` gains a "Status / check verb pair" subtable under "Health
|
||||
check" and a `doc wait` paragraph with full exit-code list (0/1/124/130).
|
||||
- `cli/AGENTS.md` gains design SOPs for **Status / check verb pair pattern**
|
||||
and **Long-poll wait commands**, plus a note on the SetAgentHelp pattern
|
||||
and current coverage (chat / kb list).
|
||||
- **Multi-id delete partial-failure exit code**: `doc delete` /
|
||||
`session delete` / `chunk delete` (multi-id mode) now exit `1`
|
||||
(`operation.failed`) when some targets fail, rather than exit `7`
|
||||
(`server.error`). The retry-with-backoff hint for server.* would have
|
||||
misled callers when the actual cause is a target's terminal state.
|
||||
- **`doc upload` with no path / no `--from-url`** now exits `2`
|
||||
(`FlagError`, matching cobra's `MinimumNArgs` convention for commands
|
||||
that need a positional), rather than `5` (`input.invalid_argument`).
|
||||
- **`--log-level` invalid value** exits `2` (`FlagError`) for consistency
|
||||
with `--format` invalid-value behaviour. Env values still fall through
|
||||
silently (env is best-effort).
|
||||
- **Multi-id delete stdout contract**: pre-flight failures (e.g. missing
|
||||
`-y` confirmation) no longer emit the empty `{ok, failed}` envelope to
|
||||
stdout — stdout stays empty per the wire contract in README.md, the
|
||||
typed error goes to stderr only.
|
||||
- **Positional id help strings now namespaced** for clarity in both human
|
||||
help and agent `--help` parsing: `<id>` → `<kb-id>` / `<doc-id>` /
|
||||
`<session-id>` on kb / doc / session subtrees. `agent` and `chunk`
|
||||
subtrees were already namespaced. Pure help-text change — argument
|
||||
parsing is unchanged.
|
||||
- `chat "<text>"` Use string now shows quotes — matches `agent invoke` and
|
||||
`search chunks` quoting hint for queries that contain spaces.
|
||||
|
||||
#### SDK additions (strictly additive)
|
||||
- `client.SetDebugLevel(level string)` — programmatic control over the SDK's
|
||||
internal slog debug logger.
|
||||
|
||||
### v0.5 — agent CRUD, chunk subtree, MCP chunk_list, audit-driven cleanup
|
||||
|
||||
#### Added
|
||||
- `weknora agent create <name> --model <id>` / `agent edit <id>` /
|
||||
`agent delete <id>` — hybrid surface (hot-path flags for the common
|
||||
fields + `--config-file` YAML/JSON for the long tail +
|
||||
`--generate-skeleton` template emit). `--from <agent-id>` copies
|
||||
from an existing agent.
|
||||
- `weknora chunk list --doc <doc-id>` / `chunk view <chunk-id>` /
|
||||
`chunk delete <chunk-id> --doc <doc-id>` — new subtree for RAG retrieval
|
||||
debug. Paginated with v0.4 `--limit` / `--page-size` / `--all-pages` canon.
|
||||
- `weknora mcp serve` adds `chunk_list` as the 10th curated tool.
|
||||
- `weknora agent view <id>` human output now renders all 34 AgentConfig
|
||||
fields (previously 7), grouped into 10 presentation sections.
|
||||
- `--all-pages` / `--page-size` on `search docs` and `search sessions`
|
||||
(catching up with `session list` / `doc list` canon from v0.3+v0.4).
|
||||
- `weknora doc list` gains `--keyword` / `--file-type` / `--source` /
|
||||
`--tag-id` / `--start-time` / `--end-time` (RFC3339) — matches the
|
||||
SDK's `KnowledgeListFilter` surface. Time flags reject malformed
|
||||
input with `input.invalid_argument`.
|
||||
- MCP `doc_list` tool gains the same 6 filter fields (`keyword`,
|
||||
`file_type`, `source`, `tag_id`, `start_time`, `end_time`) so agents
|
||||
have parity with the CLI.
|
||||
- `weknora session view --full` (with `--limit`, default 50, bounds
|
||||
1..1000) loads chat history via `LoadMessages` and renders messages
|
||||
inline after session metadata. JSON mode projects messages into a
|
||||
`messages` array. `--limit` without `--full` errors with
|
||||
`input.invalid_argument`.
|
||||
- `weknora kb view` human render now includes `TYPE`, `PINNED` (badge,
|
||||
only when set), `TEMPORARY` (badge), `PROCESSING` (with doc count,
|
||||
only when active), `SUMMARY MODEL`, and `CREATED`. Nested config
|
||||
structs stay JSON-only.
|
||||
- `weknora doc view` human render expands to include `TITLE` (when
|
||||
distinct from filename), `DESC`, `SOURCE`, `CHANNEL`, `TAG`,
|
||||
`STORAGE` (human-readable bytes), `SUMMARY`, `ENABLED`, and `HASH`
|
||||
(12-char prefix). All omit-empty.
|
||||
- `weknora doc upload` gains `--enable-multimodel` (tri-state:
|
||||
unset/true/false), repeatable `--metadata key=value`, and
|
||||
`--channel` flags. `--enable-multimodel` and `--channel` apply to
|
||||
file / `--recursive` / `--from-url`; `--metadata` is file /
|
||||
`--recursive` only (the URL-ingest request carries no metadata
|
||||
field server-side, so passing it with `--from-url` is rejected
|
||||
up-front as `input.invalid_argument`). URL mode additionally
|
||||
accepts `--title`, `--file-type`, and `--tag-id`. Threads through
|
||||
to the SDK's `CreateKnowledgeFromFile` / `CreateKnowledgeFromURL`
|
||||
signatures (previously hardcoded to nil/"api" and dropped URL
|
||||
extras).
|
||||
|
||||
#### Fixed
|
||||
- MCP `search_chunks` tool: `limit` arg now correctly threads into
|
||||
`SearchParams.MatchCount`. Previously the server's default cap won,
|
||||
silently capping below the requested limit.
|
||||
- `search sessions` human time format: now renders a relative
|
||||
duration ("2 hours ago") matching `session list`, instead of raw
|
||||
RFC3339.
|
||||
- `doc upload` (file path): re-uploading a file already ingested into
|
||||
the KB now surfaces as `resource.already_exists` (exit 1) instead of
|
||||
the misleading `network.error` ("check base URL reachability"). The
|
||||
SDK returns its `ErrDuplicateFile` sentinel with no `HTTP error <n>:`
|
||||
prefix because the duplicate is detected via file-hash short-circuit,
|
||||
not by HTTP status; the previous fall-through to `WrapHTTP` therefore
|
||||
misclassified it. The `--from-url` branch already handled the
|
||||
symmetric `ErrDuplicateURL` correctly.
|
||||
|
||||
#### Breaking changes
|
||||
- `weknora search docs` now applies the keyword filter server-side via
|
||||
`ListKnowledgeWithFilter` (was: page through every doc and
|
||||
substring-match client-side). Smaller wire payload on large KBs.
|
||||
**The match is now case-sensitive** (server uses `LIKE %keyword%`),
|
||||
whereas the previous client-side path lowered both sides. Callers
|
||||
that relied on case-insensitive matching (e.g. `search docs Q3`
|
||||
finding `q3 retro`) must lower-case the query themselves, or fall
|
||||
back to `weknora api` with a custom filter.
|
||||
|
||||
#### Changed
|
||||
- `cli/AGENTS.md` MCP curation rationale rewritten: curated read-only
|
||||
is a deliberate product call gated on the absence of server-side
|
||||
per-token scope. When server-side scope ships, mutation tools can
|
||||
land in the MCP surface.
|
||||
- `cli/AGENTS.md` adds "Command surface design SOP" and "CRUD command
|
||||
flag canon" sections for future contributors. The design-SOP
|
||||
section includes a step reminding contributors to decide
|
||||
flag-vs-escape-hatch per field rather than trying to flag-mirror
|
||||
every SDK capability.
|
||||
- `cli/README.md` now documents the `weknora api` raw HTTP passthrough
|
||||
as the canonical escape hatch for deep KB config, per-request `chat`
|
||||
/ `agent invoke` overrides, and operations without a CLI verb.
|
||||
|
||||
### v0.4 — output contract hardening and mainstream alignment
|
||||
|
||||
#### Breaking changes
|
||||
- Dropped the JSON envelope. `stdout` now emits bare typed data
|
||||
(`{...}` or `[...]`); errors are written to `stderr` as `code: msg`
|
||||
with an actionable `hint:` line. Pipelines using `--json | jq` no
|
||||
longer have to filter out an envelope wrapper.
|
||||
- Dropped `--dry-run`. Destructive writes still require `-y/--yes`;
|
||||
non-TTY callers that omit `-y` exit with code 10 and
|
||||
`input.confirmation_required` so an agent must surface the prompt
|
||||
to a human before retrying.
|
||||
- Dropped the per-command AI footer that rendered when AI-coding-agent
|
||||
env detection fired. The same machine-readable guidance now lives in
|
||||
the standard `--help` (visible to all callers) and in `mcp serve`'s
|
||||
tool descriptions.
|
||||
|
||||
#### Added
|
||||
- `weknora mcp serve` — curated read-only stdio MCP server exposing 9
|
||||
tools (`kb_list`, `kb_view`, `doc_list`, `doc_view`, `doc_download`,
|
||||
`search_chunks`, `chat`, `agent_list`, `agent_invoke`). Destructive
|
||||
verbs are intentionally excluded.
|
||||
- `weknora agent list` / `agent view` / `agent invoke` — manage and
|
||||
call WeKnora's server-side Custom Agent resources.
|
||||
- `weknora auth token` — print the active credential to `stdout` for
|
||||
scripting (raw secret by default; `--json` emits `{token, mode, context}`).
|
||||
- `weknora doc upload --from-url` — ingest a remote URL.
|
||||
- `--json=fields,...` field projection and `--jq <expr>` filtering on
|
||||
every command that emits JSON.
|
||||
- `--limit` and `--all-pages` on list / search commands for bounded
|
||||
output and explicit pagination control.
|
||||
- Per-resource filter flags: `kb list --pinned`, `doc list --status`,
|
||||
`session list --since`.
|
||||
|
||||
#### Changed
|
||||
- Go toolchain bumped from 1.24 to 1.26.
|
||||
- `auth login --with-token` validates the supplied key against
|
||||
`/auth/me` before persisting, and prints an advisory if the keyring
|
||||
is unavailable and credentials fall back to a 0600 file under
|
||||
`$XDG_CONFIG_HOME/weknora/secrets/`.
|
||||
- AGENTS.md rewritten as a developer guide (~170 lines, 6 H2 sections).
|
||||
|
||||
### v0.3 — extended management surface and a `session` subtree
|
||||
|
||||
#### Added
|
||||
- `context add` / `context list` / `context remove` — first-class CRUD over
|
||||
connection targets (previously implicit via `auth login --name`).
|
||||
Removing the *current* context requires explicit `-y` (exit-10 protocol)
|
||||
because subsequent commands have no default target.
|
||||
- `auth refresh` — exchanges the stored refresh token for a new access +
|
||||
refresh pair (OAuth refresh-token grant). Transparent 401 → refresh →
|
||||
retry is also wired into the SDK transport with singleflight de-dup, so
|
||||
most callers never need to invoke this explicitly.
|
||||
- `kb edit` — partial-update edit with only-sent-fields semantics
|
||||
(`*string` options so unset fields stay unset in the PUT body).
|
||||
- `kb pin` / `kb unpin` — idempotent pin/unpin toggle; no-op when already
|
||||
in the target state (emits `_meta.warnings`, no server call).
|
||||
- `kb empty` — bulk-delete documents while preserving the KB record and
|
||||
its config. High-risk-write; exit-10 confirmation in non-TTY / `--json`
|
||||
paths.
|
||||
- `doc view <id>` — show one document's metadata (title, file name,
|
||||
type, size, parse status, embedding model, processed-at, error
|
||||
message). Counterpart to `kb view` and `session view`.
|
||||
- `doc download` — stream a knowledge file to disk (`-O FILE` /
|
||||
`-O -` for stdout) with `--clobber` controlling overwrite. Rejects
|
||||
server-supplied path-like filenames; partial writes on error are
|
||||
cleaned up.
|
||||
- `doc upload --recursive --glob '*.md'` — walk a directory and upload
|
||||
every match. Per-file `OK` / `FAIL` progress lines on the human path;
|
||||
aggregated `uploaded[]` / `failed[]` envelope on `--json`. Exit code
|
||||
typed to the first failure's class on partial failure.
|
||||
- `search chunks` / `search kb` / `search docs` / `search sessions` —
|
||||
verb-noun subtree (gh `search code/repos/issues/…` shape). `search
|
||||
chunks` is hybrid (vector + keyword) retrieval; the other three are
|
||||
client-side substring filters useful for discovering identifiers.
|
||||
All four take `--limit N` / `-L N` (1..1000) to cap returned rows.
|
||||
- `session list` / `session view` / `session delete` — chat session
|
||||
management.
|
||||
- `api --input FILE` / `api --input -` — body source for raw HTTP
|
||||
passthrough (file or stdin); mutually exclusive with `--data`.
|
||||
- `unlink` — remove the cwd's `.weknora/project.yaml` so subsequent
|
||||
commands stop auto-resolving `--kb` from it. Walks up from cwd so a
|
||||
user in a subdirectory can unlink without cd-ing to the project root.
|
||||
- Completion smoke test guards against cobra bumps silently breaking
|
||||
bash / zsh / fish / powershell completion.
|
||||
|
||||
#### SDK additions (Go client at `client/`, strictly additive)
|
||||
- `OpenKnowledgeFile(ctx, id) (filename, body io.ReadCloser, err)` — new
|
||||
primitive returning the body as a stream plus the server-suggested
|
||||
Content-Disposition filename. `DownloadKnowledgeFile` is now a thin
|
||||
wrapper (signature unchanged, gained partial-file-on-error cleanup).
|
||||
- `WithTransport(http.RoundTripper) ClientOption` — lets the CLI install
|
||||
the 401-retry transport.
|
||||
- `PathAuthLogin` / `PathAuthRefresh` constants — so HTTP middleware
|
||||
doesn't re-hardcode the literals.
|
||||
- `IsPinned bool` field on `KnowledgeBase` (server already returned it;
|
||||
SDK just hadn't modeled it).
|
||||
@@ -0,0 +1,47 @@
|
||||
# Makefile for the WeKnora CLI (cli/ Go module).
|
||||
#
|
||||
# Usage:
|
||||
# make build compile to ./bin/weknora with version metadata
|
||||
# make test go test ./...
|
||||
# make test-coverage same with coverage report
|
||||
# make lint go vet (golangci-lint planned)
|
||||
# make tidy go mod tidy
|
||||
# make clean remove ./bin
|
||||
|
||||
BINARY := weknora
|
||||
BUILD_DIR := bin
|
||||
PKG := github.com/Tencent/WeKnora/cli
|
||||
|
||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
||||
COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo none)
|
||||
DATE := $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
|
||||
LDFLAGS := -s -w \
|
||||
-X $(PKG)/internal/build.Version=$(VERSION) \
|
||||
-X $(PKG)/internal/build.Commit=$(COMMIT) \
|
||||
-X $(PKG)/internal/build.Date=$(DATE)
|
||||
|
||||
.PHONY: build test test-coverage lint tidy clean help
|
||||
|
||||
build:
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
go build -ldflags="$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY) .
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
test-coverage:
|
||||
go test -coverprofile=coverage.out ./...
|
||||
go tool cover -func=coverage.out
|
||||
|
||||
lint:
|
||||
go vet ./...
|
||||
|
||||
tidy:
|
||||
go mod tidy
|
||||
|
||||
clean:
|
||||
rm -rf $(BUILD_DIR) coverage.out
|
||||
|
||||
help:
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
|
||||
+466
@@ -0,0 +1,466 @@
|
||||
# weknora — WeKnora CLI
|
||||
|
||||
A command-line interface for the WeKnora RAG knowledge-base server. Lets you
|
||||
authenticate, manage knowledge bases and documents, run hybrid search, and
|
||||
ask streaming RAG questions from your terminal or from an AI agent.
|
||||
|
||||
```bash
|
||||
$ weknora --help
|
||||
Command-line client for the WeKnora RAG server. Manage knowledge bases
|
||||
and documents, run hybrid search, chat with grounded answers, or expose
|
||||
a curated read-only MCP tool surface for AI agents.
|
||||
|
||||
Available Commands:
|
||||
agent Manage custom agents (CRUD + status/check)
|
||||
api Make a raw API request to the WeKnora server
|
||||
auth Manage authentication credentials and profiles
|
||||
chat Ask a streaming RAG question against a knowledge base
|
||||
chunk Manage document chunks (RAG retrieval debug)
|
||||
completion Generate the autocompletion script for the specified shell
|
||||
config Inspect the CLI's resolved configuration
|
||||
doc Manage documents in a knowledge base
|
||||
doctor Run 4 self-checks: base URL, auth, server version, credential storage
|
||||
exit-codes Exit code matrix and the agent action for each
|
||||
help Help about any command
|
||||
kb Manage knowledge bases
|
||||
link Bind the current directory to a knowledge base
|
||||
mcp Run weknora as a Model Context Protocol server
|
||||
message Inspect and manage messages inside chat sessions
|
||||
model Manage models (list / view / create / update / delete)
|
||||
profile Manage CLI profiles (named connection targets)
|
||||
schema Machine-readable contract for a command (or the whole surface)
|
||||
search Search across chunks, knowledge bases, documents, or sessions
|
||||
session Manage chat sessions
|
||||
skills List and install the bundled Agent Skills
|
||||
unlink Remove the directory's knowledge-base binding
|
||||
version Show CLI build metadata
|
||||
```
|
||||
|
||||
The wire contract for AI agents is documented [below](#wire-contract).
|
||||
For contributing to the CLI source, see [AGENTS.md](AGENTS.md).
|
||||
|
||||
---
|
||||
|
||||
## Install
|
||||
|
||||
### From source
|
||||
|
||||
Requires Go 1.26+.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Tencent/WeKnora.git
|
||||
cd WeKnora/cli
|
||||
go build -o weknora .
|
||||
sudo mv weknora /usr/local/bin/ # or anywhere on $PATH
|
||||
```
|
||||
|
||||
### Pre-built binaries
|
||||
|
||||
Building from source is the supported install method today. Pre-built binaries,
|
||||
`go install`, and a Homebrew formula are planned to accompany a tagged release;
|
||||
until then, use the from-source build above.
|
||||
|
||||
---
|
||||
|
||||
## 5-minute quickstart
|
||||
|
||||
```bash
|
||||
# 1. Register your WeKnora server as a profile and make it active
|
||||
weknora profile add prod --host https://kb.example.com --use
|
||||
|
||||
# 2. Authenticate the active profile (interactive password prompt)
|
||||
weknora auth login
|
||||
|
||||
# 2b. Or pipe an API key from stdin (for CI / AI agents)
|
||||
echo "sk-..." | weknora auth login --with-token
|
||||
|
||||
# 3. List knowledge bases
|
||||
weknora kb list
|
||||
|
||||
# 4. Bind this directory to a knowledge base — subsequent commands auto-resolve --kb
|
||||
weknora link --kb my-knowledge-base
|
||||
|
||||
# 5. Upload a document, then block until parsing finishes
|
||||
weknora doc upload notes.md
|
||||
weknora doc wait doc_abc # exit 0 completed, 1 failed, 124 --timeout, 130 ^C
|
||||
weknora doc reparse doc_abc # re-trigger parsing if it failed, then wait again
|
||||
|
||||
# 6. Search
|
||||
weknora search chunks "what is reciprocal rank fusion?"
|
||||
|
||||
# 7. Ask the LLM (streams to terminal)
|
||||
weknora chat "summarise the design doc"
|
||||
|
||||
# 8. Manage custom agents and run them (see `weknora agent --help` / `weknora session --help`)
|
||||
weknora model list # discover a model id for --model
|
||||
weknora agent list
|
||||
weknora session ask --agent ag_abc "what's our q4 retention plan?"
|
||||
|
||||
# 9. Inspect a document's chunks for RAG retrieval debug
|
||||
weknora chunk list --doc doc_xyz
|
||||
|
||||
# 10. Inspect messages in a session / search across sessions
|
||||
weknora message list --session sess_abc
|
||||
weknora message search "retry policy" # cross-session Q&A retrieval
|
||||
|
||||
# 11. Resolve a pending tool approval (agent run blocked on approval event)
|
||||
weknora session tool-approval resolve pend_xxx -y # approve (after user go-ahead)
|
||||
weknora session resume sess_abc --message msg_xyz # resume the blocked stream
|
||||
|
||||
# 12. Health & verification verbs
|
||||
weknora kb status kb_abc # fast snapshot: reachable / counts / processing flag (1 HTTP)
|
||||
weknora kb check kb_abc # deep verify: also aggregates failed_count via doc list (1+N HTTP)
|
||||
weknora agent status ag_abc # fast: reachable / model_id
|
||||
weknora agent check ag_abc # deep: probes every KB in the agent's scope
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Agent quick start
|
||||
|
||||
For AI agents (any MCP-capable host) integrating WeKnora:
|
||||
|
||||
1. Install: build from source (see [Install](#install))
|
||||
2. Authenticate. In a sandbox / CI, the **stateless** path needs no `auth login`
|
||||
and writes nothing to disk — just set two env vars:
|
||||
```bash
|
||||
export WEKNORA_API_KEY="sk-…" # or WEKNORA_TOKEN for a bearer JWT
|
||||
export WEKNORA_HOST="https://kb.example.com"
|
||||
weknora kb list # already authenticated
|
||||
```
|
||||
Or, for a persisted local profile:
|
||||
```bash
|
||||
weknora profile add prod --host <server-url> --use
|
||||
weknora auth login
|
||||
```
|
||||
3. Register MCP in the host's MCP config:
|
||||
```json
|
||||
{"mcpServers": {"weknora": {"command": "weknora", "args": ["mcp", "serve"]}}}
|
||||
```
|
||||
4. Read the [wire contract](AGENTS.md#wire-contract-for-ai-agents) before
|
||||
parsing `--format json` output.
|
||||
5. Read the [exit-10 anti-patterns](AGENTS.md#exit-10-anti-patterns) before
|
||||
any destructive call.
|
||||
|
||||
**Bundled Agent Skills.** This CLI ships [Agent Skills](https://agentskills.io/specification)
|
||||
under [`skills/`](skills/) that teach an agent to drive WeKnora without trial and error:
|
||||
|
||||
- [`weknora-shared`](skills/weknora-shared/SKILL.md) — **read first**: auth/profile
|
||||
sequence, `--kb` resolution, the JSON-envelope + exit-code contract, the exit-10
|
||||
protocol, `--dry-run`, and CLI-vs-MCP selection.
|
||||
- [`weknora-rag-search`](skills/weknora-rag-search/SKILL.md) — when to use `chat`
|
||||
vs `session ask` vs `search chunks`, plus retrieval gotchas.
|
||||
|
||||
Install them with the CLI (the skills are embedded in the binary, no checkout
|
||||
needed):
|
||||
|
||||
```bash
|
||||
weknora skills install # writes to ~/.claude/skills
|
||||
weknora skills install --dir <agent-skills-dir> --force # other agents / overwrite
|
||||
weknora skills list --format json # what would be installed
|
||||
```
|
||||
|
||||
Existing files are left untouched without `--force`; `--dry-run` previews the
|
||||
file list. Each skill's frontmatter records the CLI version it was
|
||||
`tested_against`; a CI parity test (`internal/skillparity`) fails if a skill
|
||||
ever references a command, flag, or MCP tool the CLI no longer has.
|
||||
|
||||
---
|
||||
|
||||
## Multi-profile
|
||||
|
||||
`profile.*` manages profile *records* (positional `<name>`); `auth.*` operates
|
||||
on the *active* profile (override per-invocation with the global `--profile`
|
||||
flag). Create a profile first, then authenticate it:
|
||||
|
||||
```bash
|
||||
weknora profile add prod --host https://prod.example.com --use # add + switch
|
||||
weknora auth login # authenticate active (prod)
|
||||
|
||||
weknora profile add staging --host https://staging.example.com # add (stays inactive)
|
||||
echo "sk-..." | weknora --profile staging auth login --with-token # authenticate staging
|
||||
|
||||
weknora auth list
|
||||
weknora profile use prod # switch back
|
||||
```
|
||||
|
||||
Credentials are persisted to your OS keyring (Keychain on macOS, libsecret on
|
||||
Linux, Wincred on Windows) when available, otherwise to a 0600-mode file
|
||||
under `$XDG_CONFIG_HOME/weknora/secrets/`. The active profile lives in
|
||||
`~/.config/weknora/config.yaml`.
|
||||
|
||||
To remove a profile's stored credentials:
|
||||
|
||||
```bash
|
||||
weknora auth logout # active profile
|
||||
weknora --profile staging auth logout # specific profile
|
||||
weknora auth logout --all
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Wire contract
|
||||
|
||||
Designed to be AI-agent-first. Stable across minor releases; breaking
|
||||
changes announced in the changelog and the corresponding
|
||||
`weknora --version` bump. This section is the human overview; the complete,
|
||||
authoritative contract (envelope field stability, error taxonomy, streaming,
|
||||
confirmation and dry-run protocols) lives in **[AGENTS.md](AGENTS.md)**.
|
||||
|
||||
### Streams
|
||||
|
||||
- **stdout** is the data channel: bare JSON with `--format json`, or
|
||||
human-formatted output. Never carries error text.
|
||||
- **stderr** is logs, progress, warnings, and errors. A non-empty
|
||||
stderr does **not** mean failure — read the exit code.
|
||||
|
||||
### JSON output
|
||||
|
||||
Every command supports `--format json`, wrapping the resource in the
|
||||
symmetric envelope `{ok, data, meta?}` — `data` is an array for `list` /
|
||||
`search`, a single object for `view` and write outcomes. `--jq` runs
|
||||
against the whole envelope, so reach into list items with `.data[]`:
|
||||
|
||||
```bash
|
||||
weknora kb list --format json # {"ok":true,"data":[{"id":"kb_x",…}],"meta":{"count":1,…}}
|
||||
weknora kb view kb_x --format json # {"ok":true,"data":{"id":"kb_x","name":"Eng",…}}
|
||||
weknora kb list --format json --jq '.data[] | {id, name}' # project listed fields out of each item
|
||||
weknora kb list --format json --jq '.data[].id' # ids only
|
||||
weknora kb list --format json --jq '.meta.count' # number returned
|
||||
```
|
||||
|
||||
`--format ndjson` is also accepted for streaming list commands; each
|
||||
element is emitted as its own JSON line. `--format json` is the default
|
||||
regardless of TTY — running `weknora kb list | jq` works without an
|
||||
explicit flag. Use `--format text` for human-readable output.
|
||||
|
||||
### Errors
|
||||
|
||||
On failure, stdout stays empty and the typed error goes to stderr in
|
||||
this format:
|
||||
|
||||
```
|
||||
<code.namespace>: <message>[: <wrapped cause>]
|
||||
hint: <actionable next-step>
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
auth.unauthenticated: fetch current user: HTTP error 401: ...
|
||||
hint: run `weknora auth login`
|
||||
```
|
||||
|
||||
Under `--format json` the same failure is the typed error envelope on stderr
|
||||
(`{ok:false, error:{type, exit_code, hint?, retry_argv?, …}}`) — see
|
||||
[AGENTS.md §1.4](AGENTS.md) for the field-by-field contract and the full code
|
||||
taxonomy.
|
||||
|
||||
### Exit codes
|
||||
|
||||
| Code | Meaning | Agent action |
|
||||
|---|---|---|
|
||||
| `0` | success | continue |
|
||||
| `1` | typed `local.*` / `operation.failed` / unclassified | read stderr, decide retry/abort |
|
||||
| `2` | flag / argument validation error | re-check `weknora <cmd> --help` |
|
||||
| `3` | `auth.*` (token missing / expired / forbidden) | re-auth, then retry |
|
||||
| `4` | `resource.not_found` | verify the resource id |
|
||||
| `5` | `input.*` (other than `confirmation_required`) | adjust args, retry |
|
||||
| `6` | `server.rate_limited` | back off, retry |
|
||||
| `7` | `server.*` / `network.*` | transient — retry with backoff |
|
||||
| `10` | **`input.confirmation_required`** (high-risk write) | ask the human, retry with `-y` only after explicit approval |
|
||||
| `124` | `operation.timeout` (e.g. `doc wait --timeout` reached) | raise `--timeout` or check the underlying job |
|
||||
| `130` | cancelled by signal — typed `operation.cancelled` errors exit 1; `main.go` promotes the process exit to 130 when the root context was signal-cancelled (SIGINT / SIGTERM) | stop, do not retry |
|
||||
|
||||
Run `weknora exit-codes` for the machine-readable matrix (JSON); `weknora help exit-codes` for the human-readable table.
|
||||
|
||||
**Exit 10** is the wire-level signal for "destructive write needs
|
||||
explicit confirmation". Pass `-y/--yes` on `kb delete` /
|
||||
`doc delete` (including `--all --kb=<id>`) / `session delete` /
|
||||
`profile remove` (on the current profile) / `agent delete` /
|
||||
`chunk delete` when running headless.
|
||||
**Never auto-add `-y` without the user's explicit go-ahead** — exit 10
|
||||
is the guard against unintended writes.
|
||||
|
||||
### Other AI-agent ergonomics
|
||||
|
||||
- For chat / session ask in AI-agent contexts, pass `--format json` for a
|
||||
bounded answer-event envelope. Add `--reference` for indexed citations,
|
||||
`--verbose` for reasoning/tools/lifecycle events, or `--format ndjson` for
|
||||
the unmodified raw stream.
|
||||
- `--format json` composes with the global `--profile <name>` for
|
||||
single-shot profile overrides without disk writes.
|
||||
- `weknora mcp serve` exposes a curated read-only tool surface over
|
||||
stdio MCP for any MCP-compatible client.
|
||||
- `weknora schema` enumerates every command with its `used_for`, and
|
||||
`weknora schema <cmd path>` (e.g. `weknora schema doc update`) prints that
|
||||
command's full contract — `used_for`, `required_flags`, `examples`,
|
||||
`output`, `warnings`, `risk`, and local `flags` — as the standard envelope,
|
||||
so an agent can discover the surface without scraping `--help` prose.
|
||||
|
||||
---
|
||||
|
||||
## Advanced operations not exposed as flags
|
||||
|
||||
WeKnora CLI exposes top use cases as polished commands; deep
|
||||
configuration goes through the raw HTTP passthrough. CLI flag coverage
|
||||
targets common workflows, not 1:1 API parity. Examples of deep
|
||||
operations that intentionally go through `weknora api`:
|
||||
|
||||
- **Tuning a KB's nested config** — chunking strategy, summary model,
|
||||
multimodal extraction defaults, FAQ thresholds, VLM model. Use
|
||||
`weknora api PUT /api/v1/knowledge-bases/<id> --input -` with a JSON
|
||||
body matching the server's `UpdateKnowledgeBaseRequest`. (Note: the
|
||||
storage provider is set once at create time via
|
||||
`kb create --storage-provider <name>` and is not updatable.)
|
||||
- **Per-request `chat` parameters** — multi-KB scope, summary model
|
||||
override, image attachments, web search toggle. Use `weknora api POST
|
||||
/api/v1/knowledge-chat/<session-id> --input -`.
|
||||
- **Per-request `session ask --agent` overrides** — same shape via
|
||||
`weknora api POST /api/v1/agent-chat/<session-id> --input -`.
|
||||
- **Operations without a CLI verb** — register / change-password /
|
||||
OIDC flows, organization / sharing endpoints, tenant management.
|
||||
|
||||
`weknora api --help` documents the raw passthrough. Run
|
||||
`weknora doctor` first to verify auth and base URL.
|
||||
|
||||
---
|
||||
|
||||
## Dry-run preview
|
||||
|
||||
Add `--dry-run` to any mutation command to preview the would-be action without executing it. Useful for verifying flag/arg parsing before committing to a destructive operation, or for agent-side action planning.
|
||||
|
||||
```bash
|
||||
# Preview a kb create without actually creating
|
||||
weknora kb create --name "test-kb" --description "for review" --dry-run
|
||||
|
||||
# Output (single line; pretty-printed here for readability):
|
||||
# {
|
||||
# "ok": true,
|
||||
# "meta": {
|
||||
# "dry_run": true,
|
||||
# "plan": {
|
||||
# "action": "kb.create",
|
||||
# "args": {"name": "test-kb", "description": "for review"}
|
||||
# }
|
||||
# }
|
||||
# }
|
||||
# Exit code: 0
|
||||
```
|
||||
|
||||
dry-run is **offline**: no network calls, no file IO, no credential touches. Works without an active profile.
|
||||
|
||||
For destructive commands, dry-run does NOT trigger the exit-10 confirmation flow:
|
||||
|
||||
```bash
|
||||
weknora kb delete kb_xxxx --dry-run # exit 0, no prompt
|
||||
weknora kb delete kb_xxxx # exit 10, prompts for -y
|
||||
```
|
||||
|
||||
For the `api` command, dry-run requires explicit write method (POST/PUT/PATCH/DELETE); GET returns FlagError:
|
||||
|
||||
```bash
|
||||
echo '{"name":"foo"}' | weknora api -X POST /api/v1/knowledge-bases --input - --dry-run # OK
|
||||
weknora api /api/v1/knowledge-bases --dry-run # exit 2: requires explicit -X
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Resuming streams
|
||||
|
||||
The `weknora session resume` command resumes an SSE event stream for an existing assistant message. Useful for network-blip recovery or polling long-running agent invocations:
|
||||
|
||||
```bash
|
||||
# Original streaming call captures session_id + message_id from init event:
|
||||
weknora session ask "..." --agent ag_xxxx --format ndjson | tee /tmp/stream.ndjson
|
||||
# {"type":"init","session_id":"sess_abc","message_id":"msg_xyz"}
|
||||
# ... events flow ...
|
||||
# [network blip]
|
||||
|
||||
# Resume the same stream:
|
||||
weknora session resume sess_abc --message msg_xyz
|
||||
# Server REPLAYS all stored events from the start, then tails new ones.
|
||||
# Agent must dedupe (by message_id or event hash) to avoid double-processing.
|
||||
```
|
||||
|
||||
### Tool-approval unlock chain
|
||||
|
||||
An agent run may pause the stream on a tool-approval event until a human approves or rejects the pending tool call. The unlock sequence:
|
||||
|
||||
```bash
|
||||
# 1. Stream pauses with a tool-approval event carrying a pending_id.
|
||||
# 2. Surface the pending tool call to the user; get explicit go-ahead.
|
||||
weknora session tool-approval resolve pend_xxx -y # approve
|
||||
# weknora session tool-approval resolve pend_xxx --reject --reason "..." -y # reject
|
||||
# 3. Resume the stream — server replays + tails from where the run was blocked.
|
||||
weknora session resume sess_abc --message msg_xyz
|
||||
```
|
||||
|
||||
Pass `--modified-args '{"key":"value"}'` to replace tool arguments on approve (must be a non-empty JSON object). Never auto-pass `-y` — the approval is the exit-10 human-in-the-loop gate.
|
||||
|
||||
Server-side buffer TTL: 1 hour for redis mode; process lifetime for memory mode (default). After TTL, expect `local.sse_stream_aborted` typed error.
|
||||
|
||||
See `cli/AGENTS.md` "Stream recovery" section for the full agent contract.
|
||||
|
||||
---
|
||||
|
||||
## Health check
|
||||
|
||||
Run `weknora doctor` for a 4-status diagnostic (OK / warn / fail /
|
||||
skip) covering base URL reachability, authentication, server-CLI
|
||||
version skew, and credential storage backend. Add `--format json` for
|
||||
machine-readable output, `--offline` to skip network checks.
|
||||
|
||||
For per-resource verification, the `status` / `check` verb pair gives
|
||||
a fast vs deep choice:
|
||||
|
||||
| Verb | Cost | Use |
|
||||
|---|---|---|
|
||||
| `weknora kb status <kb-id>` | 1 HTTP | live counts / processing flag |
|
||||
| `weknora kb check <kb-id>` | 1+N HTTP | adds `failed_count` via doc-list page-walk |
|
||||
| `weknora agent status <agent-id>` | 1 HTTP | reachable / model_id |
|
||||
| `weknora agent check <agent-id>` | 1+N HTTP | also probes every KB in the agent's scope |
|
||||
|
||||
`weknora doc wait <doc-id> [<doc-id>...]` blocks until each document
|
||||
reaches a terminal `parse_status` (completed or failed). Exit codes:
|
||||
0 (all completed), 1 (any failed), 124 (`--timeout` reached), 130
|
||||
(Ctrl-C / SIGTERM). Multi-target is polled concurrently (max 5 in
|
||||
flight; pipe through `xargs -P` for more).
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Run unit + contract tests
|
||||
go test ./...
|
||||
|
||||
# Run the real-server e2e suite (requires WEKNORA_E2E_HOST + token env vars)
|
||||
go test -tags acceptance_e2e ./acceptance/e2e/...
|
||||
|
||||
# Static analysis
|
||||
go vet ./...
|
||||
```
|
||||
|
||||
CI (`.github/workflows/cli.yml`) runs build + unit + contract tests on Linux /
|
||||
macOS / Windows × Go 1.26, path-filtered to changes under `cli/`.
|
||||
|
||||
---
|
||||
|
||||
## Contributing / Reporting issues
|
||||
|
||||
- **Bugs and feature requests**: file an issue at
|
||||
[github.com/Tencent/WeKnora/issues](https://github.com/Tencent/WeKnora/issues).
|
||||
- **Security disclosures**: see the repository-level
|
||||
[SECURITY.md](../SECURITY.md). Do not file public issues for
|
||||
security findings.
|
||||
- **Pull requests**: the developer guide for editing the CLI lives in
|
||||
[AGENTS.md](AGENTS.md) (build / test / command-surface design SOP /
|
||||
CRUD flag conventions). Run `go test ./... -race -count=1` and `go vet ./...`
|
||||
before submitting.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT — see the repository [LICENSE](../LICENSE).
|
||||
@@ -0,0 +1,214 @@
|
||||
// cli/acceptance/contract/errorcodes_test.go
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
// TestAllReferencedCodesAreRegistered scans cli/cmd/ for every literal use of
|
||||
// cmdutil.NewError(codeXxx, ...) and cmdutil.Wrapf(codeXxx, ...) and verifies
|
||||
// that codeXxx is registered in cmdutil.AllCodes().
|
||||
//
|
||||
// ClassifyHTTPError is dynamic - callers don't pass a literal CodeXxx
|
||||
// ident. Most SDK call sites go through cmdutil.WrapHTTP(err, ...) which
|
||||
// the scanner skips entirely (it only inspects NewError / Wrapf selector
|
||||
// names). A few sites still call ClassifyHTTPError directly to inspect
|
||||
// or remap the code; those are call-expression args the scanner also
|
||||
// skips. Either way, the codes those paths can yield are bridged via
|
||||
// cmdutil.ClassifyHTTPErrorOutputs(), which enumerates every code the
|
||||
// switch can return - added to the registered set so the AST scanner
|
||||
// doesn't false-positive on them.
|
||||
//
|
||||
// Limitations:
|
||||
// - Only literal cmdutil.CodeXxx idents are detected; codes assigned to
|
||||
// a local variable then passed are NOT scanned (rare pattern).
|
||||
// - cmdutil.WrapHTTP(...) and cmdutil.ClassifyHTTPError(...) call
|
||||
// expressions are skipped - the ClassifyHTTPErrorOutputs bridge
|
||||
// covers their dynamic codes.
|
||||
func TestAllReferencedCodesAreRegistered(t *testing.T) {
|
||||
registered := make(map[cmdutil.ErrorCode]struct{})
|
||||
for _, c := range cmdutil.AllCodes() {
|
||||
registered[c] = struct{}{}
|
||||
}
|
||||
// Bridge: ClassifyHTTPError returns these dynamically; treat them as
|
||||
// "registered" for the purposes of the AST scan since callers pass the
|
||||
// function call (not a literal ident) as arg0.
|
||||
for _, c := range cmdutil.ClassifyHTTPErrorOutputs() {
|
||||
registered[c] = struct{}{}
|
||||
}
|
||||
|
||||
referenced := scanCmdDir(t, "../../cmd")
|
||||
|
||||
for code, locs := range referenced {
|
||||
if _, ok := registered[code]; !ok {
|
||||
for _, loc := range locs {
|
||||
t.Errorf("%s: error code %q referenced but not registered in cmdutil.AllCodes()", loc, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// scanCmdDir walks cli/cmd/** for *.go files (excluding tests) and collects
|
||||
// every literal cmdutil.CodeXxx ident passed as the first arg to
|
||||
// cmdutil.NewError / cmdutil.Wrapf.
|
||||
//
|
||||
// Returns map keyed by ErrorCode value (the const's underlying string), with
|
||||
// a slice of source positions for nicer error messages.
|
||||
func scanCmdDir(t *testing.T, dir string) map[cmdutil.ErrorCode][]token.Position {
|
||||
t.Helper()
|
||||
out := make(map[cmdutil.ErrorCode][]token.Position)
|
||||
fset := token.NewFileSet()
|
||||
walkAndScan(t, fset, dir, out)
|
||||
return out
|
||||
}
|
||||
|
||||
func walkAndScan(t *testing.T, fset *token.FileSet, root string, out map[cmdutil.ErrorCode][]token.Position) {
|
||||
t.Helper()
|
||||
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if filepath.Ext(path) != ".go" {
|
||||
return nil
|
||||
}
|
||||
if strings.HasSuffix(path, "_test.go") {
|
||||
return nil
|
||||
}
|
||||
f, err := parser.ParseFile(fset, path, nil, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %s: %v", path, err)
|
||||
}
|
||||
collectErrorCodes(fset, f, out)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("walk %s: %v", root, err)
|
||||
}
|
||||
}
|
||||
|
||||
func collectErrorCodes(fset *token.FileSet, f *ast.File, out map[cmdutil.ErrorCode][]token.Position) {
|
||||
ast.Inspect(f, func(n ast.Node) bool {
|
||||
call, ok := n.(*ast.CallExpr)
|
||||
if !ok || len(call.Args) == 0 {
|
||||
return true
|
||||
}
|
||||
sel, ok := call.Fun.(*ast.SelectorExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
x, ok := sel.X.(*ast.Ident)
|
||||
if !ok || x.Name != "cmdutil" {
|
||||
return true
|
||||
}
|
||||
if sel.Sel.Name != "NewError" && sel.Sel.Name != "Wrapf" {
|
||||
return true
|
||||
}
|
||||
// First arg should be cmdutil.CodeXxx (SelectorExpr ident).
|
||||
// If it's a function call (e.g. cmdutil.ClassifyHTTPError(err)), skip
|
||||
// - bridge handles those.
|
||||
arg0Sel, ok := call.Args[0].(*ast.SelectorExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
argX, ok := arg0Sel.X.(*ast.Ident)
|
||||
if !ok || argX.Name != "cmdutil" {
|
||||
return true
|
||||
}
|
||||
code, ok := identToErrorCode(arg0Sel.Sel.Name)
|
||||
if !ok {
|
||||
// Unknown ident name - record as bogus so the test fails with
|
||||
// a clear "code referenced but not registered" message.
|
||||
code = cmdutil.ErrorCode("UNKNOWN_REF:" + arg0Sel.Sel.Name)
|
||||
}
|
||||
pos := fset.Position(call.Pos())
|
||||
out[code] = append(out[code], pos)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// identToErrorCode maps an ident name like "CodeAuthUnauthenticated" to its
|
||||
// underlying ErrorCode value via a simple switch. Avoids reflect.
|
||||
// Keep in sync with cmdutil.AllCodes() - adding a new const here is the same
|
||||
// bookkeeping as adding it to AllCodes().
|
||||
func identToErrorCode(name string) (cmdutil.ErrorCode, bool) {
|
||||
switch name {
|
||||
case "CodeAuthUnauthenticated":
|
||||
return cmdutil.CodeAuthUnauthenticated, true
|
||||
case "CodeAuthTokenExpired":
|
||||
return cmdutil.CodeAuthTokenExpired, true
|
||||
case "CodeAuthBadCredential":
|
||||
return cmdutil.CodeAuthBadCredential, true
|
||||
case "CodeAuthForbidden":
|
||||
return cmdutil.CodeAuthForbidden, true
|
||||
case "CodeAuthCrossTenantBlocked":
|
||||
return cmdutil.CodeAuthCrossTenantBlocked, true
|
||||
case "CodeAuthTenantMismatch":
|
||||
return cmdutil.CodeAuthTenantMismatch, true
|
||||
case "CodeResourceNotFound":
|
||||
return cmdutil.CodeResourceNotFound, true
|
||||
case "CodeResourceAlreadyExists":
|
||||
return cmdutil.CodeResourceAlreadyExists, true
|
||||
case "CodeResourceLocked":
|
||||
return cmdutil.CodeResourceLocked, true
|
||||
case "CodeInputInvalidArgument":
|
||||
return cmdutil.CodeInputInvalidArgument, true
|
||||
case "CodeInputMissingFlag":
|
||||
return cmdutil.CodeInputMissingFlag, true
|
||||
case "CodeInputConfirmationRequired":
|
||||
return cmdutil.CodeInputConfirmationRequired, true
|
||||
case "CodeInputUnknownSubcommand":
|
||||
return cmdutil.CodeInputUnknownSubcommand, true
|
||||
case "CodeServerError":
|
||||
return cmdutil.CodeServerError, true
|
||||
case "CodeServerTimeout":
|
||||
return cmdutil.CodeServerTimeout, true
|
||||
case "CodeServerRateLimited":
|
||||
return cmdutil.CodeServerRateLimited, true
|
||||
case "CodeServerIncompatibleVersion":
|
||||
return cmdutil.CodeServerIncompatibleVersion, true
|
||||
case "CodeNetworkError":
|
||||
return cmdutil.CodeNetworkError, true
|
||||
case "CodeLocalConfigCorrupt":
|
||||
return cmdutil.CodeLocalConfigCorrupt, true
|
||||
case "CodeLocalKeychainDenied":
|
||||
return cmdutil.CodeLocalKeychainDenied, true
|
||||
case "CodeLocalFileIO":
|
||||
return cmdutil.CodeLocalFileIO, true
|
||||
case "CodeLocalProfileNotFound":
|
||||
return cmdutil.CodeLocalProfileNotFound, true
|
||||
case "CodeKBIDRequired":
|
||||
return cmdutil.CodeKBIDRequired, true
|
||||
case "CodeKBNotFound":
|
||||
return cmdutil.CodeKBNotFound, true
|
||||
case "CodeProjectLinkCorrupt":
|
||||
return cmdutil.CodeProjectLinkCorrupt, true
|
||||
case "CodeUserAborted":
|
||||
return cmdutil.CodeUserAborted, true
|
||||
case "CodeUploadFileNotFound":
|
||||
return cmdutil.CodeUploadFileNotFound, true
|
||||
case "CodeSSEStreamAborted":
|
||||
return cmdutil.CodeSSEStreamAborted, true
|
||||
case "CodeSessionCreateFailed":
|
||||
return cmdutil.CodeSessionCreateFailed, true
|
||||
case "CodeOperationTimeout":
|
||||
return cmdutil.CodeOperationTimeout, true
|
||||
case "CodeOperationFailed":
|
||||
return cmdutil.CodeOperationFailed, true
|
||||
case "CodeOperationCancelled":
|
||||
return cmdutil.CodeOperationCancelled, true
|
||||
case "CodeInternalError":
|
||||
return cmdutil.CodeInternalError, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// cli/acceptance/contract/helpers_test.go
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"flag"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/cmd"
|
||||
"github.com/Tencent/WeKnora/cli/cmd/doctor"
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
"github.com/Tencent/WeKnora/cli/internal/secrets"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// TestMain pins the doctor credential-storage outcome for the whole suite.
|
||||
// Otherwise the check probes the real OS keyring, which differs between
|
||||
// macOS dev machines (Keychain present → ok) and Linux CI runners without
|
||||
// libsecret (file fallback → warn), making golden outputs host-dependent.
|
||||
// MemStore is neither *FileStore nor a real keyring, so the doctor's
|
||||
// type-switch hits the StatusOK branch.
|
||||
func TestMain(m *testing.M) {
|
||||
restore := doctor.SetCredStoreFactoryForTest(func() (secrets.Store, error) {
|
||||
return secrets.NewMemStore(), nil
|
||||
})
|
||||
defer restore()
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
// update is the standard Go test golden-update flag.
|
||||
//
|
||||
// go test -update ./acceptance/contract/...
|
||||
var update = flag.Bool("update", false, "update golden files")
|
||||
|
||||
// newTestFactory builds a Factory whose Client returns mockClient.
|
||||
// Caller must NOT use t.Parallel() - see iostreams.SetForTest contract.
|
||||
//
|
||||
// WEKNORA_BASE_URL is set when mockServer is non-nil. buildClient does not
|
||||
// currently honor this env var (it reads from config.Host); commands that
|
||||
// need the mock URL rely on the mockClient injection above. The env is set
|
||||
// anyway for any direct net/http callers (e.g. doctor's HEAD /health).
|
||||
func newTestFactory(t *testing.T, mockServer *httptest.Server, mockClient *sdk.Client) *cmdutil.Factory {
|
||||
t.Helper()
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
t.Setenv("XDG_CACHE_HOME", t.TempDir())
|
||||
if mockServer != nil {
|
||||
t.Setenv("WEKNORA_BASE_URL", mockServer.URL)
|
||||
}
|
||||
f := cmdutil.New()
|
||||
if mockClient != nil {
|
||||
f.Client = func() (*sdk.Client, error) { return mockClient, nil }
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// runCmd executes the root command in-process and returns captured stdout/stderr.
|
||||
// Replaces iostreams.IO singleton via SetForTest (auto-restored in t.Cleanup).
|
||||
//
|
||||
// Mirrors cmd.Execute(): wires the cobra Out / Err sinks to the same buffers
|
||||
// it returns, and re-runs cmdutil.PrintError on stderr for failure cases so
|
||||
// the contract assertion sees the typed `code: message\nhint: ...` line.
|
||||
func runCmd(t *testing.T, f *cmdutil.Factory, args ...string) (stdout, stderr string, exitCode int) {
|
||||
t.Helper()
|
||||
out, errBuf := iostreams.SetForTest(t)
|
||||
root := cmd.NewRootCmd(f) // exported in cli/cmd/root.go (Task 16)
|
||||
root.SetArgs(args)
|
||||
root.SetContext(context.Background())
|
||||
root.SetOut(out)
|
||||
root.SetErr(errBuf)
|
||||
_, err := root.ExecuteC()
|
||||
if err != nil {
|
||||
err = cmd.MapCobraError(err)
|
||||
cmdutil.PrintError(iostreams.IO.Err, err)
|
||||
}
|
||||
return out.String(), errBuf.String(), cmdutil.ExitCode(err)
|
||||
}
|
||||
|
||||
// assertGolden compares got against the JSON golden file at path.
|
||||
// With -update, writes got to path.
|
||||
//
|
||||
// CRLF normalization: Windows checkouts with the default core.autocrlf=true
|
||||
// turn LF in tracked text files into CRLF on disk. The command output is
|
||||
// always LF, so byte-equal would fail despite identical content.
|
||||
// .gitattributes is the primary defense (forcing LF on testdata/**/*.json),
|
||||
// but we also strip CR here so a misconfigured contributor checkout doesn't
|
||||
// break the suite locally before they push.
|
||||
func assertGolden(t *testing.T, got []byte, path string) {
|
||||
t.Helper()
|
||||
if *update {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
t.Fatalf("mkdir testdata: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, got, 0644); err != nil {
|
||||
t.Fatalf("write golden: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
want, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read golden %s: %v (run with -update to create)", path, err)
|
||||
}
|
||||
want = stripCR(want)
|
||||
got = stripCR(got)
|
||||
if !bytes.Equal(want, got) {
|
||||
t.Errorf("stdout mismatch for %s\nwant:\n%s\ngot:\n%s", path, want, got)
|
||||
}
|
||||
}
|
||||
|
||||
// stripCR removes CR bytes so CRLF golden files (from Windows autocrlf
|
||||
// checkout) compare equal to LF runtime output.
|
||||
func stripCR(b []byte) []byte {
|
||||
return bytes.ReplaceAll(b, []byte{'\r'}, nil)
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
// cli/acceptance/contract/wire_test.go
|
||||
//
|
||||
// Wire contract test. Drives root cobra in-process for each scenario,
|
||||
// captures stdout + stderr, and asserts:
|
||||
//
|
||||
// - stdout matches a JSON golden in cli/acceptance/testdata/wire/
|
||||
// - on wantErr cases, stderr contains the expected typed error code
|
||||
//
|
||||
// Successful cases produce bare JSON on stdout (no envelope wrapper);
|
||||
// failure cases produce empty stdout (or, for `doctor`, the data object
|
||||
// the command writes before returning SilentError) and a `code: msg`
|
||||
// line on stderr.
|
||||
//
|
||||
// Cases intentionally omitted (with reason):
|
||||
// - doctor.success - non-offline path emits
|
||||
// unstable timing
|
||||
// ("reachable in 2ms").
|
||||
// Unit tests in cli/cmd/doctor
|
||||
// cover the all-pass shape;
|
||||
// doctor.success_offline is
|
||||
// the deterministic sibling
|
||||
// kept here.
|
||||
// - auth_login.success - requires stdin pipe
|
||||
// (--with-token) + keyring-
|
||||
// aware Secrets store; the
|
||||
// helper does not yet expose
|
||||
// a stdin hook.
|
||||
// - auth_login.error_auth_unauthenticated - same setup as above.
|
||||
//
|
||||
// All cases use leaf-positioned --format json (e.g. `version --format json`). --format is a
|
||||
// per-leaf flag, not a global persistent flag.
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/config"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// wireCase declares one row in the contract matrix. Optional fields:
|
||||
//
|
||||
// server - mock /api/v1/* endpoints; nil means no network.
|
||||
// preConfig - seed config.yaml under the per-test XDG_CONFIG_HOME
|
||||
// (set by newTestFactory); use for cases like
|
||||
// `profile use` that read local state without an
|
||||
// SDK round-trip.
|
||||
// wantErr - non-zero exit expected.
|
||||
// wantStderrSubstring - stderr must contain this substring (typically the
|
||||
// typed error code, e.g. "auth.unauthenticated").
|
||||
// Only meaningful when wantErr=true.
|
||||
type wireCase struct {
|
||||
name string
|
||||
args []string
|
||||
server http.HandlerFunc
|
||||
preConfig func(t *testing.T)
|
||||
wantErr bool
|
||||
wantStderrSubstring string
|
||||
}
|
||||
|
||||
// wireCases enumerates every contract scenario whose stdout is golden-pinned.
|
||||
// Order is illustrative, not load-bearing.
|
||||
var wireCases = []wireCase{
|
||||
// 1. version.success - pure local; no client touched.
|
||||
{
|
||||
name: "version.success",
|
||||
args: []string{"version", "--format", "json"},
|
||||
},
|
||||
|
||||
// 2. doctor.success_offline - only credential_storage runs; the three
|
||||
// network checks are skipped. Stable details + summary.
|
||||
{
|
||||
name: "doctor.success_offline",
|
||||
args: []string{"doctor", "--offline", "--format", "json"},
|
||||
server: doctorReachable, // ensures buildServices succeeds even if probed
|
||||
},
|
||||
|
||||
// 3. doctor.error_network - base_url returns 500 → ping fail → cascade
|
||||
// skip on auth_credential + server_version. credential_storage still
|
||||
// runs (independent). Contract: any check=fail bumps summary.failed
|
||||
// and RunE returns SilentError → exit 1 with the data object
|
||||
// written by emit() as the only stdout content.
|
||||
{
|
||||
name: "doctor.error_network",
|
||||
args: []string{"doctor", "--format", "json"},
|
||||
server: alwaysServerError,
|
||||
wantErr: true,
|
||||
},
|
||||
|
||||
// 4-7. kb list / get - SDK paths /api/v1/knowledge-bases[/<id>]
|
||||
{
|
||||
name: "kb_list.success",
|
||||
args: []string{"kb", "list", "--format", "json"},
|
||||
server: kbListTwo,
|
||||
},
|
||||
{
|
||||
name: "kb_list.success_empty",
|
||||
args: []string{"kb", "list", "--format", "json"},
|
||||
server: kbListEmpty,
|
||||
},
|
||||
{
|
||||
name: "kb_list.error_auth_forbidden",
|
||||
args: []string{"kb", "list", "--format", "json"},
|
||||
server: always403,
|
||||
wantErr: true,
|
||||
wantStderrSubstring: "auth.forbidden",
|
||||
},
|
||||
{
|
||||
name: "kb_view.success",
|
||||
args: []string{"kb", "view", "kb1", "--format", "json"},
|
||||
server: kbGetOne,
|
||||
},
|
||||
{
|
||||
name: "kb_view.error_resource_not_found",
|
||||
args: []string{"kb", "view", "missing", "--format", "json"},
|
||||
server: always404,
|
||||
wantErr: true,
|
||||
wantStderrSubstring: "resource.not_found",
|
||||
},
|
||||
|
||||
// 8. profile use - pure local I/O against config.yaml.
|
||||
{
|
||||
name: "profile_use.success",
|
||||
args: []string{"profile", "use", "production", "--format", "json"},
|
||||
preConfig: func(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "staging",
|
||||
Profiles: map[string]config.Profile{
|
||||
"staging": {Host: "https://staging.example.com"},
|
||||
"production": {Host: "https://prod.example.com"},
|
||||
},
|
||||
}
|
||||
if err := config.Save(cfg); err != nil {
|
||||
t.Fatalf("seed config: %v", err)
|
||||
}
|
||||
},
|
||||
},
|
||||
// (profile_use.error_local_context_not_found dropped - see file header.)
|
||||
|
||||
// 9-10. auth status - SDK /api/v1/auth/me, plus config inspection.
|
||||
{
|
||||
name: "auth_status.success",
|
||||
args: []string{"auth", "status", "--format", "json"},
|
||||
server: whoamiOK,
|
||||
},
|
||||
{
|
||||
name: "auth_status.error_auth_unauthenticated",
|
||||
args: []string{"auth", "status", "--format", "json"},
|
||||
server: always401,
|
||||
wantErr: true,
|
||||
wantStderrSubstring: "auth.unauthenticated",
|
||||
},
|
||||
|
||||
// 11-13. search chunks - verb-noun shape, positional query, --kb required.
|
||||
// --kb accepts either a kb_<id> (passed through) or a name (resolved via
|
||||
// list); UUID-format detection happens client-side so callers can use
|
||||
// either form interchangeably.
|
||||
{
|
||||
name: "search.success",
|
||||
args: []string{"search", "chunks", "query", "--kb=11111111-1111-4111-8111-111111111111", "--limit=3", "--format", "json"},
|
||||
server: searchTwoResults,
|
||||
},
|
||||
{
|
||||
name: "search.error_resource_not_found",
|
||||
args: []string{"search", "chunks", "query", "--kb=eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee", "--format", "json"},
|
||||
server: always404,
|
||||
wantErr: true,
|
||||
wantStderrSubstring: "resource.not_found",
|
||||
},
|
||||
{
|
||||
// --no-vector + --no-keyword is the input.invalid case; the KB UUID
|
||||
// is just there to satisfy MarkFlagRequired so validation runs deep
|
||||
// enough to hit the mutex-channel check.
|
||||
name: "search.error_input_invalid",
|
||||
args: []string{"search", "chunks", "query", "--kb=11111111-1111-4111-8111-111111111111", "--no-vector", "--no-keyword", "--format", "json"},
|
||||
wantErr: true,
|
||||
wantStderrSubstring: "input.invalid_argument",
|
||||
},
|
||||
}
|
||||
|
||||
// TestWireGolden is the matrix-runner. Cases are sequential (the
|
||||
// iostreams singleton swap inside helpers.runCmd is package-global;
|
||||
// t.Parallel is contractually forbidden - see helpers_test.go).
|
||||
func TestWireGolden(t *testing.T) {
|
||||
for _, tc := range wireCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var ts *httptest.Server
|
||||
var mockClient *sdk.Client
|
||||
if tc.server != nil {
|
||||
ts = httptest.NewServer(tc.server)
|
||||
defer ts.Close()
|
||||
mockClient = sdk.NewClient(ts.URL)
|
||||
}
|
||||
f := newTestFactory(t, ts, mockClient)
|
||||
if tc.preConfig != nil {
|
||||
tc.preConfig(t)
|
||||
}
|
||||
stdout, stderr, exit := runCmd(t, f, tc.args...)
|
||||
if tc.wantErr && exit == 0 {
|
||||
t.Errorf("expected non-zero exit, got 0; stdout=%q stderr=%q", stdout, stderr)
|
||||
}
|
||||
if !tc.wantErr && exit != 0 {
|
||||
t.Errorf("unexpected non-zero exit %d; stdout=%q stderr=%q", exit, stdout, stderr)
|
||||
}
|
||||
if tc.wantStderrSubstring != "" && !strings.Contains(stderr, tc.wantStderrSubstring) {
|
||||
t.Errorf("stderr missing %q; got %q", tc.wantStderrSubstring, stderr)
|
||||
}
|
||||
path := filepath.Join("..", "testdata", "wire", tc.name+".json")
|
||||
assertGolden(t, []byte(stdout), path)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP fixtures
|
||||
//
|
||||
// Handlers are intentionally permissive on path matching (HasSuffix) so they
|
||||
// work whether the SDK adds the /api/v1 prefix or not. The SDK pins the
|
||||
// /api/v1 prefix today; the suffix match keeps the fixtures resilient to
|
||||
// future route renames as long as the leaf path stays stable.
|
||||
|
||||
// fixedTime is the deterministic timestamp embedded in KnowledgeBase fixtures.
|
||||
// time.Time marshals to RFC3339; using a fixed value keeps the golden stable.
|
||||
var fixedTime = time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
func whoamiOK(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.HasSuffix(r.URL.Path, "/auth/me") {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
resp := sdk.CurrentUserResponse{Success: true}
|
||||
resp.Data.User = &sdk.AuthUser{ID: "usr_abc", Email: "user@example.com", TenantID: 42}
|
||||
resp.Data.Tenant = &sdk.AuthTenant{ID: 42, Name: "Acme"}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
func always401(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"error":"unauthenticated"}`))
|
||||
}
|
||||
|
||||
func always403(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_, _ = w.Write([]byte(`{"error":"forbidden"}`))
|
||||
}
|
||||
|
||||
func always404(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"error":"not found"}`))
|
||||
}
|
||||
|
||||
func alwaysServerError(w http.ResponseWriter, _ *http.Request) {
|
||||
// 5xx triggers PingBaseURL failure path and SDK transport error.
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`internal error`))
|
||||
}
|
||||
|
||||
// doctorReachable serves /health 200 (so PingBaseURL would succeed if it
|
||||
// were called). doctor.success_offline still skips ping, so this handler
|
||||
// is a no-op for that case but keeps buildServices on a happy path.
|
||||
func doctorReachable(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func kbListTwo(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.HasSuffix(r.URL.Path, "/knowledge-bases") {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
resp := sdk.KnowledgeBaseListResponse{
|
||||
Success: true,
|
||||
Data: []sdk.KnowledgeBase{
|
||||
{
|
||||
ID: "kb1",
|
||||
Name: "Onboarding Docs",
|
||||
TenantID: 42,
|
||||
EmbeddingModelID: "text-embedding-3",
|
||||
CreatedAt: fixedTime,
|
||||
UpdatedAt: fixedTime,
|
||||
KnowledgeCount: 5,
|
||||
ChunkCount: 128,
|
||||
},
|
||||
{
|
||||
ID: "kb2",
|
||||
Name: "API Reference",
|
||||
TenantID: 42,
|
||||
EmbeddingModelID: "text-embedding-3",
|
||||
CreatedAt: fixedTime,
|
||||
UpdatedAt: fixedTime,
|
||||
KnowledgeCount: 12,
|
||||
ChunkCount: 340,
|
||||
},
|
||||
},
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
func kbListEmpty(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.HasSuffix(r.URL.Path, "/knowledge-bases") {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
resp := sdk.KnowledgeBaseListResponse{Success: true, Data: []sdk.KnowledgeBase{}}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
func kbGetOne(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.HasSuffix(r.URL.Path, "/knowledge-bases/kb1") {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
resp := sdk.KnowledgeBaseResponse{
|
||||
Success: true,
|
||||
Data: sdk.KnowledgeBase{
|
||||
ID: "kb1",
|
||||
Name: "Onboarding Docs",
|
||||
Description: "Internal onboarding handbook",
|
||||
TenantID: 42,
|
||||
EmbeddingModelID: "text-embedding-3",
|
||||
CreatedAt: fixedTime,
|
||||
UpdatedAt: fixedTime,
|
||||
KnowledgeCount: 5,
|
||||
ChunkCount: 128,
|
||||
},
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
func searchTwoResults(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.Contains(r.URL.Path, "/knowledge-bases/11111111-1111-4111-8111-111111111111/hybrid-search") {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
resp := sdk.HybridSearchResponse{
|
||||
Success: true,
|
||||
Data: []*sdk.SearchResult{
|
||||
{
|
||||
ID: "chunk-1",
|
||||
Content: "first chunk content",
|
||||
KnowledgeID: "doc-1",
|
||||
ChunkIndex: 0,
|
||||
KnowledgeTitle: "Doc 1",
|
||||
Score: 0.92,
|
||||
MatchType: sdk.MatchTypeVector,
|
||||
},
|
||||
{
|
||||
ID: "chunk-2",
|
||||
Content: "second chunk content",
|
||||
KnowledgeID: "doc-2",
|
||||
ChunkIndex: 1,
|
||||
KnowledgeTitle: "Doc 2",
|
||||
Score: 0.81,
|
||||
MatchType: sdk.MatchTypeKeyword,
|
||||
},
|
||||
},
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// cli/acceptance/doc.go
|
||||
//
|
||||
// Package acceptance is the cli/ subtree top-level home for cross-cutting
|
||||
// contract / integration tests. Lives outside cli/internal/ so a future
|
||||
// reviewer immediately sees "this is the contract surface - change with care".
|
||||
//
|
||||
// Sub-packages:
|
||||
//
|
||||
// contract/ - envelope JSON shape golden + error.code registry consistency
|
||||
// e2e/ - real WeKnora server blackbox tests (testscript-style)
|
||||
package acceptance
|
||||
@@ -0,0 +1,279 @@
|
||||
//go:build acceptance_e2e
|
||||
|
||||
// Package e2e_test drives the WeKnora CLI binary against a real running
|
||||
// server to validate the RAG closing loop end-to-end.
|
||||
//
|
||||
// Build tag isolation: //go:build acceptance_e2e excludes this file from
|
||||
// the default `go test ./...` so the e2e suite only runs when explicitly
|
||||
// requested. To run:
|
||||
//
|
||||
// cd cli
|
||||
// WEKNORA_E2E_HOST=https://kb.example.com \
|
||||
// WEKNORA_E2E_TOKEN=eyJhbGc... \
|
||||
// go test -tags=acceptance_e2e -v ./acceptance/e2e/...
|
||||
//
|
||||
// Optional WEKNORA_E2E_KB_NAME_PREFIX customizes the throwaway KB name (default
|
||||
// "cli-e2e-"). Cleanup runs even on test failure via t.Cleanup so the server
|
||||
// doesn't accumulate test debris.
|
||||
package e2e_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestRAGFullLoop walks the demo MVP path: link a profile, create a KB,
|
||||
// upload a doc, wait for indexing, search it, then chat against it. Each
|
||||
// step parses the CLI's bare JSON to extract IDs for the next step -
|
||||
// validating both functional behavior and wire-contract stability.
|
||||
func TestRAGFullLoop(t *testing.T) {
|
||||
host := mustEnv(t, "WEKNORA_E2E_HOST")
|
||||
token := mustEnv(t, "WEKNORA_E2E_TOKEN")
|
||||
prefix := envOr("WEKNORA_E2E_KB_NAME_PREFIX", "cli-e2e-")
|
||||
|
||||
bin := buildBinary(t)
|
||||
xdg := t.TempDir()
|
||||
|
||||
// Inject host + token as WEKNORA_HOST / WEKNORA_TOKEN env vars. The
|
||||
// CLI's env-credential path (buildClientFromEnv) takes precedence
|
||||
// over profile config, so this bypasses the secrets-store dance
|
||||
// entirely — no keychain access, no file:// ref plumbing. This is
|
||||
// the most robust path for CI and local e2e alike.
|
||||
env := append(os.Environ(),
|
||||
"XDG_CONFIG_HOME="+xdg,
|
||||
"XDG_CACHE_HOME="+filepath.Join(xdg, "cache"),
|
||||
"WEKNORA_HOST="+host,
|
||||
"WEKNORA_TOKEN="+token,
|
||||
// SDK debug off - explicit so the CI run isn't noisy.
|
||||
"WEKNORA_LOG_LEVEL=error",
|
||||
)
|
||||
|
||||
// 1. kb create → KnowledgeBase object (envelope-wrapped: {ok, data})
|
||||
// The CLI now requires explicit model binding so uploaded docs are
|
||||
// immediately searchable. Use the server's builtin model IDs (they
|
||||
// exist on every deployment that configures builtin_models.yaml).
|
||||
embeddingModel := envOr("WEKNORA_E2E_EMBEDDING_MODEL", "Qwen/Qwen3-Embedding-8B")
|
||||
chatModel := envOr("WEKNORA_E2E_CHAT_MODEL", "deepseek-ai/DeepSeek-V3.2")
|
||||
kbName := prefix + fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
var created struct {
|
||||
OK bool `json:"ok"`
|
||||
Data struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"data"`
|
||||
}
|
||||
runJSONInto(t, bin, env, &created, "kb", "create", kbName,
|
||||
"--embedding-model", embeddingModel,
|
||||
"--chat-model", chatModel,
|
||||
"--format", "json")
|
||||
if created.Data.ID == "" {
|
||||
t.Fatalf("kb create returned no id")
|
||||
}
|
||||
t.Logf("created KB: %s (%s)", created.Data.ID, kbName)
|
||||
|
||||
t.Cleanup(func() {
|
||||
// Best-effort cleanup; a 404 means the KB was already gone.
|
||||
out, err := run(bin, env, "kb", "delete", created.Data.ID, "-y", "--format", "json")
|
||||
if err != nil {
|
||||
t.Logf("cleanup kb delete: %v\n%s", err, out)
|
||||
}
|
||||
})
|
||||
|
||||
// 2. doc upload → Knowledge object (envelope-wrapped: {ok, data})
|
||||
docPath := writeSampleDoc(t)
|
||||
var uploaded struct {
|
||||
OK bool `json:"ok"`
|
||||
Data struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
runJSONInto(t, bin, env, &uploaded, "doc", "upload", docPath, "--kb", created.Data.ID, "--format", "json")
|
||||
if uploaded.Data.ID == "" {
|
||||
t.Fatalf("doc upload returned no id")
|
||||
}
|
||||
t.Logf("uploaded doc: %s", uploaded.Data.ID)
|
||||
|
||||
// 3. poll until indexing finishes (status changes from "pending" / "processing" to "ready" / similar)
|
||||
waitDocReady(t, bin, env, created.Data.ID, uploaded.Data.ID, 90*time.Second)
|
||||
|
||||
// 4. search chunks → SearchResult list (envelope-wrapped: {ok, data})
|
||||
var searchResp struct {
|
||||
OK bool `json:"ok"`
|
||||
Data []map[string]any `json:"data"`
|
||||
}
|
||||
runJSONInto(t, bin, env, &searchResp, "search", "chunks", "sample", "--kb", created.Data.ID, "--limit", "5", "--format", "json")
|
||||
results := searchResp.Data
|
||||
if len(results) == 0 {
|
||||
t.Fatalf("search returned no results")
|
||||
}
|
||||
t.Logf("search returned %d results", len(results))
|
||||
|
||||
// 5. chat --format json → bounded answer-event envelope (--reference for citations)
|
||||
var chatEnv struct {
|
||||
OK bool `json:"ok"`
|
||||
Data struct {
|
||||
Events []struct {
|
||||
ResponseType string `json:"response_type"`
|
||||
Content string `json:"content"`
|
||||
KnowledgeReferences []struct {
|
||||
ChunkID string `json:"chunk_id"`
|
||||
} `json:"knowledge_references"`
|
||||
} `json:"events"`
|
||||
SessionID string `json:"session_id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
runJSONInto(t, bin, env, &chatEnv, "chat", "summarize the document briefly", "--kb", created.Data.ID, "--format", "json", "--reference")
|
||||
if !chatEnv.OK {
|
||||
t.Fatal("chat ok=false")
|
||||
}
|
||||
var answer strings.Builder
|
||||
refCount := 0
|
||||
for _, ev := range chatEnv.Data.Events {
|
||||
if ev.ResponseType == "answer" {
|
||||
answer.WriteString(ev.Content)
|
||||
}
|
||||
refCount += len(ev.KnowledgeReferences)
|
||||
}
|
||||
if strings.TrimSpace(answer.String()) == "" {
|
||||
t.Fatalf("chat returned empty answer")
|
||||
}
|
||||
t.Logf("chat answer (%d chars), %d reference indexes", len(answer.String()), refCount)
|
||||
if refCount == 0 {
|
||||
// Soft warning - some servers may not surface references for every
|
||||
// question, but the demo flow is supposed to.
|
||||
t.Logf("warning: chat returned 0 reference indexes (server may have a different config)")
|
||||
}
|
||||
}
|
||||
|
||||
func mustEnv(t *testing.T, key string) string {
|
||||
t.Helper()
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
t.Skipf("e2e: %s not set; skipping (set the env var or run `gh workflow run cli-e2e.yml`)", key)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func envOr(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// buildBinary compiles the CLI to a temp dir once per test run. Re-using a
|
||||
// single binary across sub-cases avoids the multi-second linker cost on each
|
||||
// step and matches gh acceptance/ build behavior.
|
||||
func buildBinary(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
out := filepath.Join(dir, "weknora")
|
||||
// Repo layout: this test sits at cli/acceptance/e2e/, so cli/ is two levels up.
|
||||
cmd := exec.Command("go", "build", "-o", out, ".")
|
||||
cmd.Dir = filepath.Join("..", "..")
|
||||
cmd.Stdout = os.Stderr
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
t.Fatalf("build cli: %v", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// writeSampleDoc emits a small bilingual doc that gives the embedder enough
|
||||
// signal for retrieval but stays tiny so indexing finishes within the poll
|
||||
// window.
|
||||
func writeSampleDoc(t *testing.T) string {
|
||||
t.Helper()
|
||||
content := `WeKnora E2E Sample Document
|
||||
|
||||
This sample document is used by the WeKnora CLI acceptance test suite to
|
||||
validate the end-to-end retrieval-augmented generation pipeline.
|
||||
|
||||
向量检索的核心思想是把文本通过 embedding 模型映射到高维向量空间,然后通过余弦相似度
|
||||
等度量找出语义最接近的内容片段。WeKnora 支持 vector + keyword 的混合检索模式。
|
||||
|
||||
The hybrid search mode combines vector similarity (semantic) with keyword
|
||||
matching (lexical) to balance recall and precision.
|
||||
`
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "sample.txt")
|
||||
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("write sample: %v", err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// waitDocReady polls `doc list` until the uploaded document's status indicates
|
||||
// indexing is complete. WeKnora server uses a few status values across versions
|
||||
// ("ready", "completed", "ok") - accept any non-pending/non-processing/non-failed
|
||||
// state so we don't break on a server-side rename. Failed status fails the test
|
||||
// fast.
|
||||
func waitDocReady(t *testing.T, bin string, env []string, kbID, docID string, timeout time.Duration) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
tick := 2 * time.Second
|
||||
type docItem struct {
|
||||
ID string `json:"id"`
|
||||
ParseStatus string `json:"parse_status"`
|
||||
}
|
||||
for time.Now().Before(deadline) {
|
||||
// doc list is envelope-wrapped: {ok, data: [...]} (bare array)
|
||||
var resp struct {
|
||||
OK bool `json:"ok"`
|
||||
Data []docItem `json:"data"`
|
||||
}
|
||||
runJSONInto(t, bin, env, &resp, "doc", "list", "--kb", kbID, "--page-size", "100", "--format", "json")
|
||||
for _, d := range resp.Data {
|
||||
if d.ID != docID {
|
||||
continue
|
||||
}
|
||||
low := strings.ToLower(d.ParseStatus)
|
||||
switch {
|
||||
case low == "failed", low == "error":
|
||||
t.Fatalf("doc %s indexing failed: status=%q", docID, d.ParseStatus)
|
||||
case low == "pending", low == "processing", low == "finalizing", low == "":
|
||||
// keep waiting
|
||||
default:
|
||||
t.Logf("doc %s ready (status=%q)", docID, d.ParseStatus)
|
||||
return
|
||||
}
|
||||
}
|
||||
time.Sleep(tick)
|
||||
}
|
||||
t.Fatalf("doc %s did not reach ready within %s", docID, timeout)
|
||||
}
|
||||
|
||||
// run executes the CLI and returns combined stdout. Errors include stderr +
|
||||
// exit code so failures are debuggable without re-running.
|
||||
func run(bin string, env []string, args ...string) ([]byte, error) {
|
||||
cmd := exec.Command(bin, args...)
|
||||
cmd.Env = env
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return stdout.Bytes(), fmt.Errorf("%s %s: %v\nstderr:\n%s", filepath.Base(bin), strings.Join(args, " "), err, stderr.String())
|
||||
}
|
||||
return stdout.Bytes(), nil
|
||||
}
|
||||
|
||||
// runJSONInto runs the CLI expecting bare JSON output and decodes stdout
|
||||
// into out (a struct, slice, or map pointer). Test fails on non-zero exit
|
||||
// or unparseable JSON.
|
||||
func runJSONInto(t *testing.T, bin string, env []string, out any, args ...string) {
|
||||
t.Helper()
|
||||
stdout, err := run(bin, env, args...)
|
||||
if err != nil {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
if err := json.Unmarshal(stdout, out); err != nil {
|
||||
t.Fatalf("parse JSON from %v: %v\nstdout:\n%s", args, err, string(stdout))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"ok":true,"data":{"profile":"","auth_source":"profile + keyring","user_id":"usr_abc","email":"user@example.com","tenant_id":42,"tenant_name":"Acme"}}
|
||||
@@ -0,0 +1 @@
|
||||
{"ok":true,"data":{"summary":{"all_passed":false,"passed":1,"failed":1,"skipped":2},"checks":[{"name":"base_url_reachable","status":"fail","details":"server returned 500","hint":"verify the host configured for the active profile (run `weknora profile list` / `weknora profile add <n> --host=...`) and network reachability"},{"name":"auth_credential","status":"skip","details":"prereq failed: base_url_reachable"},{"name":"server_version","status":"skip","details":"prereq failed: auth_credential"},{"name":"credential_storage","status":"ok","details":"keyring or file storage available"}]}}
|
||||
@@ -0,0 +1 @@
|
||||
{"ok":true,"data":{"summary":{"all_passed":false,"passed":1,"failed":0,"skipped":3},"checks":[{"name":"base_url_reachable","status":"skip","details":"offline mode"},{"name":"auth_credential","status":"skip","details":"offline mode"},{"name":"server_version","status":"skip","details":"offline mode"},{"name":"credential_storage","status":"ok","details":"keyring or file storage available"}]}}
|
||||
@@ -0,0 +1 @@
|
||||
{"ok":true,"data":[{"id":"kb1","name":"Onboarding Docs","type":"","is_temporary":false,"is_pinned":false,"description":"","tenant_id":42,"chunking_config":{"chunk_size":0,"chunk_overlap":0,"separators":null},"image_processing_config":{"model_id":""},"faq_config":null,"embedding_model_id":"text-embedding-3","summary_model_id":"","vlm_config":{"enabled":false,"model_id":""},"storage_provider_config":null,"storage_config":{"secret_id":"","secret_key":"","region":"","bucket_name":"","app_id":"","path_prefix":"","provider":""},"extract_config":null,"auto_tag_config":null,"created_at":"2025-01-01T12:00:00Z","updated_at":"2025-01-01T12:00:00Z","knowledge_count":5,"chunk_count":128,"is_processing":false,"processing_count":0},{"id":"kb2","name":"API Reference","type":"","is_temporary":false,"is_pinned":false,"description":"","tenant_id":42,"chunking_config":{"chunk_size":0,"chunk_overlap":0,"separators":null},"image_processing_config":{"model_id":""},"faq_config":null,"embedding_model_id":"text-embedding-3","summary_model_id":"","vlm_config":{"enabled":false,"model_id":""},"storage_provider_config":null,"storage_config":{"secret_id":"","secret_key":"","region":"","bucket_name":"","app_id":"","path_prefix":"","provider":""},"extract_config":null,"auto_tag_config":null,"created_at":"2025-01-01T12:00:00Z","updated_at":"2025-01-01T12:00:00Z","knowledge_count":12,"chunk_count":340,"is_processing":false,"processing_count":0}],"meta":{"count":2,"total_count":2}}
|
||||
@@ -0,0 +1 @@
|
||||
{"ok":true,"data":[],"meta":{"count":0,"total_count":0}}
|
||||
@@ -0,0 +1 @@
|
||||
{"ok":true,"data":{"id":"kb1","name":"Onboarding Docs","type":"","is_temporary":false,"is_pinned":false,"description":"Internal onboarding handbook","tenant_id":42,"chunking_config":{"chunk_size":0,"chunk_overlap":0,"separators":null},"image_processing_config":{"model_id":""},"faq_config":null,"embedding_model_id":"text-embedding-3","summary_model_id":"","vlm_config":{"enabled":false,"model_id":""},"storage_provider_config":null,"storage_config":{"secret_id":"","secret_key":"","region":"","bucket_name":"","app_id":"","path_prefix":"","provider":""},"extract_config":null,"auto_tag_config":null,"created_at":"2025-01-01T12:00:00Z","updated_at":"2025-01-01T12:00:00Z","knowledge_count":5,"chunk_count":128,"is_processing":false,"processing_count":0}}
|
||||
@@ -0,0 +1 @@
|
||||
{"ok":true,"data":{"current_profile":"production","previous_profile":"staging"},"profile":"staging"}
|
||||
@@ -0,0 +1 @@
|
||||
{"ok":true,"data":[{"id":"chunk-1","content":"first chunk content","knowledge_id":"doc-1","chunk_index":0,"knowledge_title":"Doc 1","start_at":0,"end_at":0,"seq":0,"score":0.92,"match_type":0,"chunk_type":"","image_info":"","metadata":null,"knowledge_filename":"","knowledge_source":"","knowledge_channel":""},{"id":"chunk-2","content":"second chunk content","knowledge_id":"doc-2","chunk_index":1,"knowledge_title":"Doc 2","start_at":0,"end_at":0,"seq":0,"score":0.81,"match_type":1,"chunk_type":"","image_info":"","metadata":null,"knowledge_filename":"","knowledge_source":"","knowledge_channel":""}],"meta":{"count":2}}
|
||||
@@ -0,0 +1 @@
|
||||
{"ok":true,"data":{"commit":"none","date":"unknown","version":"dev"}}
|
||||
@@ -0,0 +1,36 @@
|
||||
// Package agentcmd holds the `weknora agent` command tree:
|
||||
// list / view / create / update / delete / status / check. The directory is
|
||||
// named `agent/` to match the cobra subcommand; the Go package is `agentcmd`
|
||||
// to avoid colliding with cobra's *cobra.Command identifier.
|
||||
//
|
||||
// "agent" in this subtree refers to WeKnora's user-defined Custom
|
||||
// Agents (server resource: GET/POST /agents/...) and handles CRUD
|
||||
// operations only. Agent invocation has moved to `weknora session ask
|
||||
// --agent <id>` (see cli/cmd/session/ask.go).
|
||||
package agentcmd
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
// NewCmd builds the `weknora agent` parent and registers leaves. Called
|
||||
// from cli/cmd/root.go.
|
||||
func NewCmd(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "agent",
|
||||
Short: "Manage custom agents (CRUD + status/check)",
|
||||
Long: `Custom Agents bundle a system prompt, model, tool allow-list, and KB
|
||||
scope into an addressable resource. Create, update, list, view, check,
|
||||
or delete agents. To invoke an agent, use: weknora session ask --agent <id>`,
|
||||
}
|
||||
cmd.AddCommand(NewCmdList(f))
|
||||
cmd.AddCommand(NewCmdView(f))
|
||||
cmd.AddCommand(NewCmdCreate(f))
|
||||
cmd.AddCommand(NewCmdEdit(f))
|
||||
cmd.AddCommand(NewCmdDelete(f))
|
||||
cmd.AddCommand(NewCmdStatus(f))
|
||||
cmd.AddCommand(NewCmdCheck(f))
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package agentcmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// AgentCheckResult is the deep-verification response for `agent check <id>`.
|
||||
// Superset of AgentStatusResult: status fields + KBScopeAllReachable from
|
||||
// probing each KB in agent.Config.KnowledgeBases. Verb split with
|
||||
// `agent status`: status reads existing state cheaply, check actively
|
||||
// verifies dependencies.
|
||||
type AgentCheckResult struct {
|
||||
ID string `json:"id"`
|
||||
Reachable bool `json:"reachable"`
|
||||
ModelID string `json:"model_id,omitempty"`
|
||||
KBScopeAllReachable *bool `json:"kb_scope_all_reachable,omitempty"` // pointer so we can distinguish "not applicable" (e.g. agent unreachable) from "false"
|
||||
}
|
||||
|
||||
// AgentCheckService is the narrow SDK surface needed for agent check.
|
||||
type AgentCheckService interface {
|
||||
GetAgent(ctx context.Context, id string) (*sdk.Agent, error)
|
||||
GetKnowledgeBase(ctx context.Context, id string) (*sdk.KnowledgeBase, error)
|
||||
}
|
||||
|
||||
var agentCheckFields = []string{"id", "reachable", "model_id", "kb_scope_all_reachable"}
|
||||
|
||||
// NewCmdCheck builds `weknora agent check <id>`.
|
||||
func NewCmdCheck(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "check <agent-id>",
|
||||
Short: "Verify a custom agent end-to-end (status + kb_scope reachability)",
|
||||
Long: `Active verification of a custom agent.
|
||||
|
||||
Performs 1 + N HTTP calls:
|
||||
1 GET /agents/{id} — reachable + model_id
|
||||
N GET /kb/{kb_id} for each id in agent.config.knowledge_bases
|
||||
— sets kb_scope_all_reachable = true iff every probe succeeds
|
||||
|
||||
Use 'weknora agent status <id>' for a fast read-only health snapshot
|
||||
(1 HTTP call, no KB probing). Use 'agent check' when you need to verify
|
||||
the agent's downstream dependencies are all reachable.`,
|
||||
Example: ` weknora agent check ag_abc
|
||||
weknora agent check ag_abc --format json`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
fopts, err := cmdutil.CheckFormatFlag(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := runAgentCheck(c.Context(), cli, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return emitAgentCheck(res, fopts, iostreams.IO.Out)
|
||||
},
|
||||
}
|
||||
cmdutil.AddFormatFlag(cmd, agentCheckFields...)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "verify a custom agent end-to-end: status plus kb_scope reachability",
|
||||
RequiredFlags: []string{"<agent-id> (positional)"},
|
||||
Examples: []string{"weknora agent check agent_abc"},
|
||||
Output: "envelope.data is {id, reachable, ...} with kb_scope reachability folded in",
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
// runAgentCheck is the testable core. Never errors for "agent not
|
||||
// reachable" — Reachable=false carries that signal.
|
||||
func runAgentCheck(ctx context.Context, svc AgentCheckService, id string) (*AgentCheckResult, error) {
|
||||
a, err := svc.GetAgent(ctx, id)
|
||||
if err != nil {
|
||||
return &AgentCheckResult{ID: id, Reachable: false}, nil
|
||||
}
|
||||
res := &AgentCheckResult{ID: a.ID, Reachable: true}
|
||||
if a.Config != nil {
|
||||
res.ModelID = a.Config.ModelID
|
||||
}
|
||||
// Probe each KB in scope. Vacuously true when scope is empty or
|
||||
// config is nil.
|
||||
allOK := true
|
||||
if a.Config != nil {
|
||||
for _, kbID := range a.Config.KnowledgeBases {
|
||||
if _, err := svc.GetKnowledgeBase(ctx, kbID); err != nil {
|
||||
allOK = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
res.KBScopeAllReachable = &allOK
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// emitAgentCheck renders res. Same dispatch as emitAgentStatus.
|
||||
func emitAgentCheck(res *AgentCheckResult, fopts *cmdutil.FormatOptions, w io.Writer) error {
|
||||
switch fopts.Mode {
|
||||
case cmdutil.FormatJSON, cmdutil.FormatNDJSON:
|
||||
return fopts.Emit(w, res, nil)
|
||||
case cmdutil.FormatText, "":
|
||||
return writeAgentCheckText(w, res)
|
||||
default:
|
||||
return fmt.Errorf("unsupported --format %q for agent check", fopts.Mode)
|
||||
}
|
||||
}
|
||||
|
||||
func writeAgentCheckText(w io.Writer, res *AgentCheckResult) error {
|
||||
fmt.Fprintf(w, "ID: %s\n", res.ID)
|
||||
fmt.Fprintf(w, "Reachable: %v\n", res.Reachable)
|
||||
if !res.Reachable {
|
||||
return nil
|
||||
}
|
||||
if res.ModelID != "" {
|
||||
fmt.Fprintf(w, "Model: %s\n", res.ModelID)
|
||||
}
|
||||
if res.KBScopeAllReachable != nil {
|
||||
fmt.Fprintf(w, "KB scope OK: %v\n", *res.KBScopeAllReachable)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// compile-time check: SDK client satisfies AgentCheckService.
|
||||
var _ AgentCheckService = (*sdk.Client)(nil)
|
||||
@@ -0,0 +1,154 @@
|
||||
package agentcmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
type fakeAgentCheckSvc struct {
|
||||
agent *sdk.Agent
|
||||
agentErr error
|
||||
kbProbeOK map[string]bool // kb_id → reachable
|
||||
kbErr error // applied for any KB id not in the map
|
||||
}
|
||||
|
||||
func (f *fakeAgentCheckSvc) GetAgent(_ context.Context, id string) (*sdk.Agent, error) {
|
||||
if f.agentErr != nil {
|
||||
return nil, f.agentErr
|
||||
}
|
||||
return f.agent, nil
|
||||
}
|
||||
|
||||
func (f *fakeAgentCheckSvc) GetKnowledgeBase(_ context.Context, id string) (*sdk.KnowledgeBase, error) {
|
||||
if ok, found := f.kbProbeOK[id]; found {
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("kb %s unreachable", id)
|
||||
}
|
||||
return &sdk.KnowledgeBase{ID: id}, nil
|
||||
}
|
||||
if f.kbErr != nil {
|
||||
return nil, f.kbErr
|
||||
}
|
||||
return &sdk.KnowledgeBase{ID: id}, nil
|
||||
}
|
||||
|
||||
func TestRunAgentCheck_AllReachable(t *testing.T) {
|
||||
svc := &fakeAgentCheckSvc{
|
||||
agent: &sdk.Agent{ID: "ag_x", Config: &sdk.AgentConfig{
|
||||
ModelID: "m_x",
|
||||
KnowledgeBases: []string{"kb_a", "kb_b"},
|
||||
}},
|
||||
kbProbeOK: map[string]bool{"kb_a": true, "kb_b": true},
|
||||
}
|
||||
res, err := runAgentCheck(context.Background(), svc, "ag_x")
|
||||
if err != nil {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
if !res.Reachable {
|
||||
t.Error("Reachable=false, want true")
|
||||
}
|
||||
if res.ModelID != "m_x" {
|
||||
t.Errorf("ModelID=%q, want m_x", res.ModelID)
|
||||
}
|
||||
if res.KBScopeAllReachable == nil || !*res.KBScopeAllReachable {
|
||||
t.Errorf("KBScopeAllReachable should be true (pointer set), got %v", res.KBScopeAllReachable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAgentCheck_OneKBFails(t *testing.T) {
|
||||
svc := &fakeAgentCheckSvc{
|
||||
agent: &sdk.Agent{ID: "ag_x", Config: &sdk.AgentConfig{
|
||||
ModelID: "m_x",
|
||||
KnowledgeBases: []string{"kb_a", "kb_b"},
|
||||
}},
|
||||
kbProbeOK: map[string]bool{"kb_a": true, "kb_b": false},
|
||||
}
|
||||
res, _ := runAgentCheck(context.Background(), svc, "ag_x")
|
||||
if res.KBScopeAllReachable == nil || *res.KBScopeAllReachable {
|
||||
t.Errorf("KBScopeAllReachable should be false; got %v", res.KBScopeAllReachable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAgentCheck_Unreachable(t *testing.T) {
|
||||
svc := &fakeAgentCheckSvc{agentErr: fmt.Errorf("404")}
|
||||
res, err := runAgentCheck(context.Background(), svc, "ag_x")
|
||||
if err != nil {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
if res.Reachable {
|
||||
t.Error("Reachable=true on 404; want false")
|
||||
}
|
||||
if res.ID != "ag_x" {
|
||||
t.Errorf("ID=%q, want ag_x (echoed even on unreachable)", res.ID)
|
||||
}
|
||||
// KBScopeAllReachable should be nil when agent is unreachable
|
||||
if res.KBScopeAllReachable != nil {
|
||||
t.Errorf("KBScopeAllReachable should be nil when agent unreachable; got %v", res.KBScopeAllReachable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAgentCheck_NilConfig(t *testing.T) {
|
||||
// Defensive: Agent.Config is a pointer; nil should not panic and
|
||||
// KBScopeAllReachable should be vacuously true (no KBs to probe).
|
||||
svc := &fakeAgentCheckSvc{agent: &sdk.Agent{ID: "ag_x", Config: nil}}
|
||||
res, err := runAgentCheck(context.Background(), svc, "ag_x")
|
||||
if err != nil {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
if !res.Reachable {
|
||||
t.Error("Reachable=false, want true on nil config")
|
||||
}
|
||||
if res.ModelID != "" {
|
||||
t.Errorf("ModelID=%q, want empty (no config)", res.ModelID)
|
||||
}
|
||||
// Vacuously true: no KBs to probe
|
||||
if res.KBScopeAllReachable == nil || !*res.KBScopeAllReachable {
|
||||
t.Errorf("KBScopeAllReachable should be vacuously true for nil config; got %v", res.KBScopeAllReachable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitAgentCheck_JSON(t *testing.T) {
|
||||
trueP := true
|
||||
var buf bytes.Buffer
|
||||
res := &AgentCheckResult{ID: "ag_x", Reachable: true, ModelID: "m_x", KBScopeAllReachable: &trueP}
|
||||
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}
|
||||
if err := emitAgentCheck(res, fopts, &buf); err != nil {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data AgentCheckResult `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(buf.Bytes(), &env); err != nil {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
got := env.Data
|
||||
if got.ModelID != "m_x" {
|
||||
t.Errorf("ModelID=%q, want m_x", got.ModelID)
|
||||
}
|
||||
if got.KBScopeAllReachable == nil || !*got.KBScopeAllReachable {
|
||||
t.Errorf("KBScopeAllReachable should be true in JSON output; got %v", got.KBScopeAllReachable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitAgentCheck_Text(t *testing.T) {
|
||||
trueP := true
|
||||
var buf bytes.Buffer
|
||||
res := &AgentCheckResult{ID: "ag_x", Reachable: true, ModelID: "m_x", KBScopeAllReachable: &trueP}
|
||||
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatText}
|
||||
if err := emitAgentCheck(res, fopts, &buf); err != nil {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
for _, want := range []string{"ag_x", "m_x", "true"} {
|
||||
if !strings.Contains(buf.String(), want) {
|
||||
t.Errorf("output missing %q:\n%s", want, buf.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
package agentcmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// CreateService is the narrow SDK surface this command depends on.
|
||||
// *sdk.Client satisfies it via duck typing.
|
||||
type CreateService interface {
|
||||
CreateAgent(ctx context.Context, req *sdk.CreateAgentRequest) (*sdk.Agent, error)
|
||||
CopyAgent(ctx context.Context, id string) (*sdk.Agent, error)
|
||||
UpdateAgent(ctx context.Context, id string, req *sdk.UpdateAgentRequest) (*sdk.Agent, error)
|
||||
}
|
||||
|
||||
// CreateOptions captures flag state. SystemPromptReader and ConfigFileBody
|
||||
// are populated from --system-prompt-file and --config-file respectively
|
||||
// (or stdin when the value is "-"). The embedded flags struct tracks "was
|
||||
// set" bits so MergeAgentConfig can distinguish "user passed --foo zero"
|
||||
// from "user did not pass --foo".
|
||||
type CreateOptions struct {
|
||||
Name string
|
||||
Model string
|
||||
Description string
|
||||
SystemPrompt string
|
||||
SystemPromptReader io.Reader
|
||||
AgentMode string
|
||||
KBs []string
|
||||
KBSelectionMode string
|
||||
RerankModel string
|
||||
Temperature float64
|
||||
From string
|
||||
ConfigFileBody io.Reader
|
||||
ConfigFileKind string // "yaml" or "json"
|
||||
GenerateSkeleton bool
|
||||
DryRun bool
|
||||
|
||||
flags createFlagSet // populated in PreRunE for *Set bits
|
||||
}
|
||||
|
||||
// createFlagSet records which hot-path flags the user explicitly passed so
|
||||
// MergeAgentConfig knows which fields to overlay onto the base config.
|
||||
type createFlagSet struct {
|
||||
agentModeSet bool
|
||||
systemPromptSet bool
|
||||
rerankModelSet bool
|
||||
temperatureSet bool
|
||||
kbSelectionModeSet bool
|
||||
kbsSet bool
|
||||
}
|
||||
|
||||
const agentCreateExample = ` weknora agent create "Support Bot" --model <model-id>
|
||||
weknora agent create "Code Reviewer" --model <model-id> --system-prompt-file ./prompt.md --attach-kb kb_eng --attach-kb kb_arch
|
||||
weknora agent create "From Template" --model <model-id> --from ag_existing
|
||||
weknora agent create --generate-skeleton > my-agent.yaml
|
||||
weknora agent create "Tuned" --model <model-id> --config-file ./my-agent.yaml`
|
||||
|
||||
const agentCreateLong = `Create a new custom agent.
|
||||
|
||||
--model is required (an agent without a model cannot invoke); discover a
|
||||
valid model id with 'weknora model list'. The 7
|
||||
optional hot-path flags cover the most frequently set AgentConfig fields;
|
||||
for the remaining 27 use --config-file with a YAML or JSON document
|
||||
matching the AgentConfig schema (run --generate-skeleton to get a
|
||||
ready-to-edit template).
|
||||
|
||||
Precedence when both a config file and hot-path flags are supplied:
|
||||
hot-path flag > config-file value > server default
|
||||
|
||||
--from <id> copies an existing agent (SDK CopyAgent) then applies any
|
||||
hot-path overrides via UpdateAgent. --attach-kb on --from REPLACES the copied
|
||||
agent's KB list (not merge) — matches surgical-flag semantics.
|
||||
|
||||
AI agents: writes a new resource server-side. Failure surfaces as a
|
||||
typed code on stderr: input.invalid_argument (bad flags, bad file, or
|
||||
bad model), resource.not_found (--from <missing>), auth.unauthenticated.`
|
||||
|
||||
// NewCmdCreate builds `weknora agent create <name> --model <id>`.
|
||||
func NewCmdCreate(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &CreateOptions{}
|
||||
var systemPromptFile, configFile string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "create <name>",
|
||||
Short: "Create a new custom agent",
|
||||
Long: agentCreateLong,
|
||||
Example: agentCreateExample,
|
||||
// Allow 0 args for --generate-skeleton; PreRunE enforces the real
|
||||
// arity rule once it knows whether skeleton mode is active.
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
PreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
if opts.GenerateSkeleton {
|
||||
return nil
|
||||
}
|
||||
if len(args) != 1 {
|
||||
return cmdutil.NewFlagError(fmt.Errorf("accepts 1 arg, received %d", len(args)))
|
||||
}
|
||||
opts.Name = args[0]
|
||||
if opts.Model == "" {
|
||||
return cmdutil.NewFlagError(fmt.Errorf(`required flag(s) "model" not set`))
|
||||
}
|
||||
opts.flags.agentModeSet = cmd.Flags().Changed("agent-mode")
|
||||
opts.flags.systemPromptSet = cmd.Flags().Changed("system-prompt") || cmd.Flags().Changed("system-prompt-file")
|
||||
opts.flags.rerankModelSet = cmd.Flags().Changed("rerank-model")
|
||||
opts.flags.temperatureSet = cmd.Flags().Changed("temperature")
|
||||
opts.flags.kbSelectionModeSet = cmd.Flags().Changed("kb-selection-mode")
|
||||
opts.flags.kbsSet = cmd.Flags().Changed("attach-kb")
|
||||
|
||||
// --temperature is bounded 0.0..2.0. Reject out-of-range
|
||||
// early with a typed input.invalid_argument so users don't
|
||||
// burn a roundtrip on a value the server would also reject.
|
||||
if opts.flags.temperatureSet && (opts.Temperature < 0.0 || opts.Temperature > 2.0) {
|
||||
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument,
|
||||
fmt.Sprintf("--temperature must be in 0.0..2.0, got %g", opts.Temperature))
|
||||
}
|
||||
|
||||
// Reject an unknown --agent-mode / --kb-selection-mode up front
|
||||
// (typed exit 5) rather than letting the server fail the create.
|
||||
if err := validateAgentModeFlags(&opts.AgentMode, &opts.KBSelectionMode); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if systemPromptFile != "" {
|
||||
r, err := cmdutil.OpenInput(systemPromptFile)
|
||||
if err != nil {
|
||||
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, fmt.Sprintf("--system-prompt-file: %v", err))
|
||||
}
|
||||
opts.SystemPromptReader = r
|
||||
}
|
||||
if configFile != "" {
|
||||
r, kind, err := openConfigFile(configFile)
|
||||
if err != nil {
|
||||
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, fmt.Sprintf("--config-file: %v", err))
|
||||
}
|
||||
opts.ConfigFileBody = r
|
||||
opts.ConfigFileKind = kind
|
||||
}
|
||||
return nil
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
fopts, err := cmdutil.CheckFormatFlag(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
|
||||
planArgs := map[string]any{"name": opts.Name}
|
||||
if configFile != "" {
|
||||
planArgs["config"] = configFile
|
||||
}
|
||||
if handled, err := cmdutil.HandleDryRun(cmd, opts.DryRun, cmdutil.DryRunPlan{
|
||||
Action: "agent.create",
|
||||
Args: planArgs,
|
||||
}); handled {
|
||||
return err
|
||||
}
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Resolve --model / --rerank-model (id or name) and validate they
|
||||
// exist, matching the id-or-name policy of `kb config set`. A bogus name
|
||||
// fails fast here instead of creating an agent whose model never
|
||||
// resolves at run time.
|
||||
if opts.Model, err = cmdutil.ResolveModelRef(cmd.Context(), cli, opts.Model, "KnowledgeQA"); err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.RerankModel, err = cmdutil.ResolveModelRef(cmd.Context(), cli, opts.RerankModel, "Rerank"); err != nil {
|
||||
return err
|
||||
}
|
||||
// Resolve --attach-kb values (id or name) to canonical ids,
|
||||
// matching the --kb flag's id-or-name policy. Without this a raw
|
||||
// name is stored verbatim as a kb_id, silently creating an agent
|
||||
// whose KB reference never resolves at run time.
|
||||
if len(opts.KBs) > 0 {
|
||||
resolved := make([]string, 0, len(opts.KBs))
|
||||
for _, raw := range opts.KBs {
|
||||
id, rerr := cmdutil.ResolveKBFlag(cmd.Context(), cli, raw)
|
||||
if rerr != nil {
|
||||
return rerr
|
||||
}
|
||||
resolved = append(resolved, id)
|
||||
}
|
||||
opts.KBs = resolved
|
||||
}
|
||||
return runCreate(cmd.Context(), opts, fopts, cli)
|
||||
},
|
||||
}
|
||||
|
||||
// Required (enforced in PreRunE rather than cmd.MarkFlagRequired so
|
||||
// --generate-skeleton can bypass it — cobra's MarkFlagRequired runs
|
||||
// before PreRunE and would otherwise block the skeleton path).
|
||||
cmd.Flags().StringVar(&opts.Model, "model", "", "LLM model id or name (required, except with --generate-skeleton)")
|
||||
|
||||
// Hot-path (8 flag names, 7 distinct config fields)
|
||||
cmd.Flags().StringVar(&opts.Description, "description", "", "Agent description")
|
||||
cmd.Flags().StringVar(&opts.SystemPrompt, "system-prompt", "", "System prompt text (mutex with --system-prompt-file)")
|
||||
cmd.Flags().StringVar(&systemPromptFile, "system-prompt-file", "", "Read system prompt from FILE, or '-' for stdin")
|
||||
cmd.MarkFlagsMutuallyExclusive("system-prompt", "system-prompt-file")
|
||||
cmd.Flags().StringVar(&opts.AgentMode, "agent-mode", "", "Agent operating mode: "+strings.Join(agentModeValues, " | "))
|
||||
cmd.Flags().StringSliceVar(&opts.KBs, "attach-kb", nil, "Attach a knowledge base id (repeatable); aligns with 'agent update --add-kb'")
|
||||
cmd.Flags().StringVar(&opts.KBSelectionMode, "kb-selection-mode", "", "KB selection mode: "+strings.Join(kbSelectionModeValues, " | "))
|
||||
cmd.Flags().StringVar(&opts.RerankModel, "rerank-model", "", "Rerank model id or name")
|
||||
cmd.Flags().Float64Var(&opts.Temperature, "temperature", 0.0, "Generation temperature (0.0..2.0)")
|
||||
|
||||
// Power-user / utility
|
||||
cmd.Flags().StringVar(&opts.From, "from", "", "Copy from existing agent id (then apply other flags)")
|
||||
cmd.Flags().StringVar(&configFile, "config-file", "", "Full AgentConfig YAML or JSON (use '-' for stdin)")
|
||||
cmd.Flags().BoolVar(&opts.GenerateSkeleton, "generate-skeleton", false, "Emit blank AgentConfig YAML to stdout and exit")
|
||||
|
||||
cmdutil.AddFormatFlag(cmd, agentViewFields...)
|
||||
cmdutil.AddDryRunFlag(cmd, &opts.DryRun)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "Create a new custom agent. --model is required and accepts a model id or name; --attach-kb (repeatable) accepts KB ids or names. Emits the created Agent object.",
|
||||
RequiredFlags: []string{"<name> (positional)", "--model"},
|
||||
Examples: []string{
|
||||
`weknora agent create "Support Bot" --model gpt-4o`,
|
||||
`weknora agent create "Support Bot" --model gpt-4o --attach-kb kb_eng --system-prompt "You are a support assistant."`,
|
||||
`weknora agent create --generate-skeleton # print a blank config to fill in (no --model needed)`,
|
||||
},
|
||||
Output: "envelope.data is the created Agent object with id, name, config",
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runCreate(ctx context.Context, opts *CreateOptions, fopts *cmdutil.FormatOptions, svc CreateService) error {
|
||||
if opts.GenerateSkeleton {
|
||||
return cmdutil.GenerateAgentSkeleton(iostreams.IO.Out)
|
||||
}
|
||||
|
||||
// 1. Build base AgentConfig from --config-file (if any), else zero.
|
||||
// (For --from, the copied agent's existing config becomes the base
|
||||
// instead — set after CopyAgent below, before MergeAgentConfig.)
|
||||
var base sdk.AgentConfig
|
||||
if opts.ConfigFileBody != nil {
|
||||
parsed, err := cmdutil.LoadAgentConfig(opts.ConfigFileBody, opts.ConfigFileKind)
|
||||
if err != nil {
|
||||
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, err.Error())
|
||||
}
|
||||
base = *parsed
|
||||
}
|
||||
|
||||
// 2. Resolve system prompt (file/stdin > flag string)
|
||||
if opts.SystemPromptReader != nil {
|
||||
body, err := io.ReadAll(opts.SystemPromptReader)
|
||||
if err != nil {
|
||||
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, fmt.Sprintf("--system-prompt-file read: %v", err))
|
||||
}
|
||||
opts.SystemPrompt = strings.TrimSpace(string(body))
|
||||
}
|
||||
|
||||
// 3. Either copy-then-update, or create from scratch. For --from we
|
||||
// must seed `base` from the copied agent's existing config FIRST so
|
||||
// MergeAgentConfig preserves source fields the user did not override
|
||||
// (e.g. SystemPrompt, AgentMode, KB list). Without this seeding the
|
||||
// surgical overrides would ship UpdateAgent with the other 33 fields
|
||||
// zeroed, clobbering source state.
|
||||
if opts.From != "" {
|
||||
copied, err := svc.CopyAgent(ctx, opts.From)
|
||||
if err != nil {
|
||||
return cmdutil.WrapHTTP(err, "copy agent %s", opts.From)
|
||||
}
|
||||
if copied.Config != nil {
|
||||
base = *copied.Config
|
||||
}
|
||||
|
||||
// --attach-kb on --from REPLACES the copied KB list; when --attach-kb
|
||||
// is not set, KnowledgeBasesSet stays false inside applyCreateOverrides
|
||||
// and the copy's KB list passes through unchanged.
|
||||
cfg := applyCreateOverrides(&base, opts)
|
||||
|
||||
// Apply overrides on top of copied state via UpdateAgent (no
|
||||
// server-side template-parameters route, so we do two roundtrips).
|
||||
updateReq := &sdk.UpdateAgentRequest{
|
||||
Name: opts.Name,
|
||||
Description: opts.Description,
|
||||
Config: cfg,
|
||||
}
|
||||
updated, err := svc.UpdateAgent(ctx, copied.ID, updateReq)
|
||||
if err != nil {
|
||||
return cmdutil.WrapHTTP(err, "update copied agent %s", copied.ID)
|
||||
}
|
||||
return emitAgent(fopts, updated)
|
||||
}
|
||||
|
||||
// 4. Plain create path: apply hot-path flag overrides onto base
|
||||
// (zero-valued unless --config-file supplied content).
|
||||
cfg := applyCreateOverrides(&base, opts)
|
||||
|
||||
req := &sdk.CreateAgentRequest{
|
||||
Name: opts.Name,
|
||||
Description: opts.Description,
|
||||
Config: cfg,
|
||||
}
|
||||
created, err := svc.CreateAgent(ctx, req)
|
||||
if err != nil {
|
||||
return cmdutil.WrapHTTP(err, "create agent")
|
||||
}
|
||||
return emitAgent(fopts, created)
|
||||
}
|
||||
|
||||
// agentModeValues / kbSelectionModeValues are the closed server enums for the
|
||||
// --agent-mode / --kb-selection-mode flags, sourced from the SDK enumerators so
|
||||
// the CLI can't drift from the server vocabulary.
|
||||
var (
|
||||
agentModeValues = cmdutil.EnumStrings(sdk.AllAgentModes())
|
||||
kbSelectionModeValues = cmdutil.EnumStrings(sdk.AllKBSelectionModes())
|
||||
)
|
||||
|
||||
// validateAgentModeFlags validates and canonicalises --agent-mode and
|
||||
// --kb-selection-mode in place against the SDK enums. An empty value passes
|
||||
// through (flag unset → no override). Rejecting a typo here gives a fast,
|
||||
// typed input.invalid_argument instead of a confusing server-side failure.
|
||||
// Shared by `agent create` and `agent update`.
|
||||
func validateAgentModeFlags(agentMode, kbSelectionMode *string) error {
|
||||
v, err := cmdutil.ValidateEnum("agent-mode", *agentMode, agentModeValues)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*agentMode = v
|
||||
v, err = cmdutil.ValidateEnum("kb-selection-mode", *kbSelectionMode, kbSelectionModeValues)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*kbSelectionMode = v
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyCreateOverrides merges hot-path flag overrides into the base config,
|
||||
// then applies the "--attach-kb implies --kb-selection-mode=selected" fallback.
|
||||
// Shared by the --from path (base=copied agent's config) and the plain
|
||||
// create path (base=zero or --config-file). Keeping both paths on one
|
||||
// helper prevents silent divergence when future flags are added.
|
||||
func applyCreateOverrides(base *sdk.AgentConfig, opts *CreateOptions) *sdk.AgentConfig {
|
||||
overrides := cmdutil.AgentConfigFlags{
|
||||
AgentMode: opts.AgentMode, AgentModeSet: opts.flags.agentModeSet,
|
||||
SystemPrompt: opts.SystemPrompt, SystemPromptSet: opts.flags.systemPromptSet,
|
||||
ModelID: opts.Model, ModelIDSet: true, // --model is required
|
||||
RerankModelID: opts.RerankModel, RerankModelIDSet: opts.flags.rerankModelSet,
|
||||
Temperature: opts.Temperature, TemperatureSet: opts.flags.temperatureSet,
|
||||
KBSelectionMode: opts.KBSelectionMode, KBSelectionModeSet: opts.flags.kbSelectionModeSet,
|
||||
KnowledgeBases: opts.KBs, KnowledgeBasesSet: opts.flags.kbsSet,
|
||||
}
|
||||
cfg := cmdutil.MergeAgentConfig(base, overrides)
|
||||
|
||||
// --attach-kb without explicit --kb-selection-mode implies "selected".
|
||||
if opts.flags.kbsSet && !opts.flags.kbSelectionModeSet && cfg.KBSelectionMode == "" {
|
||||
cfg.KBSelectionMode = "selected"
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// openConfigFile returns a Reader, the detected kind ("yaml"/"json"), or
|
||||
// an error. Format is inferred from file extension; "-" defaults to YAML.
|
||||
func openConfigFile(path string) (io.Reader, string, error) {
|
||||
r, err := cmdutil.OpenInput(path)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
kind := "yaml"
|
||||
if strings.EqualFold(filepath.Ext(path), ".json") {
|
||||
kind = "json"
|
||||
}
|
||||
return r, kind, nil
|
||||
}
|
||||
|
||||
// emitAgent writes the Agent to stdout (bare SDK shape for --format json,
|
||||
// text KV otherwise). Shared by create and edit; defined here for proximity
|
||||
// to the create flow.
|
||||
func emitAgent(fopts *cmdutil.FormatOptions, ag *sdk.Agent) error {
|
||||
if fopts.WantsJSON() {
|
||||
return fopts.Emit(iostreams.IO.Out, ag, nil)
|
||||
}
|
||||
renderAgent(iostreams.IO.Out, ag)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
package agentcmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// fakeCreateSvc records all three SDK methods this command may invoke.
|
||||
type fakeCreateSvc struct {
|
||||
createReq *sdk.CreateAgentRequest
|
||||
createResp *sdk.Agent
|
||||
createErr error
|
||||
copySrcID string
|
||||
copyResp *sdk.Agent
|
||||
copyErr error
|
||||
updateID string
|
||||
updateReq *sdk.UpdateAgentRequest
|
||||
updateResp *sdk.Agent
|
||||
updateErr error
|
||||
updateCalled bool
|
||||
}
|
||||
|
||||
func (f *fakeCreateSvc) CreateAgent(_ context.Context, req *sdk.CreateAgentRequest) (*sdk.Agent, error) {
|
||||
f.createReq = req
|
||||
return f.createResp, f.createErr
|
||||
}
|
||||
func (f *fakeCreateSvc) CopyAgent(_ context.Context, id string) (*sdk.Agent, error) {
|
||||
f.copySrcID = id
|
||||
return f.copyResp, f.copyErr
|
||||
}
|
||||
func (f *fakeCreateSvc) UpdateAgent(_ context.Context, id string, req *sdk.UpdateAgentRequest) (*sdk.Agent, error) {
|
||||
f.updateCalled = true
|
||||
f.updateID = id
|
||||
f.updateReq = req
|
||||
return f.updateResp, f.updateErr
|
||||
}
|
||||
|
||||
func TestCreate_HappyPath_MinimalRequired(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeCreateSvc{createResp: &sdk.Agent{ID: "ag_new", Name: "Test"}}
|
||||
opts := &CreateOptions{Name: "Test", Model: "model-x"}
|
||||
err := runCreate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, svc.createReq)
|
||||
assert.Equal(t, "Test", svc.createReq.Name)
|
||||
require.NotNil(t, svc.createReq.Config)
|
||||
assert.Equal(t, "model-x", svc.createReq.Config.ModelID)
|
||||
}
|
||||
|
||||
func TestCreate_MissingName_FlagError(t *testing.T) {
|
||||
cmd := NewCmdCreate(nil)
|
||||
cmd.SetArgs([]string{"--model", "model-x"})
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
err := cmd.Execute()
|
||||
require.Error(t, err)
|
||||
// PreRunE rejects "0 args" with our flag-error sentinel; the message
|
||||
// always carries the canonical "accepts 1 arg" phrase.
|
||||
assert.Contains(t, err.Error(), "accepts 1 arg")
|
||||
}
|
||||
|
||||
func TestCreate_MissingModel_FlagError(t *testing.T) {
|
||||
cmd := NewCmdCreate(nil)
|
||||
cmd.SetArgs([]string{"Test"})
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
err := cmd.Execute()
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), `required flag(s) "model" not set`)
|
||||
}
|
||||
|
||||
func TestCreate_ConfigFile_FlagsOverrideFile(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeCreateSvc{createResp: &sdk.Agent{ID: "ag_new"}}
|
||||
opts := &CreateOptions{
|
||||
Name: "Test",
|
||||
Model: "model-x", // override file
|
||||
ConfigFileBody: bytes.NewBufferString(`{"agent_mode":"smart-reasoning","model_id":"model-y","temperature":0.5}`),
|
||||
ConfigFileKind: "json",
|
||||
}
|
||||
require.NoError(t, runCreate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
require.NotNil(t, svc.createReq.Config)
|
||||
assert.Equal(t, "smart-reasoning", svc.createReq.Config.AgentMode, "file value preserved when no flag override")
|
||||
assert.Equal(t, "model-x", svc.createReq.Config.ModelID, "flag overrides file")
|
||||
assert.InDelta(t, 0.5, svc.createReq.Config.Temperature, 0.001)
|
||||
}
|
||||
|
||||
func TestCreate_From_CopiesThenUpdates(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeCreateSvc{
|
||||
copyResp: &sdk.Agent{ID: "ag_clone", Name: "Source", Config: &sdk.AgentConfig{ModelID: "model-y"}},
|
||||
updateResp: &sdk.Agent{ID: "ag_clone", Name: "Renamed"},
|
||||
}
|
||||
opts := &CreateOptions{Name: "Renamed", Model: "model-x", From: "ag_source"}
|
||||
require.NoError(t, runCreate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
assert.Equal(t, "ag_source", svc.copySrcID)
|
||||
require.True(t, svc.updateCalled, "must Update after Copy when overrides present")
|
||||
assert.Equal(t, "ag_clone", svc.updateID)
|
||||
assert.Equal(t, "Renamed", svc.updateReq.Name)
|
||||
require.NotNil(t, svc.updateReq.Config)
|
||||
assert.Equal(t, "model-x", svc.updateReq.Config.ModelID)
|
||||
}
|
||||
|
||||
func TestCreate_GenerateSkeleton_NoAPICall(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeCreateSvc{}
|
||||
opts := &CreateOptions{GenerateSkeleton: true}
|
||||
require.NoError(t, runCreate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
assert.Nil(t, svc.createReq, "must not call CreateAgent")
|
||||
assert.Equal(t, "", svc.copySrcID, "must not call CopyAgent")
|
||||
assert.Contains(t, out.String(), "agent_mode:", "skeleton emitted to stdout")
|
||||
}
|
||||
|
||||
func TestCreate_RepeatedKB_ImpliesSelectedMode(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeCreateSvc{createResp: &sdk.Agent{ID: "ag_new"}}
|
||||
opts := &CreateOptions{
|
||||
Name: "Test",
|
||||
Model: "model-x",
|
||||
KBs: []string{"kb_a", "kb_b"},
|
||||
flags: createFlagSet{kbsSet: true},
|
||||
}
|
||||
require.NoError(t, runCreate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
assert.Equal(t, []string{"kb_a", "kb_b"}, svc.createReq.Config.KnowledgeBases)
|
||||
assert.Equal(t, "selected", svc.createReq.Config.KBSelectionMode, "passing --attach-kb implies selected mode")
|
||||
}
|
||||
|
||||
func TestCreate_SystemPromptFile_ReaderRead(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeCreateSvc{createResp: &sdk.Agent{ID: "ag_new"}}
|
||||
opts := &CreateOptions{
|
||||
Name: "Test",
|
||||
Model: "model-x",
|
||||
SystemPromptReader: strings.NewReader("You are a helpful assistant.\n"),
|
||||
flags: createFlagSet{systemPromptSet: true},
|
||||
}
|
||||
require.NoError(t, runCreate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
assert.Equal(t, "You are a helpful assistant.", svc.createReq.Config.SystemPrompt, "TrimSpace removes trailing newline")
|
||||
}
|
||||
|
||||
func TestCreate_From_PreservesSourceFieldsNotOverridden(t *testing.T) {
|
||||
// Regression: with --from X and only --temperature overridden, the
|
||||
// other 33 AgentConfig fields must round-trip from the copied agent.
|
||||
// Pre-fix, runCreate built `cfg` from a zero AgentConfig{} baseline,
|
||||
// so UpdateAgent shipped temperature=0.9 plus every other field
|
||||
// zeroed — clobbering source SystemPrompt / AgentMode / KBs.
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeCreateSvc{
|
||||
copyResp: &sdk.Agent{ID: "ag_clone", Config: &sdk.AgentConfig{
|
||||
ModelID: "model-y",
|
||||
SystemPrompt: "Source prompt",
|
||||
AgentMode: "smart-reasoning",
|
||||
Temperature: 0.5,
|
||||
KnowledgeBases: []string{"kb_src_a", "kb_src_b"},
|
||||
}},
|
||||
updateResp: &sdk.Agent{ID: "ag_clone"},
|
||||
}
|
||||
// Only --temperature overridden; other fields should round-trip.
|
||||
opts := &CreateOptions{
|
||||
Name: "Renamed", Model: "model-y", From: "ag_source",
|
||||
Temperature: 0.9,
|
||||
flags: createFlagSet{temperatureSet: true},
|
||||
}
|
||||
require.NoError(t, runCreate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
require.NotNil(t, svc.updateReq)
|
||||
require.NotNil(t, svc.updateReq.Config)
|
||||
assert.Equal(t, "Source prompt", svc.updateReq.Config.SystemPrompt, "source SystemPrompt must round-trip")
|
||||
assert.Equal(t, "smart-reasoning", svc.updateReq.Config.AgentMode, "source AgentMode must round-trip")
|
||||
assert.Equal(t, []string{"kb_src_a", "kb_src_b"}, svc.updateReq.Config.KnowledgeBases, "source KB list must round-trip when --attach-kb not passed")
|
||||
assert.InDelta(t, 0.9, svc.updateReq.Config.Temperature, 0.001, "Temperature overridden")
|
||||
}
|
||||
|
||||
func TestCreate_From_KBReplacesSourceList(t *testing.T) {
|
||||
// --attach-kb on --from REPLACES the copied agent's KB list (instead of
|
||||
// merging with it). The override semantic matches a from-scratch
|
||||
// `agent create --attach-kb a --attach-kb b`: whatever was on the source agent is
|
||||
// discarded for KBs the caller explicitly listed.
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeCreateSvc{
|
||||
copyResp: &sdk.Agent{ID: "ag_clone", Config: &sdk.AgentConfig{
|
||||
ModelID: "model-y",
|
||||
KnowledgeBases: []string{"kb_src_a", "kb_src_b"},
|
||||
}},
|
||||
updateResp: &sdk.Agent{ID: "ag_clone"},
|
||||
}
|
||||
opts := &CreateOptions{
|
||||
Name: "X", Model: "model-y", From: "ag_source",
|
||||
KBs: []string{"kb_new"},
|
||||
flags: createFlagSet{kbsSet: true},
|
||||
}
|
||||
require.NoError(t, runCreate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
require.NotNil(t, svc.updateReq.Config)
|
||||
assert.Equal(t, []string{"kb_new"}, svc.updateReq.Config.KnowledgeBases, "--attach-kb replaces source KB list")
|
||||
assert.Equal(t, "selected", svc.updateReq.Config.KBSelectionMode, "--attach-kb on --from implies selected mode")
|
||||
}
|
||||
|
||||
func TestCreate_Temperature_Bounds(t *testing.T) {
|
||||
for _, badT := range []float64{-0.1, 2.1, 100.0} {
|
||||
t.Run(fmt.Sprintf("t=%g", badT), func(t *testing.T) {
|
||||
cmd := NewCmdCreate(nil)
|
||||
cmd.SetArgs([]string{"Test", "--model", "model-x", "--temperature", fmt.Sprintf("%f", badT)})
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
err := cmd.Execute()
|
||||
require.Error(t, err, "expected error for --temperature %g", badT)
|
||||
assert.Contains(t, err.Error(), "0.0..2.0")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreate_AgentModeValidation: an unknown --agent-mode / --kb-selection-mode
|
||||
// is rejected up front (PreRunE) with a typed input.invalid_argument (exit 5),
|
||||
// while a valid value passes the gate. The closed set is sourced from the SDK
|
||||
// enumerators, so this also guards against CLI/SDK drift.
|
||||
func TestCreate_AgentModeValidation(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr string // substring; "" means the mode gate must pass
|
||||
}{
|
||||
{"bad agent-mode", []string{"--agent-mode", "bogus"}, "agent-mode"},
|
||||
{"bad kb-selection-mode", []string{"--kb-selection-mode", "every"}, "kb-selection-mode"},
|
||||
{"valid agent-mode", []string{"--agent-mode", "smart-reasoning", "--dry-run"}, ""},
|
||||
{"valid kb-selection-mode", []string{"--kb-selection-mode", "selected", "--dry-run"}, ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
cmd := NewCmdCreate(nil)
|
||||
cmd.SetArgs(append([]string{"Test", "--model", "model-x"}, tc.args...))
|
||||
cmd.SilenceUsage, cmd.SilenceErrors = true, true
|
||||
err := cmd.Execute()
|
||||
if tc.wantErr == "" {
|
||||
// Valid mode + --dry-run: must clear the mode gate. (--dry-run
|
||||
// short-circuits before any client call, so nil factory is fine.)
|
||||
require.NoError(t, err)
|
||||
return
|
||||
}
|
||||
require.Error(t, err)
|
||||
var ce *cmdutil.Error
|
||||
require.ErrorAs(t, err, &ce)
|
||||
assert.Equal(t, cmdutil.CodeInputInvalidArgument, ce.Code)
|
||||
assert.Equal(t, 5, cmdutil.ExitCode(err))
|
||||
assert.Contains(t, err.Error(), tc.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreate_CopyAgent_NotFound(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeCreateSvc{copyErr: errBadHTTP404}
|
||||
opts := &CreateOptions{Name: "X", Model: "model-x", From: "ag_missing"}
|
||||
err := runCreate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "resource.not_found")
|
||||
}
|
||||
|
||||
// errBadHTTP404 simulates the SDK's "HTTP error 404: not found" format that
|
||||
// ClassifyHTTPError parses. Defined here so create_test and edit_test/delete_test
|
||||
// can share it via package scope without spinning up an HTTP server.
|
||||
var errBadHTTP404 = &simpleErr{msg: "HTTP error 404: not found"}
|
||||
|
||||
type simpleErr struct{ msg string }
|
||||
|
||||
func (e *simpleErr) Error() string { return e.msg }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// --attach-kb (renamed from --kb)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestCreate_AttachKBFlagExists asserts that `--attach-kb` is a registered flag
|
||||
// on `agent create` and that the old `--kb` flag no longer exists.
|
||||
func TestCreate_AttachKBFlagExists(t *testing.T) {
|
||||
cmd := NewCmdCreate(nil)
|
||||
// --attach-kb must exist
|
||||
f := cmd.Flags().Lookup("attach-kb")
|
||||
require.NotNil(t, f, "--attach-kb flag must be registered on 'agent create'")
|
||||
// bare --kb must NOT exist (renamed)
|
||||
old := cmd.Flags().Lookup("kb")
|
||||
assert.Nil(t, old, "old --kb flag must not exist on 'agent create' (renamed to --attach-kb)")
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package agentcmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
"github.com/Tencent/WeKnora/cli/internal/prompt"
|
||||
)
|
||||
|
||||
// agentDeleteFields enumerates the JSON discovery fields for `agent delete`.
|
||||
// Result payload is a tiny {id, deleted} object — mirrors `kb delete`.
|
||||
var agentDeleteFields = []string{"id", "deleted"}
|
||||
|
||||
type DeleteOptions struct {
|
||||
AgentID string
|
||||
Yes bool // sourced from the global -y/--yes persistent flag
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
// DeleteService is the narrow SDK surface this command depends on.
|
||||
type DeleteService interface {
|
||||
DeleteAgent(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
// deleteResult is the typed payload emitted on success in JSON mode.
|
||||
type deleteResult struct {
|
||||
ID string `json:"id"`
|
||||
Deleted bool `json:"deleted"`
|
||||
}
|
||||
|
||||
// Delete is NOT idempotent on a missing id — it surfaces resource.not_found
|
||||
// rather than silently exiting 0. Idempotent-already-true semantics are
|
||||
// reserved for unlink-style local cleanups, not server-side resource removal.
|
||||
const agentDeleteLong = `Permanently delete a custom agent.
|
||||
|
||||
Prompts for confirmation by default when stdout is a TTY and --format json is
|
||||
not set. Pass -y/--yes (the global flag) to skip the prompt (required in
|
||||
agent / CI / piped contexts).
|
||||
|
||||
Typed exit codes:
|
||||
resource.not_found no agent with the given id (exit 4)
|
||||
auth.forbidden caller lacks delete permission on the agent (exit 3)
|
||||
input.confirmation_required destructive op without -y on a TTY (exit 10)
|
||||
|
||||
AI agents: This is a high-risk write. Without -y/--yes the CLI exits 10
|
||||
and writes input.confirmation_required to stderr. NEVER auto-pass -y
|
||||
without the user's explicit go-ahead — the exit-10 protocol exists
|
||||
exactly to guard against unintended deletes.`
|
||||
|
||||
const agentDeleteExample = ` weknora agent delete ag_abc # interactive confirm
|
||||
weknora agent delete ag_abc -y # no prompt
|
||||
weknora agent delete ag_abc -y --format json # bare {id, deleted:true} JSON`
|
||||
|
||||
// NewCmdDelete builds `weknora agent delete <agent-id>`.
|
||||
func NewCmdDelete(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &DeleteOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "delete <agent-id>",
|
||||
Short: "Delete a custom agent",
|
||||
Long: agentDeleteLong,
|
||||
Example: agentDeleteExample,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
fopts, err := cmdutil.CheckFormatFlag(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
|
||||
opts.AgentID = args[0]
|
||||
opts.Yes, _ = cmd.Flags().GetBool("yes")
|
||||
if handled, err := cmdutil.HandleDryRun(cmd, opts.DryRun, cmdutil.DryRunPlan{
|
||||
Action: "agent.delete",
|
||||
Args: map[string]any{
|
||||
"agent_id": opts.AgentID,
|
||||
},
|
||||
}); handled {
|
||||
return err
|
||||
}
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runDelete(cmd.Context(), opts, fopts, cli, f.Prompter())
|
||||
},
|
||||
}
|
||||
cmdutil.AddFormatFlag(cmd, agentDeleteFields...)
|
||||
cmdutil.AddDryRunFlag(cmd, &opts.DryRun)
|
||||
cmdutil.SetRisk(cmd, "agent.delete")
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "permanently delete a custom agent",
|
||||
RequiredFlags: []string{"<agent-id> (positional)"},
|
||||
Output: "envelope.data is {id, deleted:true}",
|
||||
Examples: []string{
|
||||
"weknora agent delete ag_abc -y",
|
||||
"weknora agent delete ag_abc -y --format json",
|
||||
},
|
||||
Warnings: []string{
|
||||
"Requires explicit user approval (exit 10 / input.confirmation_required); never auto-add -y.",
|
||||
"agent delete is irreversible; loses the agent + its KB binding + custom skills.",
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runDelete(ctx context.Context, opts *DeleteOptions, fopts *cmdutil.FormatOptions, svc DeleteService, p prompt.Prompter) error {
|
||||
if err := cmdutil.ConfirmDestructive(p, opts.Yes, fopts.WantsJSON(), "delete", "agent", opts.AgentID, "agent.delete", []string{"weknora", "agent", "delete", opts.AgentID, "-y"}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := svc.DeleteAgent(ctx, opts.AgentID); err != nil {
|
||||
return cmdutil.WrapHTTP(err, "delete agent %s", opts.AgentID)
|
||||
}
|
||||
if fopts.WantsJSON() {
|
||||
return fopts.Emit(iostreams.IO.Out, deleteResult{ID: opts.AgentID, Deleted: true}, nil)
|
||||
}
|
||||
fmt.Fprintf(iostreams.IO.Out, "✓ Deleted agent %s\n", opts.AgentID)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package agentcmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
"github.com/Tencent/WeKnora/cli/internal/testutil"
|
||||
)
|
||||
|
||||
type fakeDeleteSvc struct {
|
||||
gotID string
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeDeleteSvc) DeleteAgent(_ context.Context, id string) error {
|
||||
f.gotID = id
|
||||
return f.err
|
||||
}
|
||||
|
||||
func TestDelete_NonTTY_NoYes_ExitTen(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t) // non-TTY
|
||||
svc := &fakeDeleteSvc{}
|
||||
err := runDelete(
|
||||
context.Background(),
|
||||
&DeleteOptions{AgentID: "ag_abc", Yes: false},
|
||||
&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, &testutil.ConfirmPrompter{},
|
||||
)
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeInputConfirmationRequired, typed.Code)
|
||||
assert.Empty(t, svc.gotID, "must not call DeleteAgent without confirm")
|
||||
assert.Equal(t, 10, cmdutil.ExitCode(err), "exit code 10 per destructive-write protocol")
|
||||
}
|
||||
|
||||
func TestDelete_NonTTY_WithYes_Direct(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeDeleteSvc{}
|
||||
require.NoError(t, runDelete(
|
||||
context.Background(),
|
||||
&DeleteOptions{AgentID: "ag_abc", Yes: true},
|
||||
&cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, &testutil.ConfirmPrompter{},
|
||||
))
|
||||
assert.Equal(t, "ag_abc", svc.gotID)
|
||||
assert.Contains(t, out.String(), "ag_abc")
|
||||
}
|
||||
|
||||
func TestDelete_404_PropagatesNotFound(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeDeleteSvc{err: errBadHTTP404}
|
||||
err := runDelete(
|
||||
context.Background(),
|
||||
&DeleteOptions{AgentID: "ag_missing", Yes: true},
|
||||
&cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, &testutil.ConfirmPrompter{},
|
||||
)
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeResourceNotFound, typed.Code)
|
||||
}
|
||||
|
||||
func TestDelete_TTY_ConfirmYes(t *testing.T) {
|
||||
_, _ = iostreams.SetForTestWithTTY(t)
|
||||
svc := &fakeDeleteSvc{}
|
||||
p := &testutil.ConfirmPrompter{Answer: true}
|
||||
require.NoError(t, runDelete(
|
||||
context.Background(),
|
||||
&DeleteOptions{AgentID: "ag_abc"},
|
||||
&cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, p,
|
||||
))
|
||||
assert.True(t, p.Asked)
|
||||
assert.Equal(t, "ag_abc", svc.gotID)
|
||||
}
|
||||
|
||||
func TestDelete_TTY_ConfirmNo(t *testing.T) {
|
||||
_, errBuf := iostreams.SetForTestWithTTY(t)
|
||||
svc := &fakeDeleteSvc{}
|
||||
p := &testutil.ConfirmPrompter{Answer: false}
|
||||
err := runDelete(
|
||||
context.Background(),
|
||||
&DeleteOptions{AgentID: "ag_abc"},
|
||||
&cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, p,
|
||||
)
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeUserAborted, typed.Code)
|
||||
assert.Empty(t, svc.gotID, "answer=no must not call DeleteAgent")
|
||||
assert.Contains(t, errBuf.String(), "Aborted")
|
||||
}
|
||||
|
||||
func TestDelete_JSON_BareObject(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeDeleteSvc{}
|
||||
require.NoError(t, runDelete(
|
||||
context.Background(),
|
||||
&DeleteOptions{AgentID: "ag_abc", Yes: true},
|
||||
&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, &testutil.ConfirmPrompter{},
|
||||
))
|
||||
assert.Contains(t, out.String(), `"id":"ag_abc"`)
|
||||
assert.Contains(t, out.String(), `"deleted":true`)
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
package agentcmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// EditService is the narrow SDK surface this command depends on. The fetch
|
||||
// half (GetAgent) is mandatory because UpdateAgent is a full PUT — without
|
||||
// the pre-fetch baseline, any field not passed as a flag would silently
|
||||
// clobber to the zero value.
|
||||
type EditService interface {
|
||||
GetAgent(ctx context.Context, id string) (*sdk.Agent, error)
|
||||
UpdateAgent(ctx context.Context, id string, req *sdk.UpdateAgentRequest) (*sdk.Agent, error)
|
||||
// ListModels backs --model / --rerank-model id-or-name resolution so a
|
||||
// bogus name fails fast instead of clobbering config.model_id with an
|
||||
// unresolvable string (which never resolves at run time).
|
||||
ListModels(ctx context.Context) ([]sdk.Model, error)
|
||||
}
|
||||
|
||||
// EditOptions captures the surgical flag state. Both string fields and
|
||||
// reader-based file inputs are tracked alongside per-flag *Set bits in
|
||||
// editFlagSet so empty strings are distinguishable from "unset".
|
||||
type EditOptions struct {
|
||||
AgentID string
|
||||
Name string
|
||||
Description string
|
||||
Model string
|
||||
SystemPrompt string
|
||||
SystemPromptReader io.Reader
|
||||
AgentMode string
|
||||
RerankModel string
|
||||
Temperature float64
|
||||
AddKBs []string
|
||||
RemoveKBs []string
|
||||
KBSelectionMode string
|
||||
ConfigFileBody io.Reader
|
||||
ConfigFileKind string // "yaml" or "json"
|
||||
DryRun bool
|
||||
|
||||
flags editFlagSet
|
||||
}
|
||||
|
||||
// editFlagSet tracks which surgical flags the user passed. Empty-string
|
||||
// values are valid (clear semantics) so cmd.Flags().Changed() is the only
|
||||
// reliable signal of "user supplied this flag."
|
||||
type editFlagSet struct {
|
||||
nameSet bool
|
||||
descriptionSet bool
|
||||
modelSet bool
|
||||
systemPromptSet bool
|
||||
agentModeSet bool
|
||||
rerankModelSet bool
|
||||
temperatureSet bool
|
||||
addKBsSet bool
|
||||
removeKBsSet bool
|
||||
kbSelectionModeSet bool
|
||||
configFileSet bool
|
||||
}
|
||||
|
||||
const agentEditLong = `Update a custom agent's fields surgically.
|
||||
|
||||
At least one update flag is required; flags you omit preserve the current
|
||||
server-side value via fetch-then-update. Pass --description "" to clear
|
||||
the description (empty string is a valid value, not "unset").
|
||||
|
||||
KB list operations are list-shaped: --add-kb and --remove-kb are
|
||||
idempotent (re-adding an already-attached KB is silent success; removing
|
||||
an unattached KB is silent success). Passing the same id to both flags
|
||||
nets out to no-op and prints a stderr warning.
|
||||
|
||||
--config-file fully replaces the AgentConfig baseline (the same shape
|
||||
GenerateAgentSkeleton emits). Surgical flags then apply on top of that
|
||||
replaced baseline. To partially update one or two fields without
|
||||
touching the rest, use surgical flags alone — that path L-2 fetches
|
||||
current state and only mutates what's set. Precedence within a single
|
||||
invocation:
|
||||
|
||||
surgical flag > config-file value > zero value
|
||||
|
||||
AI agents: this is a high-risk write. Without -y/--yes the CLI exits 10
|
||||
with input.confirmation_required. Surface the prompt to the user and only
|
||||
retry with -y after explicit approval. Other failure codes: resource.not_found
|
||||
(agent id or KB id), auth.forbidden, input.invalid_argument (no flags, bad file).`
|
||||
|
||||
const agentEditExample = ` weknora agent update ag_abc --name "Renamed" -y
|
||||
weknora agent update ag_abc --description "" -y # clear description
|
||||
weknora agent update ag_abc --add-kb kb_new --remove-kb kb_old -y
|
||||
weknora agent update ag_abc --system-prompt-file ./prompt.md -y
|
||||
weknora agent update ag_abc --config-file ./tuned.yaml --temperature 0.9 -y`
|
||||
|
||||
// NewCmdEdit builds `weknora agent update <agent-id>`.
|
||||
func NewCmdEdit(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &EditOptions{}
|
||||
var systemPromptFile, configFile string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "update <agent-id>",
|
||||
Short: "Update a custom agent's configuration",
|
||||
Long: agentEditLong,
|
||||
Example: agentEditExample,
|
||||
Args: cobra.ExactArgs(1),
|
||||
PreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
opts.AgentID = args[0]
|
||||
opts.flags.nameSet = cmd.Flags().Changed("name")
|
||||
opts.flags.descriptionSet = cmd.Flags().Changed("description")
|
||||
opts.flags.modelSet = cmd.Flags().Changed("model")
|
||||
opts.flags.systemPromptSet = cmd.Flags().Changed("system-prompt") || cmd.Flags().Changed("system-prompt-file")
|
||||
opts.flags.agentModeSet = cmd.Flags().Changed("agent-mode")
|
||||
opts.flags.rerankModelSet = cmd.Flags().Changed("rerank-model")
|
||||
opts.flags.temperatureSet = cmd.Flags().Changed("temperature")
|
||||
opts.flags.addKBsSet = cmd.Flags().Changed("add-kb")
|
||||
opts.flags.removeKBsSet = cmd.Flags().Changed("remove-kb")
|
||||
opts.flags.kbSelectionModeSet = cmd.Flags().Changed("kb-selection-mode")
|
||||
opts.flags.configFileSet = cmd.Flags().Changed("config-file")
|
||||
|
||||
// --temperature is bounded 0.0..2.0. Reject out-of-range
|
||||
// early with a typed input.invalid_argument.
|
||||
if opts.flags.temperatureSet && (opts.Temperature < 0.0 || opts.Temperature > 2.0) {
|
||||
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument,
|
||||
fmt.Sprintf("--temperature must be in 0.0..2.0, got %g", opts.Temperature))
|
||||
}
|
||||
|
||||
// Reject an unknown --agent-mode / --kb-selection-mode up front
|
||||
// (typed exit 5) rather than letting the server fail the update.
|
||||
if err := validateAgentModeFlags(&opts.AgentMode, &opts.KBSelectionMode); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if systemPromptFile != "" {
|
||||
r, err := cmdutil.OpenInput(systemPromptFile)
|
||||
if err != nil {
|
||||
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, fmt.Sprintf("--system-prompt-file: %v", err))
|
||||
}
|
||||
opts.SystemPromptReader = r
|
||||
}
|
||||
if configFile != "" {
|
||||
r, kind, err := openConfigFile(configFile)
|
||||
if err != nil {
|
||||
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, fmt.Sprintf("--config-file: %v", err))
|
||||
}
|
||||
opts.ConfigFileBody = r
|
||||
opts.ConfigFileKind = kind
|
||||
}
|
||||
return nil
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
fopts, err := cmdutil.CheckFormatFlag(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
|
||||
// Validate "at least one update flag" before the dry-run gate so
|
||||
// --dry-run rejects identically to the live path. Same typed
|
||||
// Error as runEdit (kept there for direct-call callers).
|
||||
if !editHasAnyFlag(opts) {
|
||||
return &cmdutil.Error{
|
||||
Code: cmdutil.CodeInputInvalidArgument,
|
||||
Message: "agent update requires at least one flag",
|
||||
Hint: "pass at least one update flag (e.g., --name, --add-kb, --description) or --config-file",
|
||||
}
|
||||
}
|
||||
if opts.DryRun {
|
||||
planArgs := map[string]any{"agent_id": opts.AgentID}
|
||||
if opts.flags.nameSet {
|
||||
planArgs["name"] = opts.Name
|
||||
}
|
||||
if opts.flags.descriptionSet {
|
||||
planArgs["description"] = opts.Description
|
||||
}
|
||||
if opts.flags.modelSet {
|
||||
planArgs["model"] = opts.Model
|
||||
}
|
||||
if opts.flags.agentModeSet {
|
||||
planArgs["agent_mode"] = opts.AgentMode
|
||||
}
|
||||
if opts.flags.rerankModelSet {
|
||||
planArgs["rerank_model"] = opts.RerankModel
|
||||
}
|
||||
if opts.flags.temperatureSet {
|
||||
planArgs["temperature"] = opts.Temperature
|
||||
}
|
||||
if opts.flags.addKBsSet {
|
||||
planArgs["add_kb"] = opts.AddKBs
|
||||
}
|
||||
if opts.flags.removeKBsSet {
|
||||
planArgs["remove_kb"] = opts.RemoveKBs
|
||||
}
|
||||
if opts.flags.kbSelectionModeSet {
|
||||
planArgs["kb_selection_mode"] = opts.KBSelectionMode
|
||||
}
|
||||
if opts.flags.configFileSet {
|
||||
planArgs["config_file"] = configFile
|
||||
}
|
||||
if opts.flags.systemPromptSet {
|
||||
if systemPromptFile != "" {
|
||||
planArgs["system_prompt_file"] = systemPromptFile
|
||||
} else {
|
||||
planArgs["system_prompt"] = opts.SystemPrompt
|
||||
}
|
||||
}
|
||||
if handled, err := cmdutil.HandleDryRun(cmd, true, cmdutil.DryRunPlan{
|
||||
Action: "agent.update",
|
||||
Args: planArgs,
|
||||
}); handled {
|
||||
return err
|
||||
}
|
||||
}
|
||||
yes, _ := cmd.Flags().GetBool("yes")
|
||||
// Build the retry command from the flags the user actually passed.
|
||||
// Include list-shaped and file-path flags so exit-10 retry_argv reproduces
|
||||
// the original update (BuildRetryArgv expands StringSlice as repeats).
|
||||
retryCmd := cmdutil.BuildRetryArgv(cmd, []string{"weknora", "agent", "update", opts.AgentID},
|
||||
"name", "description", "model", "system-prompt", "system-prompt-file",
|
||||
"agent-mode", "rerank-model", "temperature", "add-kb", "remove-kb",
|
||||
"kb-selection-mode", "config-file", "format")
|
||||
if err := cmdutil.ConfirmWrite(f.Prompter(), yes, fopts.WantsJSON(), "update", "agent", opts.AgentID, "agent.update", retryCmd); err != nil {
|
||||
return err
|
||||
}
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Resolve --add-kb values (id or name) to canonical ids, matching
|
||||
// the --kb id-or-name policy and agent create --attach-kb. Avoids
|
||||
// silently attaching an unresolvable name as a kb_id.
|
||||
if len(opts.AddKBs) > 0 {
|
||||
resolved := make([]string, 0, len(opts.AddKBs))
|
||||
for _, raw := range opts.AddKBs {
|
||||
id, rerr := cmdutil.ResolveKBFlag(cmd.Context(), cli, raw)
|
||||
if rerr != nil {
|
||||
return rerr
|
||||
}
|
||||
resolved = append(resolved, id)
|
||||
}
|
||||
opts.AddKBs = resolved
|
||||
}
|
||||
return runEdit(cmd.Context(), opts, fopts, cli)
|
||||
},
|
||||
}
|
||||
|
||||
// Surgical flags
|
||||
cmd.Flags().StringVar(&opts.Name, "name", "", "New agent name")
|
||||
cmd.Flags().StringVar(&opts.Description, "description", "", `New description (use "" to clear)`)
|
||||
cmd.Flags().StringVar(&opts.Model, "model", "", "LLM model id")
|
||||
cmd.Flags().StringVar(&opts.SystemPrompt, "system-prompt", "", "System prompt text (mutex with --system-prompt-file)")
|
||||
cmd.Flags().StringVar(&systemPromptFile, "system-prompt-file", "", "Read system prompt from FILE, or '-' for stdin")
|
||||
cmd.MarkFlagsMutuallyExclusive("system-prompt", "system-prompt-file")
|
||||
cmd.Flags().StringVar(&opts.AgentMode, "agent-mode", "", "Agent operating mode: "+strings.Join(agentModeValues, " | "))
|
||||
cmd.Flags().StringVar(&opts.RerankModel, "rerank-model", "", "Rerank model id")
|
||||
cmd.Flags().Float64Var(&opts.Temperature, "temperature", 0.0, "Generation temperature (0.0..2.0)")
|
||||
cmd.Flags().StringSliceVar(&opts.AddKBs, "add-kb", nil, "Attach knowledge base id (repeatable, idempotent)")
|
||||
cmd.Flags().StringSliceVar(&opts.RemoveKBs, "remove-kb", nil, "Detach knowledge base id (repeatable, idempotent)")
|
||||
cmd.Flags().StringVar(&opts.KBSelectionMode, "kb-selection-mode", "", "KB selection mode: "+strings.Join(kbSelectionModeValues, " | "))
|
||||
|
||||
// Full-replace
|
||||
cmd.Flags().StringVar(&configFile, "config-file", "", "Full AgentConfig YAML or JSON (REPLACES current config baseline; surgical flags then apply on top)")
|
||||
|
||||
cmdutil.AddFormatFlag(cmd, agentViewFields...)
|
||||
cmdutil.AddDryRunFlag(cmd, &opts.DryRun)
|
||||
cmdutil.SetWriteRisk(cmd, "agent.update")
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "surgically update a custom agent's configuration",
|
||||
RequiredFlags: []string{"<agent-id> (positional)", "at least one update flag (--name, --add-kb, etc.)"},
|
||||
Examples: []string{
|
||||
"weknora agent update ag_abc --name \"Renamed\"",
|
||||
"weknora agent update ag_abc --add-kb kb_new --remove-kb kb_old",
|
||||
"weknora agent update ag_abc --config-file ./tuned.yaml",
|
||||
},
|
||||
Output: "envelope.data is the updated Agent object (id, name, config) after the update is applied",
|
||||
Warnings: []string{
|
||||
"Requires explicit user approval (exit 10 / input.confirmation_required); never auto-add -y.",
|
||||
"agent update overwrites config; fetch-then-update protects unmentioned fields, but bad input still saved.",
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
// editHasAnyFlag reports whether opts carries at least one surgical update
|
||||
// signal. Required-flag validation lives in runEdit (not PreRunE) so unit
|
||||
// tests can invoke runEdit with a hand-built EditOptions directly.
|
||||
func editHasAnyFlag(opts *EditOptions) bool {
|
||||
fl := opts.flags
|
||||
return fl.nameSet || fl.descriptionSet || fl.modelSet || fl.systemPromptSet ||
|
||||
fl.agentModeSet || fl.rerankModelSet || fl.temperatureSet ||
|
||||
fl.addKBsSet || fl.removeKBsSet || fl.kbSelectionModeSet || fl.configFileSet
|
||||
}
|
||||
|
||||
func runEdit(ctx context.Context, opts *EditOptions, fopts *cmdutil.FormatOptions, svc EditService) error {
|
||||
if !editHasAnyFlag(opts) {
|
||||
return &cmdutil.Error{
|
||||
Code: cmdutil.CodeInputInvalidArgument,
|
||||
Message: "agent update requires at least one flag",
|
||||
Hint: "pass at least one update flag (e.g., --name, --add-kb, --description) or --config-file",
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch-then-update so omitted fields round-trip unchanged through
|
||||
// the full PUT body.
|
||||
current, err := svc.GetAgent(ctx, opts.AgentID)
|
||||
if err != nil {
|
||||
return cmdutil.WrapHTTP(err, "fetch agent %s", opts.AgentID)
|
||||
}
|
||||
|
||||
// Build base config: server state, then overlay --config-file (if any).
|
||||
base := sdk.AgentConfig{}
|
||||
if current.Config != nil {
|
||||
base = *current.Config
|
||||
}
|
||||
if opts.ConfigFileBody != nil {
|
||||
parsed, err := cmdutil.LoadAgentConfig(opts.ConfigFileBody, opts.ConfigFileKind)
|
||||
if err != nil {
|
||||
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, err.Error())
|
||||
}
|
||||
base = *parsed
|
||||
}
|
||||
|
||||
// Resolve --system-prompt-file before flag overlay.
|
||||
if opts.SystemPromptReader != nil {
|
||||
body, err := io.ReadAll(opts.SystemPromptReader)
|
||||
if err != nil {
|
||||
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, fmt.Sprintf("--system-prompt-file read: %v", err))
|
||||
}
|
||||
opts.SystemPrompt = strings.TrimSpace(string(body))
|
||||
}
|
||||
|
||||
// Resolve --model / --rerank-model (id or name) and validate they exist,
|
||||
// mirroring agent create. Without this a bogus name is stored verbatim as
|
||||
// config.model_id and the agent silently never resolves at run time.
|
||||
if opts.flags.modelSet {
|
||||
if opts.Model, err = cmdutil.ResolveModelRef(ctx, svc, opts.Model, "KnowledgeQA"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if opts.flags.rerankModelSet {
|
||||
if opts.RerankModel, err = cmdutil.ResolveModelRef(ctx, svc, opts.RerankModel, "Rerank"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Compute KB list from current + add/remove with a stderr warning when
|
||||
// the same id appears in both flags (net no-op, still idempotent).
|
||||
kbs := computeKBList(base.KnowledgeBases, opts.AddKBs, opts.RemoveKBs)
|
||||
|
||||
overrides := cmdutil.AgentConfigFlags{
|
||||
AgentMode: opts.AgentMode, AgentModeSet: opts.flags.agentModeSet,
|
||||
SystemPrompt: opts.SystemPrompt, SystemPromptSet: opts.flags.systemPromptSet,
|
||||
ModelID: opts.Model, ModelIDSet: opts.flags.modelSet,
|
||||
RerankModelID: opts.RerankModel, RerankModelIDSet: opts.flags.rerankModelSet,
|
||||
Temperature: opts.Temperature, TemperatureSet: opts.flags.temperatureSet,
|
||||
KBSelectionMode: opts.KBSelectionMode, KBSelectionModeSet: opts.flags.kbSelectionModeSet,
|
||||
// KB list is always replaced (its add/remove was already merged
|
||||
// into kbs); we only signal "set" when the user actually touched
|
||||
// the list so a plain --name edit doesn't churn the field.
|
||||
KnowledgeBases: kbs,
|
||||
KnowledgeBasesSet: opts.flags.addKBsSet || opts.flags.removeKBsSet,
|
||||
}
|
||||
cfg := cmdutil.MergeAgentConfig(&base, overrides)
|
||||
|
||||
// Build the full PUT body. Name/Description default to the current
|
||||
// server values so the surgical-flag-only path preserves them.
|
||||
req := &sdk.UpdateAgentRequest{
|
||||
Name: current.Name,
|
||||
Description: current.Description,
|
||||
Config: cfg,
|
||||
}
|
||||
if opts.flags.nameSet {
|
||||
req.Name = opts.Name
|
||||
}
|
||||
if opts.flags.descriptionSet {
|
||||
req.Description = opts.Description
|
||||
}
|
||||
|
||||
updated, err := svc.UpdateAgent(ctx, opts.AgentID, req)
|
||||
if err != nil {
|
||||
return cmdutil.WrapHTTP(err, "update agent %s", opts.AgentID)
|
||||
}
|
||||
return emitAgent(fopts, updated)
|
||||
}
|
||||
|
||||
// computeKBList applies --add-kb / --remove-kb to current with idempotent
|
||||
// semantics. Ids present in both add and remove cancel out and surface a
|
||||
// stderr warning so users notice the conflict but don't see a hard error.
|
||||
// Stderr is the right channel here (not stdout) because callers piping
|
||||
// --format json | jq would otherwise see corrupted JSON.
|
||||
func computeKBList(current, add, remove []string) []string {
|
||||
// Detect ids in both add and remove; they net out to no-op and are
|
||||
// excluded from both operations.
|
||||
canceledSet := map[string]struct{}{}
|
||||
addSeen := map[string]struct{}{}
|
||||
for _, id := range add {
|
||||
addSeen[id] = struct{}{}
|
||||
}
|
||||
for _, id := range remove {
|
||||
if _, both := addSeen[id]; both {
|
||||
canceledSet[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
if len(canceledSet) > 0 {
|
||||
canceled := make([]string, 0, len(canceledSet))
|
||||
for id := range canceledSet {
|
||||
canceled = append(canceled, id)
|
||||
}
|
||||
// Sort for deterministic test output; map iteration is random.
|
||||
sort.Strings(canceled)
|
||||
fmt.Fprintf(iostreams.IO.Err, "warning: --add-kb and --remove-kb cancel out for: %s\n", strings.Join(canceled, ", "))
|
||||
}
|
||||
|
||||
// Compute effective remove set (excluding canceled).
|
||||
removeEff := map[string]struct{}{}
|
||||
for _, id := range remove {
|
||||
if _, c := canceledSet[id]; c {
|
||||
continue
|
||||
}
|
||||
removeEff[id] = struct{}{}
|
||||
}
|
||||
|
||||
// Filter removals out of the current list (idempotent: unattached id
|
||||
// simply isn't in current).
|
||||
out := make([]string, 0, len(current))
|
||||
for _, id := range current {
|
||||
if _, drop := removeEff[id]; drop {
|
||||
continue
|
||||
}
|
||||
out = append(out, id)
|
||||
}
|
||||
// Append any add ids not already present (idempotent: already-attached
|
||||
// id silently de-dupes).
|
||||
present := map[string]struct{}{}
|
||||
for _, id := range out {
|
||||
present[id] = struct{}{}
|
||||
}
|
||||
for _, id := range add {
|
||||
if _, c := canceledSet[id]; c {
|
||||
continue
|
||||
}
|
||||
if _, dup := present[id]; dup {
|
||||
continue
|
||||
}
|
||||
out = append(out, id)
|
||||
present[id] = struct{}{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package agentcmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
"github.com/Tencent/WeKnora/cli/internal/prompt"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// agentDryRunFactory builds a Factory whose Client closure panics if invoked —
|
||||
// dry-run must early-exit before any SDK call.
|
||||
func agentDryRunFactory(t *testing.T) *cmdutil.Factory {
|
||||
t.Helper()
|
||||
return &cmdutil.Factory{
|
||||
Client: func() (*sdk.Client, error) {
|
||||
t.Fatal("dry-run path must not call Factory.Client(); SDK side effect leaked")
|
||||
return nil, nil
|
||||
},
|
||||
Prompter: func() prompt.Prompter {
|
||||
t.Fatal("dry-run path must not call Factory.Prompter(); confirm-prompt side effect leaked")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentEdit_DryRun_RejectsNoFlag: --dry-run must reject the same
|
||||
// "no update flag supplied" invocation the live path rejects. Before the
|
||||
// fix the editHasAnyFlag check lived in runEdit() (live-only), reached only
|
||||
// after HandleDryRun early-exited.
|
||||
func TestAgentEdit_DryRun_RejectsNoFlag(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
f := agentDryRunFactory(t)
|
||||
root := withRootHarnessAgent(NewCmdEdit(f), "ag_abc", "--dry-run", "--format", "json")
|
||||
err := root.Execute()
|
||||
require.Error(t, err, "dry-run must reject identically to live path")
|
||||
|
||||
var typed *cmdutil.Error
|
||||
require.True(t, errors.As(err, &typed), "expected *cmdutil.Error, got %T %v", err, err)
|
||||
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
package agentcmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
"github.com/Tencent/WeKnora/cli/internal/prompt"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// fakeEditSvc scripts GetAgent (fetch baseline) + UpdateAgent (apply
|
||||
// surgical overlays). updateCalls lets tests verify that no-flag invocations
|
||||
// don't reach the wire.
|
||||
type fakeEditSvc struct {
|
||||
getResp *sdk.Agent
|
||||
getErr error
|
||||
updateReq *sdk.UpdateAgentRequest
|
||||
updateID string
|
||||
updateResp *sdk.Agent
|
||||
updateErr error
|
||||
updateCalls int
|
||||
models []sdk.Model
|
||||
modelsErr error
|
||||
}
|
||||
|
||||
func (f *fakeEditSvc) GetAgent(_ context.Context, _ string) (*sdk.Agent, error) {
|
||||
return f.getResp, f.getErr
|
||||
}
|
||||
|
||||
func (f *fakeEditSvc) UpdateAgent(_ context.Context, id string, req *sdk.UpdateAgentRequest) (*sdk.Agent, error) {
|
||||
f.updateReq = req
|
||||
f.updateID = id
|
||||
f.updateCalls++
|
||||
return f.updateResp, f.updateErr
|
||||
}
|
||||
|
||||
func (f *fakeEditSvc) ListModels(_ context.Context) ([]sdk.Model, error) {
|
||||
return f.models, f.modelsErr
|
||||
}
|
||||
|
||||
func TestEdit_ModelName_ResolvedToID(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeEditSvc{
|
||||
getResp: &sdk.Agent{ID: "ag_abc", Name: "A", Config: &sdk.AgentConfig{ModelID: "old-id"}},
|
||||
updateResp: &sdk.Agent{ID: "ag_abc"},
|
||||
models: []sdk.Model{{ID: "m-real", Name: "good-llm", Type: "KnowledgeQA"}},
|
||||
}
|
||||
opts := &EditOptions{AgentID: "ag_abc", Model: "good-llm", flags: editFlagSet{modelSet: true}}
|
||||
require.NoError(t, runEdit(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
require.NotNil(t, svc.updateReq)
|
||||
require.NotNil(t, svc.updateReq.Config)
|
||||
assert.Equal(t, "m-real", svc.updateReq.Config.ModelID, "--model name must resolve to the model id")
|
||||
}
|
||||
|
||||
func TestEdit_BogusModelName_RejectedNoWrite(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeEditSvc{
|
||||
getResp: &sdk.Agent{ID: "ag_abc", Name: "A", Config: &sdk.AgentConfig{ModelID: "old-id"}},
|
||||
updateResp: &sdk.Agent{ID: "ag_abc"},
|
||||
models: []sdk.Model{{ID: "m-real", Name: "good-llm", Type: "KnowledgeQA"}},
|
||||
}
|
||||
opts := &EditOptions{AgentID: "ag_abc", Model: "totally-bogus-model-xyz", flags: editFlagSet{modelSet: true}}
|
||||
err := runEdit(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc)
|
||||
require.Error(t, err, "a --model name matching no model must fail")
|
||||
var e *cmdutil.Error
|
||||
require.ErrorAs(t, err, &e)
|
||||
assert.Equal(t, cmdutil.CodeResourceNotFound, e.Code)
|
||||
assert.Equal(t, 0, svc.updateCalls, "must not write an agent with an unresolvable model")
|
||||
}
|
||||
|
||||
func TestEdit_FetchThenUpdate_PreservesUntouchedFields(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeEditSvc{
|
||||
getResp: &sdk.Agent{
|
||||
ID: "ag_abc", Name: "Original", Description: "Keep me",
|
||||
Config: &sdk.AgentConfig{ModelID: "model-x", Temperature: 0.7, KnowledgeBases: []string{"kb_a"}},
|
||||
},
|
||||
updateResp: &sdk.Agent{ID: "ag_abc"},
|
||||
}
|
||||
// Only --description passed; everything else should round-trip.
|
||||
opts := &EditOptions{
|
||||
AgentID: "ag_abc",
|
||||
Description: "Updated",
|
||||
flags: editFlagSet{descriptionSet: true},
|
||||
}
|
||||
require.NoError(t, runEdit(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
require.NotNil(t, svc.updateReq)
|
||||
assert.Equal(t, "Original", svc.updateReq.Name, "Name must round-trip unchanged")
|
||||
assert.Equal(t, "Updated", svc.updateReq.Description)
|
||||
require.NotNil(t, svc.updateReq.Config)
|
||||
assert.Equal(t, "model-x", svc.updateReq.Config.ModelID, "ModelID must round-trip")
|
||||
assert.Equal(t, []string{"kb_a"}, svc.updateReq.Config.KnowledgeBases, "KBs must round-trip")
|
||||
assert.InDelta(t, 0.7, svc.updateReq.Config.Temperature, 0.001)
|
||||
}
|
||||
|
||||
func TestEdit_AddRemoveKB_SameID_NetNoOpWithWarning(t *testing.T) {
|
||||
_, errBuf := iostreams.SetForTest(t)
|
||||
svc := &fakeEditSvc{
|
||||
getResp: &sdk.Agent{Config: &sdk.AgentConfig{KnowledgeBases: []string{"kb_a"}}},
|
||||
updateResp: &sdk.Agent{},
|
||||
}
|
||||
opts := &EditOptions{
|
||||
AgentID: "ag_abc",
|
||||
AddKBs: []string{"kb_b"},
|
||||
RemoveKBs: []string{"kb_b"},
|
||||
flags: editFlagSet{addKBsSet: true, removeKBsSet: true},
|
||||
}
|
||||
require.NoError(t, runEdit(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
assert.Equal(t, []string{"kb_a"}, svc.updateReq.Config.KnowledgeBases, "net no-op preserves original list")
|
||||
assert.Contains(t, errBuf.String(), "cancel out", "warning emitted to stderr")
|
||||
}
|
||||
|
||||
func TestEdit_NoFlags_InvalidArgument(t *testing.T) {
|
||||
svc := &fakeEditSvc{}
|
||||
err := runEdit(context.Background(), &EditOptions{AgentID: "ag_abc"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc)
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
|
||||
assert.Equal(t, 0, svc.updateCalls, "must not call UpdateAgent")
|
||||
}
|
||||
|
||||
func TestEdit_AddKB_AlreadyAttached_Silent(t *testing.T) {
|
||||
_, errBuf := iostreams.SetForTest(t)
|
||||
svc := &fakeEditSvc{
|
||||
getResp: &sdk.Agent{Config: &sdk.AgentConfig{KnowledgeBases: []string{"kb_a", "kb_b"}}},
|
||||
updateResp: &sdk.Agent{},
|
||||
}
|
||||
opts := &EditOptions{
|
||||
AgentID: "ag_abc",
|
||||
AddKBs: []string{"kb_a"}, // already attached
|
||||
flags: editFlagSet{addKBsSet: true},
|
||||
}
|
||||
require.NoError(t, runEdit(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
assert.Equal(t, []string{"kb_a", "kb_b"}, svc.updateReq.Config.KnowledgeBases, "no duplicate")
|
||||
assert.NotContains(t, errBuf.String(), "warning", "already-attached is silent success")
|
||||
}
|
||||
|
||||
func TestEdit_RemoveKB_Unattached_Silent(t *testing.T) {
|
||||
_, errBuf := iostreams.SetForTest(t)
|
||||
svc := &fakeEditSvc{
|
||||
getResp: &sdk.Agent{Config: &sdk.AgentConfig{KnowledgeBases: []string{"kb_a"}}},
|
||||
updateResp: &sdk.Agent{},
|
||||
}
|
||||
opts := &EditOptions{
|
||||
AgentID: "ag_abc",
|
||||
RemoveKBs: []string{"kb_zzz"},
|
||||
flags: editFlagSet{removeKBsSet: true},
|
||||
}
|
||||
require.NoError(t, runEdit(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
assert.Equal(t, []string{"kb_a"}, svc.updateReq.Config.KnowledgeBases)
|
||||
assert.NotContains(t, errBuf.String(), "warning")
|
||||
}
|
||||
|
||||
func TestEdit_ClearDescription_EmptyString(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeEditSvc{
|
||||
getResp: &sdk.Agent{Name: "X", Description: "old", Config: &sdk.AgentConfig{ModelID: "m"}},
|
||||
updateResp: &sdk.Agent{},
|
||||
}
|
||||
opts := &EditOptions{
|
||||
AgentID: "ag_abc",
|
||||
Description: "",
|
||||
flags: editFlagSet{descriptionSet: true},
|
||||
}
|
||||
require.NoError(t, runEdit(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
assert.Equal(t, "", svc.updateReq.Description, "explicit empty must clear server-side")
|
||||
assert.Equal(t, "X", svc.updateReq.Name, "Name round-trip unchanged")
|
||||
}
|
||||
|
||||
func TestEdit_ConfigFile_OverridesByFlag(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeEditSvc{
|
||||
getResp: &sdk.Agent{
|
||||
Name: "X",
|
||||
Config: &sdk.AgentConfig{ModelID: "old-model", Temperature: 0.1},
|
||||
},
|
||||
updateResp: &sdk.Agent{},
|
||||
}
|
||||
opts := &EditOptions{
|
||||
AgentID: "ag_abc",
|
||||
Temperature: 0.9,
|
||||
ConfigFileBody: bytes.NewBufferString(`{"temperature":0.5,"model_id":"file-model"}`),
|
||||
ConfigFileKind: "json",
|
||||
flags: editFlagSet{temperatureSet: true, configFileSet: true},
|
||||
}
|
||||
require.NoError(t, runEdit(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
require.NotNil(t, svc.updateReq.Config)
|
||||
assert.Equal(t, "file-model", svc.updateReq.Config.ModelID, "file overrides current state")
|
||||
assert.InDelta(t, 0.9, svc.updateReq.Config.Temperature, 0.001, "flag overrides file")
|
||||
}
|
||||
|
||||
// TestEdit_ConfigFile_FullReplacesBaseline pins the documented behavior:
|
||||
// --config-file fully replaces the AgentConfig baseline; current-server
|
||||
// fields not mentioned in the file are zeroed. The Long help directs
|
||||
// users to surgical flags when they want a partial update.
|
||||
func TestEdit_ConfigFile_FullReplacesBaseline(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeEditSvc{
|
||||
getResp: &sdk.Agent{
|
||||
Name: "X",
|
||||
Config: &sdk.AgentConfig{
|
||||
SystemPrompt: "Existing prompt",
|
||||
ModelID: "old-model",
|
||||
Temperature: 0.1,
|
||||
AgentMode: "smart-reasoning",
|
||||
KnowledgeBases: []string{"kb_existing"},
|
||||
},
|
||||
},
|
||||
updateResp: &sdk.Agent{},
|
||||
}
|
||||
opts := &EditOptions{
|
||||
AgentID: "ag_abc",
|
||||
ConfigFileBody: bytes.NewBufferString(`{"model_id":"file-only"}`),
|
||||
ConfigFileKind: "json",
|
||||
flags: editFlagSet{configFileSet: true},
|
||||
}
|
||||
require.NoError(t, runEdit(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
require.NotNil(t, svc.updateReq.Config)
|
||||
assert.Equal(t, "file-only", svc.updateReq.Config.ModelID, "file's model_id applied")
|
||||
assert.Equal(t, "", svc.updateReq.Config.SystemPrompt, "file fully replaces baseline; unset fields are zeroed")
|
||||
assert.InDelta(t, 0.0, svc.updateReq.Config.Temperature, 0.001, "unset fields zeroed")
|
||||
assert.Equal(t, "", svc.updateReq.Config.AgentMode, "unset fields zeroed")
|
||||
assert.Empty(t, svc.updateReq.Config.KnowledgeBases, "unset fields zeroed")
|
||||
}
|
||||
|
||||
func TestEdit_NotFound(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeEditSvc{getErr: errBadHTTP404}
|
||||
opts := &EditOptions{AgentID: "ag_missing", Name: "x", flags: editFlagSet{nameSet: true}}
|
||||
err := runEdit(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "resource.not_found")
|
||||
}
|
||||
|
||||
func TestEdit_AddKB_AppendsToExisting(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeEditSvc{
|
||||
getResp: &sdk.Agent{Config: &sdk.AgentConfig{KnowledgeBases: []string{"kb_a"}}},
|
||||
updateResp: &sdk.Agent{},
|
||||
}
|
||||
opts := &EditOptions{
|
||||
AgentID: "ag_abc",
|
||||
AddKBs: []string{"kb_b", "kb_c"},
|
||||
flags: editFlagSet{addKBsSet: true},
|
||||
}
|
||||
require.NoError(t, runEdit(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
assert.Equal(t, []string{"kb_a", "kb_b", "kb_c"}, svc.updateReq.Config.KnowledgeBases)
|
||||
}
|
||||
|
||||
func TestEdit_Temperature_Bounds(t *testing.T) {
|
||||
for _, badT := range []float64{-0.1, 2.1, 100.0} {
|
||||
t.Run(fmt.Sprintf("t=%g", badT), func(t *testing.T) {
|
||||
cmd := NewCmdEdit(nil)
|
||||
cmd.SetArgs([]string{"ag_abc", "--temperature", fmt.Sprintf("%f", badT)})
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
err := cmd.Execute()
|
||||
require.Error(t, err, "expected error for --temperature %g", badT)
|
||||
assert.Contains(t, err.Error(), "0.0..2.0")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEdit_SystemPromptFile(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeEditSvc{
|
||||
getResp: &sdk.Agent{Config: &sdk.AgentConfig{ModelID: "m"}},
|
||||
updateResp: &sdk.Agent{},
|
||||
}
|
||||
opts := &EditOptions{
|
||||
AgentID: "ag_abc",
|
||||
SystemPromptReader: strings.NewReader("new prompt\n"),
|
||||
flags: editFlagSet{systemPromptSet: true},
|
||||
}
|
||||
require.NoError(t, runEdit(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
assert.Equal(t, "new prompt", svc.updateReq.Config.SystemPrompt)
|
||||
}
|
||||
|
||||
// withRootHarnessAgent wraps `weknora agent update ...` under a synthetic root
|
||||
// cmd that registers the global persistent flags (mirrors addGlobalFlags in
|
||||
// cmd/root.go).
|
||||
func withRootHarnessAgent(edit *cobra.Command, args ...string) *cobra.Command {
|
||||
root := &cobra.Command{Use: "weknora"}
|
||||
pf := root.PersistentFlags()
|
||||
pf.BoolP("yes", "y", false, "")
|
||||
pf.String("format", "", "Output format: text | json | ndjson")
|
||||
pf.StringP("jq", "q", "", "")
|
||||
ag := &cobra.Command{Use: "agent"}
|
||||
ag.AddCommand(edit)
|
||||
root.AddCommand(ag)
|
||||
root.SetArgs(append([]string{"agent", "update"}, args...))
|
||||
root.SetContext(context.Background())
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
return root
|
||||
}
|
||||
|
||||
// TestAgentEdit_RequiresConfirmation asserts that without -y (non-TTY / JSON
|
||||
// mode), agent update returns input.confirmation_required (exit 10).
|
||||
func TestAgentEdit_RequiresConfirmation(t *testing.T) {
|
||||
iostreams.SetForTest(t) // non-TTY
|
||||
f := &cmdutil.Factory{
|
||||
Client: func() (*sdk.Client, error) { return nil, nil },
|
||||
Prompter: func() prompt.Prompter { return prompt.AgentPrompter{} },
|
||||
}
|
||||
root := withRootHarnessAgent(NewCmdEdit(f), "ag_abc", "--name", "Renamed", "--format", "json")
|
||||
err := root.Execute()
|
||||
require.Error(t, err)
|
||||
var ce *cmdutil.Error
|
||||
require.ErrorAs(t, err, &ce)
|
||||
assert.Equal(t, cmdutil.CodeInputConfirmationRequired, ce.Code)
|
||||
assert.Equal(t, 10, cmdutil.ExitCode(err), "exit code 10 per destructive-write protocol")
|
||||
// retry argv must include -y and the agent id
|
||||
assert.Contains(t, ce.RetryArgv, "-y")
|
||||
assert.Contains(t, ce.RetryArgv, "ag_abc")
|
||||
}
|
||||
|
||||
// TestAgentEdit_RetryArgvPreservesAddKB is a regression for #2597: exit-10
|
||||
// retry_argv must keep --add-kb / --remove-kb / --config-file so re-running
|
||||
// after human approval reproduces the original update.
|
||||
func TestAgentEdit_RetryArgvPreservesAddKB(t *testing.T) {
|
||||
iostreams.SetForTest(t) // non-TTY
|
||||
f := &cmdutil.Factory{
|
||||
Client: func() (*sdk.Client, error) { return nil, nil },
|
||||
Prompter: func() prompt.Prompter { return prompt.AgentPrompter{} },
|
||||
}
|
||||
// Avoid --config-file / --system-prompt-file here: PreRunE opens paths and
|
||||
// would fail before ConfirmWrite. Those flags still go through the same
|
||||
// BuildRetryArgv scalar path covered in cmdutil tests.
|
||||
root := withRootHarnessAgent(NewCmdEdit(f),
|
||||
"ag_abc", "--add-kb", "kb_new", "--remove-kb", "kb_old", "--format", "json")
|
||||
err := root.Execute()
|
||||
require.Error(t, err)
|
||||
var ce *cmdutil.Error
|
||||
require.ErrorAs(t, err, &ce)
|
||||
assert.Equal(t, cmdutil.CodeInputConfirmationRequired, ce.Code)
|
||||
// pflag Visit is lexicographical among changed flags.
|
||||
assert.Equal(t, []string{
|
||||
"weknora", "agent", "update", "ag_abc",
|
||||
"--add-kb", "kb_new",
|
||||
"--format", "json",
|
||||
"--remove-kb", "kb_old",
|
||||
"-y",
|
||||
}, ce.RetryArgv)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package agentcmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
"github.com/Tencent/WeKnora/cli/internal/output"
|
||||
"github.com/Tencent/WeKnora/cli/internal/text"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// agentListFields enumerates the fields surfaced for `--format json` discovery
|
||||
// on `agent list`. Mirrors the json tags on sdk.Agent - nested Config is
|
||||
// omitted because its sub-fields make filtering noisy (use `--jq` instead).
|
||||
var agentListFields = []string{
|
||||
"id", "name", "description", "avatar",
|
||||
"is_builtin", "tenant_id", "created_by",
|
||||
"created_at", "updated_at",
|
||||
}
|
||||
|
||||
// ListService is the narrow SDK surface this command depends on.
|
||||
type ListService interface {
|
||||
ListAgents(ctx context.Context) ([]sdk.Agent, error)
|
||||
}
|
||||
|
||||
// ListOptions captures `agent list` filter flag state.
|
||||
type ListOptions struct {
|
||||
// Limit caps the returned slice client-side. 0 = no cap, 1..10000 = explicit.
|
||||
// The agent list SDK is unpaginated; --all-pages is intentionally not
|
||||
// exposed because it would be a no-op.
|
||||
Limit int
|
||||
}
|
||||
|
||||
// NewCmdList builds `weknora agent list`.
|
||||
func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &ListOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List custom agents visible to the active tenant",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(c *cobra.Command, _ []string) error {
|
||||
fopts, err := cmdutil.CheckFormatFlag(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
|
||||
// Validate static input before building the client so a bad --limit
|
||||
// returns input.invalid_argument (exit 5), not an auth error (exit 3).
|
||||
if err := validateListOpts(opts); err != nil {
|
||||
return err
|
||||
}
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runList(c.Context(), opts, fopts, cli)
|
||||
},
|
||||
}
|
||||
cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum results to return — client-side cap; meta.has_more/total_count report the full size (1..10000)")
|
||||
cmdutil.AddFormatFlag(cmd, agentListFields...)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "List custom agents visible to the active tenant. The SDK returns all agents in one call (no server-side pagination); meta.count reflects the full tenant set, --limit caps client-side.",
|
||||
Examples: []string{"weknora agent list --format json", "weknora agent list --limit 10 --format json"},
|
||||
Output: "envelope.data is an array of Agent objects with id, name, is_builtin; meta.total_count is the full tenant set and meta.has_more=true means --limit truncated it (raise --limit to get the rest)",
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
// validateListOpts checks --limit. Called from RunE before the client is built
|
||||
// (so a bad value surfaces as exit 5, not an auth error) and at runList's top
|
||||
// for direct callers; idempotent and nil-safe.
|
||||
func validateListOpts(opts *ListOptions) error {
|
||||
if opts == nil {
|
||||
return nil
|
||||
}
|
||||
if opts.Limit < 1 || opts.Limit > 10000 {
|
||||
return &cmdutil.Error{
|
||||
Code: cmdutil.CodeInputInvalidArgument,
|
||||
Message: fmt.Sprintf("--limit must be in 1..10000, got %d", opts.Limit),
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runList(ctx context.Context, opts *ListOptions, fopts *cmdutil.FormatOptions, svc ListService) error {
|
||||
if opts == nil {
|
||||
opts = &ListOptions{}
|
||||
}
|
||||
if err := validateListOpts(opts); err != nil {
|
||||
return err
|
||||
}
|
||||
items, err := svc.ListAgents(ctx)
|
||||
if err != nil {
|
||||
return cmdutil.WrapHTTP(err, "list agents")
|
||||
}
|
||||
if items == nil {
|
||||
items = []sdk.Agent{} // ensure JSON [] not null
|
||||
}
|
||||
// Default sort: updated_at desc - most recently-edited agents surface
|
||||
// first. Mirrors kb list / doc list behavior.
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
return items[i].UpdatedAt.After(items[j].UpdatedAt)
|
||||
})
|
||||
// The agent list SDK is unpaginated — it returns every agent in one
|
||||
// call — so the CLI holds the true total and can tell the caller
|
||||
// whether the client-side --limit dropped any. total_count is the full
|
||||
// count; has_more flags that --limit truncated it (raise --limit to get
|
||||
// the rest — there is no server cursor to continue with).
|
||||
total := len(items)
|
||||
truncated := false
|
||||
if opts.Limit > 0 && len(items) > opts.Limit {
|
||||
items = items[:opts.Limit]
|
||||
truncated = true
|
||||
}
|
||||
|
||||
if fopts.WantsJSON() {
|
||||
meta := &output.Meta{Count: output.IntPtr(len(items)), HasMore: truncated, TotalCount: output.IntPtr(total)}
|
||||
return fopts.Emit(iostreams.IO.Out, items, meta)
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
fmt.Fprintln(iostreams.IO.Out, "(no agents)")
|
||||
return nil
|
||||
}
|
||||
|
||||
tw := tabwriter.NewWriter(iostreams.IO.Out, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "ID\tNAME\tBUILTIN\tUPDATED")
|
||||
now := time.Now()
|
||||
for _, a := range items {
|
||||
name := text.Truncate(40, a.Name)
|
||||
builtin := "-"
|
||||
if a.IsBuiltin {
|
||||
builtin = "yes"
|
||||
}
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", a.ID, name, builtin, text.FuzzyAgo(now, a.UpdatedAt))
|
||||
}
|
||||
return tw.Flush()
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package agentcmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
type fakeListSvc struct {
|
||||
items []sdk.Agent
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeListSvc) ListAgents(_ context.Context) ([]sdk.Agent, error) {
|
||||
return f.items, f.err
|
||||
}
|
||||
|
||||
func TestList_Empty_Text(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
if err := runList(context.Background(), &ListOptions{Limit: 30}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, &fakeListSvc{}); err != nil {
|
||||
t.Fatalf("runList: %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "(no agents)") {
|
||||
t.Errorf("expected '(no agents)', got %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_Empty_JSON(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
if err := runList(context.Background(), &ListOptions{Limit: 30}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, &fakeListSvc{}); err != nil {
|
||||
t.Fatalf("runList: %v", err)
|
||||
}
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data []sdk.Agent `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("parse envelope: %v\n%s", err, out.String())
|
||||
}
|
||||
if !env.OK {
|
||||
t.Error("envelope.ok=false")
|
||||
}
|
||||
if len(env.Data) != 0 {
|
||||
t.Errorf("expected empty data, got %d items", len(env.Data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_NonEmpty_Text_RendersColumns(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
now := time.Now()
|
||||
items := []sdk.Agent{
|
||||
{ID: "ag_a", Name: "Research", IsBuiltin: true, UpdatedAt: now.Add(-1 * time.Hour)},
|
||||
{ID: "ag_b", Name: "Triage", UpdatedAt: now.Add(-3 * 24 * time.Hour)},
|
||||
}
|
||||
if err := runList(context.Background(), &ListOptions{Limit: 30}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, &fakeListSvc{items: items}); err != nil {
|
||||
t.Fatalf("runList: %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
for _, w := range []string{"ID", "NAME", "BUILTIN", "ag_a", "Research", "yes", "ag_b", "Triage"} {
|
||||
if !strings.Contains(got, w) {
|
||||
t.Errorf("output missing %q in:\n%s", w, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_NonEmpty_JSON_SortsByUpdatedAtDesc(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
now := time.Now()
|
||||
items := []sdk.Agent{
|
||||
{ID: "ag_old", Name: "old", UpdatedAt: now.Add(-7 * 24 * time.Hour)},
|
||||
{ID: "ag_new", Name: "new", UpdatedAt: now},
|
||||
{ID: "ag_mid", Name: "mid", UpdatedAt: now.Add(-1 * time.Hour)},
|
||||
}
|
||||
if err := runList(context.Background(), &ListOptions{Limit: 30}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, &fakeListSvc{items: items}); err != nil {
|
||||
t.Fatalf("runList: %v", err)
|
||||
}
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data []sdk.Agent `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
got := env.Data
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("len = %d, want 3", len(got))
|
||||
}
|
||||
wantOrder := []string{"ag_new", "ag_mid", "ag_old"}
|
||||
for i, w := range wantOrder {
|
||||
if got[i].ID != w {
|
||||
t.Errorf("position %d: got %s, want %s (updated_at desc)", i, got[i].ID, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_JSON_JQProjection(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
items := []sdk.Agent{
|
||||
{ID: "ag_x", Name: "Foo", Description: "long description"},
|
||||
}
|
||||
// --jq projects from the envelope; .data[] | ... extracts from the array inside envelope.
|
||||
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON, JQ: ".data[] | {id, name}"}
|
||||
if err := runList(context.Background(), &ListOptions{Limit: 30}, fopts, &fakeListSvc{items: items}); err != nil {
|
||||
t.Fatalf("runList: %v", err)
|
||||
}
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if _, has := got["description"]; has {
|
||||
t.Errorf("description should be filtered out: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// makeAgents returns N Agents with distinct IDs and descending UpdatedAt.
|
||||
func makeAgents(n int) []sdk.Agent {
|
||||
base := time.Now()
|
||||
out := make([]sdk.Agent, n)
|
||||
for i := 0; i < n; i++ {
|
||||
out[i] = sdk.Agent{
|
||||
ID: fmt.Sprintf("ag_%02d", i),
|
||||
Name: fmt.Sprintf("name-%02d", i),
|
||||
UpdatedAt: base.Add(-time.Duration(i) * time.Hour),
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestList_Limit_CapsResults(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
if err := runList(context.Background(), &ListOptions{Limit: 5}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, &fakeListSvc{items: makeAgents(20)}); err != nil {
|
||||
t.Fatalf("runList: %v", err)
|
||||
}
|
||||
got := strings.Count(out.String(), `"id":"ag_`)
|
||||
if got != 5 {
|
||||
t.Errorf("--limit 5 must slice 20 down to 5; got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestList_Truncation_SignalsHasMoreAndTotal pins that --limit truncation
|
||||
// tells the agent it did not get everything (has_more + total_count).
|
||||
// Regression: agent list silently dropped agents past --limit with no signal.
|
||||
func TestList_Truncation_SignalsHasMoreAndTotal(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
if err := runList(context.Background(), &ListOptions{Limit: 5}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, &fakeListSvc{items: makeAgents(20)}); err != nil {
|
||||
t.Fatalf("runList: %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), `"has_more":true`) {
|
||||
t.Errorf("truncated list must set has_more:true; got:\n%s", out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), `"total_count":20`) {
|
||||
t.Errorf("truncated list must report total_count:20; got:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_NoTruncation_OmitsHasMore(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
if err := runList(context.Background(), &ListOptions{Limit: 30}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, &fakeListSvc{items: makeAgents(3)}); err != nil {
|
||||
t.Fatalf("runList: %v", err)
|
||||
}
|
||||
if strings.Contains(out.String(), `"has_more"`) {
|
||||
t.Errorf("non-truncated list must omit has_more; got:\n%s", out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), `"total_count":3`) {
|
||||
t.Errorf("list must report total_count:3; got:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_Limit_Zero_Rejected(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
err := runList(context.Background(), &ListOptions{Limit: 0}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, &fakeListSvc{items: makeAgents(7)})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for --limit 0")
|
||||
}
|
||||
var typed *cmdutil.Error
|
||||
if !errors.As(err, &typed) {
|
||||
t.Fatalf("expected *cmdutil.Error, got %T: %v", err, err)
|
||||
}
|
||||
if typed.Code != cmdutil.CodeInputInvalidArgument {
|
||||
t.Errorf("expected CodeInputInvalidArgument, got %v", typed.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_Limit_Negative_Rejected(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
err := runList(context.Background(), &ListOptions{Limit: -1}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, &fakeListSvc{items: makeAgents(2)})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for negative --limit")
|
||||
}
|
||||
var typed *cmdutil.Error
|
||||
if !errors.As(err, &typed) {
|
||||
t.Fatalf("expected *cmdutil.Error, got %T: %v", err, err)
|
||||
}
|
||||
if typed.Code != cmdutil.CodeInputInvalidArgument {
|
||||
t.Errorf("expected CodeInputInvalidArgument, got %v", typed.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package agentcmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// AgentStatusResult is the shallow health snapshot for `agent status <id>`.
|
||||
//
|
||||
// One HTTP call: reachable + model_id. For downstream KB reachability
|
||||
// verification use 'agent check <id>' (1 + N HTTP).
|
||||
//
|
||||
// model_reachable is intentionally OMITTED: the server has no per-agent
|
||||
// test endpoint, so we cannot verify model reachability from the agent
|
||||
// resource alone.
|
||||
type AgentStatusResult struct {
|
||||
ID string `json:"id"`
|
||||
Reachable bool `json:"reachable"`
|
||||
ModelID string `json:"model_id,omitempty"`
|
||||
}
|
||||
|
||||
// AgentStatusService is the narrow SDK surface needed for agent status.
|
||||
type AgentStatusService interface {
|
||||
GetAgent(ctx context.Context, id string) (*sdk.Agent, error)
|
||||
}
|
||||
|
||||
var agentStatusFields = []string{"id", "reachable", "model_id"}
|
||||
|
||||
func NewCmdStatus(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "status <agent-id>",
|
||||
Short: "Show health status of a custom agent",
|
||||
Long: `Shallow health snapshot for an agent (1 HTTP call).
|
||||
|
||||
Returns: reachable / model_id.
|
||||
|
||||
For downstream KB reachability verification use 'weknora agent check <id>'
|
||||
(active verification, 1 + N HTTP). For full agent config / metadata use
|
||||
'weknora agent view <id>'.`,
|
||||
Example: ` weknora agent status ag_abc
|
||||
weknora agent status ag_abc --format json`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
fopts, err := cmdutil.CheckFormatFlag(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := runAgentStatus(c.Context(), cli, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return emitAgentStatus(res, fopts, iostreams.IO.Out)
|
||||
},
|
||||
}
|
||||
cmdutil.AddFormatFlag(cmd, agentStatusFields...)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "shallow health probe of a custom agent: reachability without kb_scope verification",
|
||||
RequiredFlags: []string{"<agent-id> (positional)"},
|
||||
Examples: []string{"weknora agent status agent_abc"},
|
||||
Output: "envelope.data is {id, reachable, ...}; use `agent check` for deep kb_scope verification",
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
// runAgentStatus is the testable core. Never errors for "agent not
|
||||
// reachable" — Reachable=false carries that signal.
|
||||
func runAgentStatus(ctx context.Context, svc AgentStatusService, id string) (*AgentStatusResult, error) {
|
||||
a, err := svc.GetAgent(ctx, id)
|
||||
if err != nil {
|
||||
return &AgentStatusResult{ID: id, Reachable: false}, nil
|
||||
}
|
||||
res := &AgentStatusResult{ID: a.ID, Reachable: true}
|
||||
if a.Config != nil {
|
||||
res.ModelID = a.Config.ModelID
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// emitAgentStatus renders res per --format. Mirrors emitStatus pattern from
|
||||
// cli/cmd/kb/status.go (C3).
|
||||
func emitAgentStatus(res *AgentStatusResult, fopts *cmdutil.FormatOptions, w io.Writer) error {
|
||||
switch fopts.Mode {
|
||||
case cmdutil.FormatJSON, cmdutil.FormatNDJSON:
|
||||
return fopts.Emit(w, res, nil)
|
||||
case cmdutil.FormatText, "":
|
||||
return writeAgentStatusText(w, res)
|
||||
default:
|
||||
return fmt.Errorf("unsupported --format %q for agent status", fopts.Mode)
|
||||
}
|
||||
}
|
||||
|
||||
func writeAgentStatusText(w io.Writer, res *AgentStatusResult) error {
|
||||
fmt.Fprintf(w, "ID: %s\n", res.ID)
|
||||
fmt.Fprintf(w, "Reachable: %v\n", res.Reachable)
|
||||
if !res.Reachable {
|
||||
return nil
|
||||
}
|
||||
if res.ModelID != "" {
|
||||
fmt.Fprintf(w, "Model: %s\n", res.ModelID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// compile-time check: SDK client satisfies AgentStatusService.
|
||||
var _ AgentStatusService = (*sdk.Client)(nil)
|
||||
@@ -0,0 +1,108 @@
|
||||
package agentcmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
type fakeAgentStatusSvc struct {
|
||||
agent *sdk.Agent
|
||||
agentErr error
|
||||
}
|
||||
|
||||
func (f *fakeAgentStatusSvc) GetAgent(_ context.Context, id string) (*sdk.Agent, error) {
|
||||
if f.agentErr != nil {
|
||||
return nil, f.agentErr
|
||||
}
|
||||
return f.agent, nil
|
||||
}
|
||||
|
||||
func TestRunAgentStatus_ShallowFields(t *testing.T) {
|
||||
svc := &fakeAgentStatusSvc{agent: &sdk.Agent{
|
||||
ID: "ag_x",
|
||||
Config: &sdk.AgentConfig{
|
||||
ModelID: "m_x",
|
||||
KnowledgeBases: []string{"kb_a", "kb_b"},
|
||||
},
|
||||
}}
|
||||
res, err := runAgentStatus(context.Background(), svc, "ag_x")
|
||||
if err != nil {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
if !res.Reachable {
|
||||
t.Error("Reachable=false, want true")
|
||||
}
|
||||
if res.ModelID != "m_x" {
|
||||
t.Errorf("ModelID=%q, want m_x", res.ModelID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAgentStatus_Unreachable(t *testing.T) {
|
||||
svc := &fakeAgentStatusSvc{agentErr: fmt.Errorf("404")}
|
||||
res, err := runAgentStatus(context.Background(), svc, "ag_x")
|
||||
if err != nil {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
if res.Reachable {
|
||||
t.Error("Reachable=true on 404; want false")
|
||||
}
|
||||
if res.ID != "ag_x" {
|
||||
t.Errorf("ID=%q, want ag_x (echoed even on unreachable)", res.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAgentStatus_NilConfig(t *testing.T) {
|
||||
// Defensive: Agent.Config is a pointer; nil should not panic
|
||||
svc := &fakeAgentStatusSvc{agent: &sdk.Agent{ID: "ag_x", Config: nil}}
|
||||
res, err := runAgentStatus(context.Background(), svc, "ag_x")
|
||||
if err != nil {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
if !res.Reachable {
|
||||
t.Error("Reachable=false, want true on nil config")
|
||||
}
|
||||
if res.ModelID != "" {
|
||||
t.Errorf("ModelID=%q, want empty (no config)", res.ModelID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitAgentStatus_JSON(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
res := &AgentStatusResult{ID: "ag_x", Reachable: true, ModelID: "m_x"}
|
||||
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}
|
||||
if err := emitAgentStatus(res, fopts, &buf); err != nil {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data AgentStatusResult `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(buf.Bytes(), &env); err != nil {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
got := env.Data
|
||||
if got.ModelID != "m_x" {
|
||||
t.Errorf("ModelID=%q, want m_x", got.ModelID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitAgentStatus_Text(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
res := &AgentStatusResult{ID: "ag_x", Reachable: true, ModelID: "m_x"}
|
||||
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatText}
|
||||
if err := emitAgentStatus(res, fopts, &buf); err != nil {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
for _, want := range []string{"ag_x", "m_x", "true"} {
|
||||
if !strings.Contains(buf.String(), want) {
|
||||
t.Errorf("output missing %q:\n%s", want, buf.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
package agentcmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
"github.com/Tencent/WeKnora/cli/internal/text"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// promptPreviewWidth caps inline KV row prompt previews. Multi-line prompts
|
||||
// collapse to one line via text.OneLine; the Templates section gets the
|
||||
// full multi-line treatment instead.
|
||||
const promptPreviewWidth = 80
|
||||
|
||||
// agentViewFields enumerates the top-level Agent keys surfaced in `--help`
|
||||
// as a hint for `--jq` projection. Nested AgentConfig fields are reachable
|
||||
// via `--jq '.config.system_prompt'` or by selecting `config` whole and
|
||||
// post-processing.
|
||||
var agentViewFields = []string{
|
||||
"id", "name", "description", "avatar",
|
||||
"is_builtin", "tenant_id", "created_by", "config",
|
||||
"created_at", "updated_at",
|
||||
}
|
||||
|
||||
// ViewService is the narrow SDK surface this command depends on.
|
||||
type ViewService interface {
|
||||
GetAgent(ctx context.Context, agentID string) (*sdk.Agent, error)
|
||||
}
|
||||
|
||||
// NewCmdView builds `weknora agent view <agent-id>`.
|
||||
func NewCmdView(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "view <agent-id>",
|
||||
Short: "Show a custom agent's configuration",
|
||||
Long: `Renders the agent's metadata and full AgentConfig as grouped KV
|
||||
sections (Identity / LLM / KB attachment / Retrieval / Query rewrite /
|
||||
Tools / FAQ / Web search / Multi-turn / Fallback / Templates). Zero-value
|
||||
fields are omitted; sections with no set fields are suppressed entirely.
|
||||
|
||||
Pass --format json for the bare SDK Agent object (config nested, not flattened).
|
||||
Use --jq to project specific fields or reach into nested config.`,
|
||||
Example: ` weknora agent view ag_abc
|
||||
weknora agent view ag_abc --format json --jq '{id, name, config}' # top-level projection
|
||||
weknora agent view ag_abc --format json --jq '.config.system_prompt'`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
fopts, err := cmdutil.CheckFormatFlag(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runView(c.Context(), fopts, cli, args[0])
|
||||
},
|
||||
}
|
||||
cmdutil.AddFormatFlag(cmd, agentViewFields...)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "fetch one custom agent's full configuration by id",
|
||||
RequiredFlags: []string{"<agent-id> (positional)"},
|
||||
Examples: []string{"weknora agent view agent_abc"},
|
||||
Output: "envelope.data is the custom agent (id, name, description, is_builtin, and a nested config object holding model/kb_scope/...)",
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runView(ctx context.Context, fopts *cmdutil.FormatOptions, svc ViewService, agentID string) error {
|
||||
a, err := svc.GetAgent(ctx, agentID)
|
||||
if err != nil {
|
||||
return cmdutil.WrapHTTP(err, "fetch agent %s", agentID)
|
||||
}
|
||||
if fopts.WantsJSON() {
|
||||
return fopts.Emit(iostreams.IO.Out, a, nil)
|
||||
}
|
||||
renderAgent(iostreams.IO.Out, a)
|
||||
return nil
|
||||
}
|
||||
|
||||
// renderAgent prints a single agent in human-readable form, grouped into
|
||||
// 10 presentation sections. Zero-value fields are omitted; a section
|
||||
// header prints only when at least one of its fields is set. Group
|
||||
// labels and order are pinned by snapshot tests so future drift surfaces
|
||||
// as test failure rather than silent divergence.
|
||||
func renderAgent(w io.Writer, a *sdk.Agent) {
|
||||
// Identity is always rendered — id/name/created_at/updated_at are
|
||||
// never meaningfully empty for a fetched Agent.
|
||||
fmt.Fprintln(w, "Identity:")
|
||||
fmt.Fprintf(w, " ID: %s\n", a.ID)
|
||||
fmt.Fprintf(w, " Name: %s\n", a.Name)
|
||||
if a.Description != "" {
|
||||
fmt.Fprintf(w, " Description: %s\n", a.Description)
|
||||
}
|
||||
if a.IsBuiltin {
|
||||
fmt.Fprintln(w, " Builtin: yes")
|
||||
}
|
||||
if a.CreatedBy != "" {
|
||||
fmt.Fprintf(w, " Created by: %s\n", a.CreatedBy)
|
||||
}
|
||||
if a.TenantID != 0 {
|
||||
fmt.Fprintf(w, " Tenant ID: %d\n", a.TenantID)
|
||||
}
|
||||
fmt.Fprintf(w, " Created at: %s\n", a.CreatedAt.Format("2006-01-02 15:04:05"))
|
||||
fmt.Fprintf(w, " Updated at: %s\n", a.UpdatedAt.Format("2006-01-02 15:04:05"))
|
||||
|
||||
if a.Config == nil {
|
||||
return
|
||||
}
|
||||
c := a.Config
|
||||
|
||||
// Each group is rendered via a tiny helper: collect its set rows
|
||||
// upfront, suppress the whole section if empty. Avoids the
|
||||
// "header printed but no body" failure mode that plagues naive
|
||||
// conditional rendering.
|
||||
type row struct{ k, v string }
|
||||
emit := func(label string, rows []row) {
|
||||
if len(rows) == 0 {
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintf(w, "%s:\n", label)
|
||||
for _, r := range rows {
|
||||
fmt.Fprintf(w, " %-32s %s\n", r.k+":", r.v)
|
||||
}
|
||||
}
|
||||
|
||||
// LLM
|
||||
llm := []row{}
|
||||
if c.ModelID != "" {
|
||||
llm = append(llm, row{"Model ID", c.ModelID})
|
||||
}
|
||||
if c.RerankModelID != "" {
|
||||
llm = append(llm, row{"Rerank model ID", c.RerankModelID})
|
||||
}
|
||||
if c.Temperature != 0 {
|
||||
llm = append(llm, row{"Temperature", fmt.Sprintf("%g", c.Temperature)})
|
||||
}
|
||||
if c.MaxCompletionTokens != 0 {
|
||||
llm = append(llm, row{"Max completion tokens", fmt.Sprintf("%d", c.MaxCompletionTokens)})
|
||||
}
|
||||
if c.MaxIterations < 0 {
|
||||
llm = append(llm, row{"Max iterations", "unlimited"})
|
||||
} else if c.MaxIterations != 0 {
|
||||
llm = append(llm, row{"Max iterations", fmt.Sprintf("%d", c.MaxIterations)})
|
||||
}
|
||||
if c.AgentMode != "" {
|
||||
llm = append(llm, row{"Mode", c.AgentMode})
|
||||
}
|
||||
emit("LLM", llm)
|
||||
|
||||
// KB attachment
|
||||
kb := []row{}
|
||||
if c.KBSelectionMode != "" {
|
||||
kb = append(kb, row{"KB selection mode", c.KBSelectionMode})
|
||||
}
|
||||
if len(c.KnowledgeBases) > 0 {
|
||||
kb = append(kb, row{"Knowledge bases", strings.Join(c.KnowledgeBases, ", ")})
|
||||
}
|
||||
emit("KB attachment", kb)
|
||||
|
||||
// Retrieval
|
||||
retr := []row{}
|
||||
if c.EmbeddingTopK != 0 {
|
||||
retr = append(retr, row{"Embedding top K", fmt.Sprintf("%d", c.EmbeddingTopK)})
|
||||
}
|
||||
if c.KeywordThreshold != 0 {
|
||||
retr = append(retr, row{"Keyword threshold", fmt.Sprintf("%g", c.KeywordThreshold)})
|
||||
}
|
||||
if c.VectorThreshold != 0 {
|
||||
retr = append(retr, row{"Vector threshold", fmt.Sprintf("%g", c.VectorThreshold)})
|
||||
}
|
||||
if c.RerankTopK != 0 {
|
||||
retr = append(retr, row{"Rerank top K", fmt.Sprintf("%d", c.RerankTopK)})
|
||||
}
|
||||
if c.RerankThreshold != 0 {
|
||||
retr = append(retr, row{"Rerank threshold", fmt.Sprintf("%g", c.RerankThreshold)})
|
||||
}
|
||||
emit("Retrieval", retr)
|
||||
|
||||
// Query rewrite
|
||||
qr := []row{}
|
||||
if c.EnableQueryExpansion {
|
||||
qr = append(qr, row{"Query expansion", "enabled"})
|
||||
}
|
||||
if c.EnableRewrite {
|
||||
qr = append(qr, row{"Rewrite", "enabled"})
|
||||
}
|
||||
if c.QueryUnderstandModelID != "" {
|
||||
qr = append(qr, row{"Query understand model ID", c.QueryUnderstandModelID})
|
||||
}
|
||||
if c.RewritePromptSystem != "" {
|
||||
qr = append(qr, row{"Rewrite prompt (system)", text.OneLine(promptPreviewWidth, c.RewritePromptSystem)})
|
||||
}
|
||||
if c.RewritePromptUser != "" {
|
||||
qr = append(qr, row{"Rewrite prompt (user)", text.OneLine(promptPreviewWidth, c.RewritePromptUser)})
|
||||
}
|
||||
emit("Query rewrite", qr)
|
||||
|
||||
// Tools
|
||||
tools := []row{}
|
||||
if len(c.AllowedTools) > 0 {
|
||||
tools = append(tools, row{"Allowed tools", strings.Join(c.AllowedTools, ", ")})
|
||||
}
|
||||
if c.MCPSelectionMode != "" {
|
||||
tools = append(tools, row{"MCP selection mode", c.MCPSelectionMode})
|
||||
}
|
||||
if len(c.MCPServices) > 0 {
|
||||
tools = append(tools, row{"MCP services", strings.Join(c.MCPServices, ", ")})
|
||||
}
|
||||
if len(c.SupportedFileTypes) > 0 {
|
||||
tools = append(tools, row{"Supported file types", strings.Join(c.SupportedFileTypes, ", ")})
|
||||
}
|
||||
emit("Tools", tools)
|
||||
|
||||
// FAQ
|
||||
faq := []row{}
|
||||
if c.FAQPriorityEnabled {
|
||||
faq = append(faq, row{"FAQ priority", "enabled"})
|
||||
}
|
||||
if c.FAQDirectAnswerThreshold != 0 {
|
||||
faq = append(faq, row{"FAQ direct-answer threshold", fmt.Sprintf("%g", c.FAQDirectAnswerThreshold)})
|
||||
}
|
||||
if c.FAQScoreBoost != 0 {
|
||||
faq = append(faq, row{"FAQ score boost", fmt.Sprintf("%g", c.FAQScoreBoost)})
|
||||
}
|
||||
emit("FAQ", faq)
|
||||
|
||||
// Web search
|
||||
web := []row{}
|
||||
if c.WebSearchEnabled {
|
||||
web = append(web, row{"Web search", "enabled"})
|
||||
}
|
||||
if c.WebSearchMaxResults != 0 {
|
||||
web = append(web, row{"Web search max results", fmt.Sprintf("%d", c.WebSearchMaxResults)})
|
||||
}
|
||||
emit("Web search", web)
|
||||
|
||||
// Multi-turn
|
||||
mt := []row{}
|
||||
if c.MultiTurnEnabled {
|
||||
mt = append(mt, row{"Multi-turn", "enabled"})
|
||||
}
|
||||
if c.HistoryTurns != 0 {
|
||||
mt = append(mt, row{"History turns", fmt.Sprintf("%d", c.HistoryTurns)})
|
||||
}
|
||||
emit("Multi-turn", mt)
|
||||
|
||||
// Fallback
|
||||
fb := []row{}
|
||||
if c.FallbackStrategy != "" {
|
||||
fb = append(fb, row{"Strategy", c.FallbackStrategy})
|
||||
}
|
||||
if c.FallbackResponse != "" {
|
||||
fb = append(fb, row{"Response", c.FallbackResponse})
|
||||
}
|
||||
if c.FallbackPrompt != "" {
|
||||
fb = append(fb, row{"Prompt", text.OneLine(promptPreviewWidth, c.FallbackPrompt)})
|
||||
}
|
||||
emit("Fallback", fb)
|
||||
|
||||
// Templates — system_prompt and context_template can be multi-line;
|
||||
// render headed blocks rather than KV rows for readability.
|
||||
if c.SystemPrompt != "" || c.ContextTemplate != "" {
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintln(w, "Templates:")
|
||||
if c.SystemPrompt != "" {
|
||||
fmt.Fprintln(w, " System prompt:")
|
||||
writeIndented(w, c.SystemPrompt, " ")
|
||||
}
|
||||
if c.ContextTemplate != "" {
|
||||
fmt.Fprintln(w, " Context template:")
|
||||
writeIndented(w, c.ContextTemplate, " ")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// writeIndented prints s with the given prefix on every line. Trailing
|
||||
// newline always added so the next section starts on its own line.
|
||||
func writeIndented(w io.Writer, s, prefix string) {
|
||||
for _, line := range strings.Split(strings.TrimRight(s, "\n"), "\n") {
|
||||
fmt.Fprintf(w, "%s%s\n", prefix, line)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package agentcmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
type fakeViewSvc struct {
|
||||
resp *sdk.Agent
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeViewSvc) GetAgent(_ context.Context, _ string) (*sdk.Agent, error) {
|
||||
return f.resp, f.err
|
||||
}
|
||||
|
||||
func TestView_Text_RendersMetadataAndConfig(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeViewSvc{resp: &sdk.Agent{
|
||||
ID: "ag_abc",
|
||||
Name: "Research",
|
||||
Description: "deep-dive helper",
|
||||
IsBuiltin: true,
|
||||
CreatedAt: time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC),
|
||||
UpdatedAt: time.Date(2026, 5, 2, 13, 30, 0, 0, time.UTC),
|
||||
Config: &sdk.AgentConfig{
|
||||
AgentMode: "smart-reasoning",
|
||||
ModelID: "model_42",
|
||||
KBSelectionMode: "selected",
|
||||
KnowledgeBases: []string{"kb_x", "kb_y"},
|
||||
AllowedTools: []string{"knowledge_search", "web_search"},
|
||||
WebSearchEnabled: true,
|
||||
},
|
||||
}}
|
||||
if err := runView(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "ag_abc"); err != nil {
|
||||
t.Fatalf("runView: %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
for _, want := range []string{"ag_abc", "Research", "deep-dive helper", "Builtin:", "Identity", "LLM", "KB attachment", "Tools", "smart-reasoning", "model_42", "selected", "kb_x", "knowledge_search", "Web search"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing %q in:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentViewFields_TopLevelOnly pins the contract that the field-hint
|
||||
// list on `agent view` only lists top-level Agent keys (including `config`
|
||||
// as a whole). Nested AgentConfig fields are reachable via --jq.
|
||||
func TestAgentViewFields_TopLevelOnly(t *testing.T) {
|
||||
for _, want := range []string{"id", "name", "config", "created_at"} {
|
||||
if !slices.Contains(agentViewFields, want) {
|
||||
t.Errorf("agentViewFields missing top-level key %q", want)
|
||||
}
|
||||
}
|
||||
for _, dotted := range []string{"config.system_prompt", "config.model_id", "config.fallback_strategy"} {
|
||||
if slices.Contains(agentViewFields, dotted) {
|
||||
t.Errorf("agentViewFields must not list dotted nested key %q (the --jq projection path handles nesting directly)", dotted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderAgent_RendersAllGroupsWithOmitEmpty validates the grouped
|
||||
// text rendering: present groups print, zero-value fields omit, and an
|
||||
// entire section is suppressed when all of its fields are zero.
|
||||
func TestRenderAgent_RendersAllGroupsWithOmitEmpty(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
ag := &sdk.Agent{
|
||||
ID: "ag_abc",
|
||||
Name: "Test",
|
||||
Config: &sdk.AgentConfig{
|
||||
AgentMode: "smart-reasoning",
|
||||
SystemPrompt: "You help users.",
|
||||
ModelID: "model-x",
|
||||
Temperature: 0.7,
|
||||
KBSelectionMode: "selected",
|
||||
KnowledgeBases: []string{"kb_a"},
|
||||
FAQPriorityEnabled: true,
|
||||
WebSearchEnabled: false, // zero — omitted in text
|
||||
FallbackStrategy: "fixed",
|
||||
FallbackResponse: "I don't know.",
|
||||
},
|
||||
}
|
||||
renderAgent(iostreams.IO.Out, ag)
|
||||
body := out.String()
|
||||
// Group labels appear:
|
||||
for _, label := range []string{"Identity", "LLM", "KB attachment", "FAQ", "Fallback", "Templates"} {
|
||||
if !strings.Contains(body, label) {
|
||||
t.Errorf("missing group label %q in:\n%s", label, body)
|
||||
}
|
||||
}
|
||||
// Set fields rendered:
|
||||
for _, want := range []string{"smart-reasoning", "model-x", "You help users."} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("missing value %q in:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
// Zero-value fields omitted (web search disabled → max_results not shown):
|
||||
if strings.Contains(body, "web_search_max_results") {
|
||||
t.Errorf("zero-valued web_search_max_results leaked into:\n%s", body)
|
||||
}
|
||||
// Section with all zero values must be suppressed entirely (no Retrieval
|
||||
// fields were set in this fixture).
|
||||
if strings.Contains(body, "Retrieval") {
|
||||
t.Errorf("Retrieval section rendered with all-zero fields in:\n%s", body)
|
||||
}
|
||||
// Multi-turn section is all-zero too — must be suppressed.
|
||||
if strings.Contains(body, "Multi-turn") {
|
||||
t.Errorf("Multi-turn section rendered with all-zero fields in:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestView_Text_OmitsEmptyFields(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeViewSvc{resp: &sdk.Agent{
|
||||
ID: "ag_min",
|
||||
Name: "Minimal",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}}
|
||||
if err := runView(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "ag_min"); err != nil {
|
||||
t.Fatalf("runView: %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
if strings.Contains(got, "Description:") {
|
||||
t.Errorf("empty description should be omitted, got:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "Builtin:") {
|
||||
t.Errorf("non-builtin should not render Builtin: line, got:\n%s", got)
|
||||
}
|
||||
// With Config==nil none of the grouped config sections should appear.
|
||||
for _, label := range []string{"LLM:", "KB attachment:", "Retrieval:", "Query rewrite:", "Tools:", "FAQ:", "Web search:", "Multi-turn:", "Fallback:", "Templates:"} {
|
||||
if strings.Contains(got, label) {
|
||||
t.Errorf("nil Config should not render %q, got:\n%s", label, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestView_JSON_BareObject(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeViewSvc{resp: &sdk.Agent{ID: "ag_json", Name: "JSONy"}}
|
||||
if err := runView(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "ag_json"); err != nil {
|
||||
t.Fatalf("runView: %v", err)
|
||||
}
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data sdk.Agent `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
got := env.Data
|
||||
if got.ID != "ag_json" || got.Name != "JSONy" {
|
||||
t.Errorf("envelope.data shape wrong: id=%s name=%s", got.ID, got.Name)
|
||||
}
|
||||
if !env.OK {
|
||||
t.Errorf("envelope.ok must be true, got %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestView_404_MapsToResourceNotFound(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeViewSvc{err: errors.New("HTTP error 404: agent not found")}
|
||||
err := runView(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "ag_missing")
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
var typed *cmdutil.Error
|
||||
if !errors.As(err, &typed) {
|
||||
t.Fatalf("expected *cmdutil.Error, got %T", err)
|
||||
}
|
||||
if typed.Code != cmdutil.CodeResourceNotFound {
|
||||
t.Errorf("expected resource.not_found, got %s", typed.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
// TestEveryLeafCommandHasAgentHelp enforces the agent-first contract: every
|
||||
// leaf (runnable, no subcommands) command must emit a structured AgentHelp JSON
|
||||
// blob under WEKNORA_AGENT_HELP=1, so an agent never has to scrape human prose.
|
||||
// Drift guard in the spirit of the K6/K7 skill-parity tests: a new leaf command
|
||||
// added without SetAgentHelp fails CI here.
|
||||
//
|
||||
// Exemptions: cobra's generated `completion`/`help` subtrees carry no
|
||||
// domain semantics worth a machine blob.
|
||||
func TestEveryLeafCommandHasAgentHelp(t *testing.T) {
|
||||
t.Setenv("WEKNORA_AGENT_HELP", "1")
|
||||
root := NewRootCmd(cmdutil.New())
|
||||
|
||||
var missing []string
|
||||
eachLeafCommand(root, func(c *cobra.Command) {
|
||||
// Invoke the leaf's help func with the agent env set and require JSON.
|
||||
var buf bytes.Buffer
|
||||
c.SetOut(&buf)
|
||||
c.Help()
|
||||
var ah struct {
|
||||
UsedFor string `json:"used_for"`
|
||||
}
|
||||
if err := json.Unmarshal(buf.Bytes(), &ah); err != nil || ah.UsedFor == "" {
|
||||
missing = append(missing, c.CommandPath())
|
||||
}
|
||||
})
|
||||
|
||||
sort.Strings(missing)
|
||||
if len(missing) > 0 {
|
||||
t.Errorf("leaf commands missing agent-help JSON (register cmdutil.SetAgentHelp):\n %v\n(%d commands)",
|
||||
missing, len(missing))
|
||||
}
|
||||
}
|
||||
|
||||
// renderAgentHelp runs a leaf's help under WEKNORA_AGENT_HELP=1 and decodes the
|
||||
// machine blob (used_for / output / examples) an agent would read.
|
||||
func renderAgentHelp(t *testing.T, c *cobra.Command) struct {
|
||||
UsedFor string `json:"used_for"`
|
||||
Output string `json:"output"`
|
||||
Examples []string `json:"examples"`
|
||||
} {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
c.SetOut(&buf)
|
||||
c.Help()
|
||||
var ah struct {
|
||||
UsedFor string `json:"used_for"`
|
||||
Output string `json:"output"`
|
||||
Examples []string `json:"examples"`
|
||||
}
|
||||
if err := json.Unmarshal(buf.Bytes(), &ah); err != nil {
|
||||
t.Fatalf("%s: agent-help is not JSON: %v", c.CommandPath(), err)
|
||||
}
|
||||
return ah
|
||||
}
|
||||
|
||||
// TestEveryLeafCommandDeclaresOutput enforces that every leaf command tells an
|
||||
// agent what its stdout carries. Even side-effect commands describe their
|
||||
// envelope (e.g. deletes emit {id, deleted:true}); an empty Output is a contract
|
||||
// gap, not a valid state. Sibling drift guard to the agent-help test above.
|
||||
func TestEveryLeafCommandDeclaresOutput(t *testing.T) {
|
||||
t.Setenv("WEKNORA_AGENT_HELP", "1")
|
||||
root := NewRootCmd(cmdutil.New())
|
||||
|
||||
var missing []string
|
||||
eachLeafCommand(root, func(c *cobra.Command) {
|
||||
if renderAgentHelp(t, c).Output == "" {
|
||||
missing = append(missing, c.CommandPath())
|
||||
}
|
||||
})
|
||||
|
||||
sort.Strings(missing)
|
||||
if len(missing) > 0 {
|
||||
t.Errorf("leaf commands missing agent-help Output (declare AgentHelp.Output):\n %v\n(%d commands)",
|
||||
missing, len(missing))
|
||||
}
|
||||
}
|
||||
|
||||
// TestEveryLeafCommandHasExample enforces that every leaf ships at least one
|
||||
// runnable example — agents learn invocation shape from examples, not prose.
|
||||
func TestEveryLeafCommandHasExample(t *testing.T) {
|
||||
t.Setenv("WEKNORA_AGENT_HELP", "1")
|
||||
root := NewRootCmd(cmdutil.New())
|
||||
|
||||
var missing []string
|
||||
eachLeafCommand(root, func(c *cobra.Command) {
|
||||
if len(renderAgentHelp(t, c).Examples) == 0 {
|
||||
missing = append(missing, c.CommandPath())
|
||||
}
|
||||
})
|
||||
|
||||
sort.Strings(missing)
|
||||
if len(missing) > 0 {
|
||||
t.Errorf("leaf commands missing agent-help Examples (declare AgentHelp.Examples):\n %v\n(%d commands)",
|
||||
missing, len(missing))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
// Package api implements the `weknora api` raw HTTP passthrough command.
|
||||
//
|
||||
// Shape: one positional (path) + `-X/--method` flag, default GET (auto-
|
||||
// promoted to POST when a body is supplied via -d/--input). Text mode writes
|
||||
// the raw response body to stdout; --format json places the parsed server
|
||||
// response directly under envelope.data (no {status, headers, body} wrapper),
|
||||
// so `--jq '.data...'` projects at the server's own depth. Reuses
|
||||
// sdk.Client.Raw which already applies tenant + auth headers.
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// apiFields is intentionally a marker - api wraps arbitrary HTTP responses
|
||||
// whose schema the CLI doesn't know, so field hints are meaningless here.
|
||||
// The marker shows up in --help so users can tell.
|
||||
var apiFields = []string{"<response-shape-varies>"}
|
||||
|
||||
type Options struct {
|
||||
Method string
|
||||
Input string // --input: file path, "-" for stdin
|
||||
Data string // -d/--data: inline JSON body (mutually exclusive with --input)
|
||||
Fields []string // -F/--field: key=value pairs assembled into a JSON object body (mutually exclusive with -d/--input)
|
||||
Yes bool
|
||||
DryRun bool
|
||||
StdinReader io.Reader // overridden by tests; defaults to iostreams.IO.In
|
||||
}
|
||||
|
||||
// Service is the narrow SDK surface this command depends on. The production
|
||||
// implementation is *sdk.Client, whose Raw method already injects auth /
|
||||
// tenant / request-id headers (see client.applyAuthHeaders). Tests substitute
|
||||
// either a fake or a real client pointed at httptest.Server.
|
||||
type Service interface {
|
||||
Raw(ctx context.Context, method, path string, body any) (*http.Response, error)
|
||||
}
|
||||
|
||||
// NewCmd returns the `weknora api` command.
|
||||
func NewCmd(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &Options{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "api <path>",
|
||||
Short: "Make a raw API request to the WeKnora server",
|
||||
Long: `Raw HTTP API access. JSON body via -d/--data (inline) or --input <file>/- (stdin).
|
||||
|
||||
The default method is GET; supplying a body (-d or --input) auto-promotes it
|
||||
to POST. Use -X/--method to override (any non-empty method is accepted:
|
||||
DELETE / PUT / PATCH / HEAD / OPTIONS / TRACE / custom).
|
||||
|
||||
Auth, tenant, and request-id headers are applied automatically from the
|
||||
active profile. In text mode (default) the raw server response body is written
|
||||
to stdout. In --format json the parsed server response is placed directly
|
||||
under envelope.data — drill in with --jq '.data...' at the server's own depth
|
||||
(e.g. '.data.data[]' for a list endpoint). Only -X DELETE is confirmation-gated.
|
||||
|
||||
Examples:
|
||||
weknora api /api/v1/knowledge-bases # GET
|
||||
weknora api /api/v1/knowledge-bases -d '{"name":"foo"}' # POST (auto)
|
||||
weknora api /api/v1/knowledge-bases -F name=foo -F enabled=true # POST, typed body
|
||||
echo '{"name":"foo"}' | weknora api /api/v1/knowledge-bases --input - # POST via stdin
|
||||
weknora api /api/v1/knowledge-bases/kb_xxx -X DELETE`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
opts.Yes, _ = c.Flags().GetBool("yes")
|
||||
fopts, err := cmdutil.CheckFormatFlag(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
|
||||
if opts.DryRun {
|
||||
// --dry-run on the raw escape-hatch is only meaningful when a
|
||||
// would-be mutation exists to preview. GET (default or explicit)
|
||||
// is read-only with no side effect, so previewing it is a
|
||||
// likely user error — signal it via FlagError exit 2 with the
|
||||
// concrete repair. Use resolveMethod so --input auto-promotes
|
||||
// GET → POST identically to the live path.
|
||||
method := resolveMethod(opts)
|
||||
if method == http.MethodGet {
|
||||
return cmdutil.NewFlagError(fmt.Errorf(
|
||||
"--dry-run requires explicit -X POST/PUT/PATCH/DELETE; default GET is read-only with no side effect to preview"))
|
||||
}
|
||||
var body any
|
||||
if hasBody(opts) {
|
||||
contents, err := resolveBody(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Surface the parsed body in the plan so agents can grep
|
||||
// meta.plan.body for shape; resolveBody has already validated
|
||||
// it as JSON.
|
||||
var parsed any
|
||||
if json.Unmarshal(contents, &parsed) == nil {
|
||||
body = parsed
|
||||
} else {
|
||||
body = string(contents)
|
||||
}
|
||||
}
|
||||
if handled, err := cmdutil.HandleDryRun(c, true, cmdutil.DryRunPlan{
|
||||
Action: "api." + strings.ToLower(method),
|
||||
Method: method,
|
||||
Path: args[0],
|
||||
Body: body,
|
||||
}); handled {
|
||||
return err
|
||||
}
|
||||
}
|
||||
method := resolveMethod(opts)
|
||||
// Escape-hatch DELETE through `weknora api` is just as destructive
|
||||
// as `weknora kb delete` - exit-10 destructive protocol must apply
|
||||
// (cli/README.md). PUT/PATCH mutate server state like a typed
|
||||
// `kb/agent/doc update`, so they get the same exit-10 WRITE gate;
|
||||
// without it the raw escape hatch bypassed the "an agent cannot
|
||||
// silently mutate" guarantee. POST stays ungated to match typed
|
||||
// `create` (also ungated). GET/HEAD are reads.
|
||||
switch method {
|
||||
case http.MethodDelete:
|
||||
if err := cmdutil.ConfirmDestructive(f.Prompter(), opts.Yes, fopts.WantsJSON(), "delete", "endpoint", args[0], "api.delete", []string{"weknora", "api", "-X", "DELETE", args[0], "-y"}); err != nil {
|
||||
return err
|
||||
}
|
||||
case http.MethodPut, http.MethodPatch:
|
||||
if err := cmdutil.ConfirmWrite(f.Prompter(), opts.Yes, fopts.WantsJSON(), "write", "endpoint", args[0], "api."+strings.ToLower(method), apiRetryArgv(opts, method, args[0])); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
paginate, _ := c.Flags().GetBool("paginate")
|
||||
return runAPI(c.Context(), opts, fopts, cli, method, args[0], paginate)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVarP(&opts.Method, "method", "X", "", "HTTP method (default: GET, or POST when a body is supplied via -d/--input). Any non-empty method is accepted.")
|
||||
cmd.Flags().StringVarP(&opts.Data, "data", "d", "", "Inline JSON request body (e.g. -d '{\"name\":\"x\"}'). Mutually exclusive with --input.")
|
||||
cmd.Flags().StringVar(&opts.Input, "input", "", "Read JSON request body from file (use `-` for stdin). Mutually exclusive with -d/--data.")
|
||||
cmd.Flags().StringArrayVarP(&opts.Fields, "field", "F", nil, "Add a key=value to a JSON object body (repeatable; e.g. -F name=foo -F enabled=true). true/false/null and numbers are typed; everything else is a string. Flat keys only. Mutually exclusive with -d/--input.")
|
||||
cmd.Flags().Bool("paginate", false, "Follow offset-based pagination (?page=N&page_size=M), merging all pages into a single {data, total} JSON response.")
|
||||
cmdutil.AddFormatFlag(cmd, apiFields...)
|
||||
cmdutil.AddDryRunFlag(cmd, &opts.DryRun)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "raw HTTP passthrough to weknora-server API endpoints when typed subcommands are insufficient",
|
||||
RequiredFlags: []string{"path (positional)"},
|
||||
Examples: []string{
|
||||
"weknora api /api/v1/knowledge-bases",
|
||||
"weknora api /api/v1/knowledge-bases -d '{\"name\":\"foo\"}'",
|
||||
"weknora api -X POST /api/v1/knowledge-bases -F name=\"Eng Docs\" -F enabled=true",
|
||||
"weknora api -X DELETE /api/v1/knowledge-bases/kb_x -y",
|
||||
"echo '{\"name\":\"foo\"}' | weknora api /api/v1/knowledge-bases --input -",
|
||||
},
|
||||
Output: "text mode (default): the raw server response body on stdout. json mode: the parsed server response is placed directly under envelope.data — project with --jq '.data...' at the server's own depth (e.g. '.data.data[]' for a list endpoint, '.data.data.id' for a created object). With --paginate, envelope.data is the merged {data, total}.",
|
||||
Warnings: []string{
|
||||
"-X DELETE is destructive-gated and -X PUT/PATCH are write-gated (exit 10 / input.confirmation_required unless -y), matching typed delete/update. -X POST (create-shaped) and GET are unguarded — you own the safety of creates made through this escape hatch.",
|
||||
"Raw passthrough: the typed error envelope does NOT fully apply. The server's own response goes under envelope.data at its native depth; a non-2xx HTTP status surfaces via the exit code, not a typed error.type/retry_argv. Do not rely on error.type/retryable for `api` the way you do for typed subcommands.",
|
||||
"Raw HTTP passthrough; agents should prefer typed subcommands (kb/doc/session/...) when available.",
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
// readInput reads opts.Input and returns its contents. "-" reads from
|
||||
// opts.StdinReader (or iostreams.IO.In as the production default) for
|
||||
// piped JSON payloads.
|
||||
//
|
||||
// The body is sent as a JSON document (the SDK marshals it as json.RawMessage),
|
||||
// so a non-JSON / malformed payload is validated and rejected here as
|
||||
// input.invalid_argument (exit 5). Without this, the failure surfaced from the
|
||||
// SDK's marshal step as a confusing network.error (exit 7, "retryable") —
|
||||
// looping an agent on a body that can never be sent.
|
||||
func readInput(opts *Options) ([]byte, error) {
|
||||
var b []byte
|
||||
if opts.Input == "-" {
|
||||
r := opts.StdinReader
|
||||
if r == nil {
|
||||
r = iostreams.IO.In
|
||||
}
|
||||
var err error
|
||||
b, err = io.ReadAll(r)
|
||||
if err != nil {
|
||||
return nil, cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "read request body from stdin")
|
||||
}
|
||||
} else {
|
||||
var err error
|
||||
b, err = os.ReadFile(opts.Input)
|
||||
if err != nil {
|
||||
return nil, cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "read input file %s", opts.Input)
|
||||
}
|
||||
}
|
||||
if len(bytes.TrimSpace(b)) > 0 && !json.Valid(b) {
|
||||
return nil, cmdutil.NewError(cmdutil.CodeInputInvalidArgument,
|
||||
"--input must be valid JSON (the request body is sent as a JSON document)")
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// resolveBody returns the request body bytes from -d/--data (inline) or
|
||||
// --input (file/stdin), or nil when neither is set. Inline --data is
|
||||
// validated as JSON here; --input is validated inside readInput. The two
|
||||
// flags are mutually exclusive — checked here (rather than cobra's
|
||||
// MarkFlagsMutuallyExclusive, whose violation surfaces as an unclassified
|
||||
// internal.error / exit 1) so the conflict reports input.invalid_argument.
|
||||
func resolveBody(opts *Options) ([]byte, error) {
|
||||
set := 0
|
||||
if opts.Data != "" {
|
||||
set++
|
||||
}
|
||||
if opts.Input != "" {
|
||||
set++
|
||||
}
|
||||
if len(opts.Fields) > 0 {
|
||||
set++
|
||||
}
|
||||
if set > 1 {
|
||||
return nil, cmdutil.NewError(cmdutil.CodeInputInvalidArgument,
|
||||
"-d/--data, --input, and -F/--field are mutually exclusive; supply the body one way")
|
||||
}
|
||||
if len(opts.Fields) > 0 {
|
||||
return buildFieldBody(opts.Fields)
|
||||
}
|
||||
if opts.Data != "" {
|
||||
if !json.Valid([]byte(opts.Data)) {
|
||||
return nil, cmdutil.NewError(cmdutil.CodeInputInvalidArgument,
|
||||
"-d/--data must be valid JSON (the request body is sent as a JSON document)")
|
||||
}
|
||||
return []byte(opts.Data), nil
|
||||
}
|
||||
if opts.Input != "" {
|
||||
return readInput(opts)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// hasBody reports whether a request body was supplied via -d or --input.
|
||||
func hasBody(opts *Options) bool {
|
||||
return opts.Data != "" || opts.Input != "" || len(opts.Fields) > 0
|
||||
}
|
||||
|
||||
// inferFieldValue maps a -F value string to a typed JSON scalar: literal
|
||||
// true/false/null and integer/float literals become typed JSON; everything
|
||||
// else stays a string. A value that looks numeric but must stay a string
|
||||
// (e.g. a zero-padded id "007") can't be forced here — use -d/--data or
|
||||
// --input for full control.
|
||||
func inferFieldValue(v string) any {
|
||||
switch v {
|
||||
case "true":
|
||||
return true
|
||||
case "false":
|
||||
return false
|
||||
case "null":
|
||||
return nil
|
||||
}
|
||||
if i, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||||
return i
|
||||
}
|
||||
if f, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
return f
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// buildFieldBody assembles -F/--field key=value pairs into a JSON object with
|
||||
// type inference. Flat keys only. Returns input.invalid_argument (exit 5) for
|
||||
// a malformed pair or an unsupported @file value.
|
||||
func buildFieldBody(fields []string) ([]byte, error) {
|
||||
obj := make(map[string]any, len(fields))
|
||||
for _, f := range fields {
|
||||
k, v, ok := strings.Cut(f, "=")
|
||||
if !ok || k == "" {
|
||||
return nil, cmdutil.NewError(cmdutil.CodeInputInvalidArgument,
|
||||
fmt.Sprintf("-F/--field must be key=value, got %q", f))
|
||||
}
|
||||
if strings.HasPrefix(v, "@") {
|
||||
return nil, cmdutil.NewError(cmdutil.CodeInputInvalidArgument,
|
||||
fmt.Sprintf("-F %q: @file values are not supported; read a file body with --input", f))
|
||||
}
|
||||
obj[k] = inferFieldValue(v)
|
||||
}
|
||||
return json.Marshal(obj)
|
||||
}
|
||||
|
||||
// resolveMethod implements the auto-method behavior: explicit -X wins;
|
||||
// otherwise body presence (-d/--data or --input) promotes GET → POST.
|
||||
func resolveMethod(opts *Options) string {
|
||||
if opts.Method != "" {
|
||||
return strings.ToUpper(opts.Method)
|
||||
}
|
||||
if hasBody(opts) {
|
||||
return "POST"
|
||||
}
|
||||
return "GET"
|
||||
}
|
||||
|
||||
// apiRetryArgv reconstructs a directly-executable `weknora api` argv (with -y)
|
||||
// for the write-confirmation gate, preserving the method, path and body flags
|
||||
// the caller passed so an agent can re-run the exact mutation after approval.
|
||||
func apiRetryArgv(opts *Options, method, path string) []string {
|
||||
argv := []string{"weknora", "api", "-X", method, path}
|
||||
switch {
|
||||
case opts.Data != "":
|
||||
argv = append(argv, "-d", opts.Data)
|
||||
case opts.Input != "":
|
||||
argv = append(argv, "--input", opts.Input)
|
||||
default:
|
||||
for _, f := range opts.Fields {
|
||||
argv = append(argv, "-F", f)
|
||||
}
|
||||
}
|
||||
return append(argv, "-y")
|
||||
}
|
||||
|
||||
// runAPI is the testable core: validate inputs, dispatch via Service.Raw,
|
||||
// classify status, and emit either the raw body or a JSON object. The
|
||||
// caller is responsible for resolving the method (defaults / auto-POST)
|
||||
// and uppercasing it; runAPI guards against unsupported values like
|
||||
// `-X PATCH-INVALID` reaching the wire.
|
||||
//
|
||||
// When paginate is true and method is GET, all offset-based pages are
|
||||
// fetched and merged into a single {data, total} JSON response. For
|
||||
// non-GET methods paginate is silently ignored (no offset semantic).
|
||||
func runAPI(ctx context.Context, opts *Options, fopts *cmdutil.FormatOptions, svc Service, method, path string, paginate bool) error {
|
||||
if paginate && method == http.MethodGet {
|
||||
return runAPIPaginated(ctx, opts, fopts, svc, path)
|
||||
}
|
||||
return runAPISingle(ctx, opts, fopts, svc, method, path)
|
||||
}
|
||||
|
||||
// runAPISingle is the original single-call implementation of runAPI.
|
||||
func runAPISingle(ctx context.Context, opts *Options, fopts *cmdutil.FormatOptions, svc Service, method, path string) error {
|
||||
if method == "" {
|
||||
return cmdutil.NewFlagError(fmt.Errorf("--method cannot be empty"))
|
||||
}
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, fmt.Sprintf("path must start with /: %s", path))
|
||||
}
|
||||
|
||||
// Resolve request body from -d/--data (inline) or --input (file / `-`).
|
||||
// Empty input is treated as no body rather than an empty JSON document
|
||||
// (which fails to marshal).
|
||||
contents, err := resolveBody(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var body any
|
||||
if len(bytes.TrimSpace(contents)) > 0 {
|
||||
body = json.RawMessage(contents)
|
||||
}
|
||||
|
||||
resp, err := svc.Raw(ctx, method, path, body)
|
||||
if err != nil {
|
||||
// Transport / DNS failure (Raw never returns a typed HTTP error of its
|
||||
// own; non-2xx responses still surface as resp != nil, err == nil).
|
||||
return cmdutil.WrapHTTP(err, "%s %s", method, path)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return cmdutil.Wrapf(cmdutil.CodeNetworkError, err, "read response body")
|
||||
}
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
code := cmdutil.ClassifyHTTPStatus(resp.StatusCode)
|
||||
ce := cmdutil.NewError(code, fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody))))
|
||||
if v := resp.Header.Get("Retry-After"); v != "" {
|
||||
if s, perr := strconv.Atoi(v); perr == nil && s > 0 {
|
||||
ce = ce.WithRetryAfter(s)
|
||||
}
|
||||
}
|
||||
return ce
|
||||
}
|
||||
|
||||
return emitRawBody(respBody, fopts)
|
||||
}
|
||||
|
||||
// emitRawBody writes a server response body under the active format. JSON mode
|
||||
// places the parsed body directly under envelope.data — no {status, headers,
|
||||
// body} wrapper — so `--jq '.data...'` projects at the server's own shape
|
||||
// (e.g. `.data.data[]` for a list endpoint) rather than a triple-nested
|
||||
// `.data.body.data[]`; non-JSON decodes best-effort to a raw string. Text mode
|
||||
// writes the body verbatim with a trailing newline. Shared by the single and
|
||||
// paginated-fallback paths so both project identically.
|
||||
func emitRawBody(body []byte, fopts *cmdutil.FormatOptions) error {
|
||||
out := iostreams.IO.Out
|
||||
if fopts.WantsJSON() {
|
||||
var bodyAny any
|
||||
if len(body) > 0 {
|
||||
if err := json.Unmarshal(body, &bodyAny); err != nil {
|
||||
bodyAny = string(body)
|
||||
}
|
||||
}
|
||||
return fopts.Emit(out, bodyAny, nil)
|
||||
}
|
||||
if _, err := out.Write(body); err != nil {
|
||||
return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "write response body")
|
||||
}
|
||||
if len(body) > 0 && body[len(body)-1] != '\n' {
|
||||
_, _ = out.Write([]byte{'\n'})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// runAPIPaginated fetches all offset-based pages for a GET request and writes
|
||||
// a single merged {data, total} JSON object to stdout. If the first page
|
||||
// response does not carry pagination metadata (total + page_size), the raw
|
||||
// response is passed through via passThroughFallback which respects the
|
||||
// --format envelope contract (same shape as runAPISingle's fallback path).
|
||||
func runAPIPaginated(ctx context.Context, opts *Options, fopts *cmdutil.FormatOptions, svc Service, path string) error {
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, fmt.Sprintf("path must start with /: %s", path))
|
||||
}
|
||||
|
||||
pageSize := extractPageSize(path)
|
||||
if pageSize == 0 {
|
||||
pageSize = 50
|
||||
}
|
||||
|
||||
allData := []json.RawMessage{}
|
||||
var lastTotal int64
|
||||
page := 1
|
||||
|
||||
for {
|
||||
curPath := setPageParam(path, page, pageSize)
|
||||
resp, err := svc.Raw(ctx, http.MethodGet, curPath, nil)
|
||||
if err != nil {
|
||||
return cmdutil.WrapHTTP(err, "GET %s", curPath)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
code := cmdutil.ClassifyHTTPStatus(resp.StatusCode)
|
||||
return cmdutil.NewError(code, fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))))
|
||||
}
|
||||
|
||||
var pageResp struct {
|
||||
Data []json.RawMessage `json:"data"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &pageResp); err != nil {
|
||||
// Non-JSON response on first page — fall back through the envelope.
|
||||
if page == 1 {
|
||||
return emitRawBody(body, fopts)
|
||||
}
|
||||
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument,
|
||||
fmt.Sprintf("--paginate: page %d response not in expected shape: %v", page, err))
|
||||
}
|
||||
|
||||
// Heuristic: if the first page lacks pagination metadata, treat the
|
||||
// response as non-paginated and fall back through the envelope.
|
||||
if page == 1 && pageResp.Total == 0 && pageResp.PageSize == 0 {
|
||||
return emitRawBody(body, fopts)
|
||||
}
|
||||
|
||||
allData = append(allData, pageResp.Data...)
|
||||
lastTotal = pageResp.Total
|
||||
|
||||
// Termination: accumulated count (not page*pageSize) handles server-capped page sizes.
|
||||
if int64(len(allData)) >= pageResp.Total || len(pageResp.Data) == 0 {
|
||||
break
|
||||
}
|
||||
page++
|
||||
}
|
||||
|
||||
merged := map[string]any{
|
||||
"data": allData,
|
||||
"total": lastTotal,
|
||||
}
|
||||
return fopts.Emit(iostreams.IO.Out, merged, nil)
|
||||
}
|
||||
|
||||
// extractPageSize parses the page_size query parameter from path, returning 0
|
||||
// if absent or unparseable.
|
||||
func extractPageSize(path string) int {
|
||||
u, err := url.Parse(path)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
if v := u.Query().Get("page_size"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// setPageParam rewrites the page and page_size query parameters in path,
|
||||
// preserving all other query parameters.
|
||||
func setPageParam(path string, page, pageSize int) string {
|
||||
u, err := url.Parse(path)
|
||||
if err != nil {
|
||||
return path
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("page", strconv.Itoa(page))
|
||||
q.Set("page_size", strconv.Itoa(pageSize))
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String()
|
||||
}
|
||||
|
||||
// compile-time check: the production SDK client implements Service.
|
||||
var _ Service = (*sdk.Client)(nil)
|
||||
@@ -0,0 +1,199 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
"github.com/Tencent/WeKnora/cli/internal/prompt"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// apiDryRunFactory builds a Factory whose Client closure panics if invoked —
|
||||
// dry-run must early-exit before any SDK call.
|
||||
func apiDryRunFactory(t *testing.T) *cmdutil.Factory {
|
||||
t.Helper()
|
||||
return &cmdutil.Factory{
|
||||
Client: func() (*sdk.Client, error) {
|
||||
t.Fatal("dry-run path must not call Factory.Client(); SDK side effect leaked")
|
||||
return nil, nil
|
||||
},
|
||||
Prompter: func() prompt.Prompter {
|
||||
t.Fatal("dry-run path must not call Factory.Prompter(); confirm-prompt side effect leaked")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestApi_DryRunWithGet_FlagError: default-method GET + --dry-run must return
|
||||
// FlagError (exit 2). The only meaningful target for --dry-run on `api` is a
|
||||
// mutation method; allowing GET to silently succeed would let agents waste a
|
||||
// round-trip previewing a no-op.
|
||||
func TestApi_DryRunWithGet_FlagError(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
root := withRootHarness(NewCmd(apiDryRunFactory(t)),
|
||||
"/api/v1/knowledge-bases", "--dry-run")
|
||||
err := root.Execute()
|
||||
require.Error(t, err, "GET + --dry-run must error")
|
||||
|
||||
var fe *cmdutil.FlagError
|
||||
require.True(t, errors.As(err, &fe), "expected *cmdutil.FlagError, got %T %v", err, err)
|
||||
assert.Equal(t, 2, cmdutil.ExitCode(err), "FlagError must map to exit 2")
|
||||
assert.Contains(t, err.Error(), "explicit -X POST/PUT/PATCH/DELETE",
|
||||
"error message must point users to the concrete repair")
|
||||
}
|
||||
|
||||
// TestApi_DryRunWithExplicitGet_FlagError: explicit `-X GET` + --dry-run is
|
||||
// also rejected. The reject condition is "method is GET", not "method flag is
|
||||
// unset"; passing -X GET explicitly must produce the same error so users
|
||||
// can't bypass the guard by being verbose.
|
||||
func TestApi_DryRunWithExplicitGet_FlagError(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
root := withRootHarness(NewCmd(apiDryRunFactory(t)),
|
||||
"/api/v1/knowledge-bases", "-X", "GET", "--dry-run")
|
||||
err := root.Execute()
|
||||
require.Error(t, err, "explicit -X GET + --dry-run must error")
|
||||
|
||||
var fe *cmdutil.FlagError
|
||||
require.True(t, errors.As(err, &fe), "expected *cmdutil.FlagError, got %T %v", err, err)
|
||||
assert.Equal(t, 2, cmdutil.ExitCode(err))
|
||||
}
|
||||
|
||||
// TestApi_DryRunWithInputAutoPromotes_EmitsPlan: --input on its own (no
|
||||
// explicit -X) must auto-promote GET → POST in the dry-run path too,
|
||||
// matching the live behavior in resolveMethod. Before the fix the dry-run
|
||||
// branch built `method` directly from `strings.ToUpper(opts.Method)`, which
|
||||
// is empty when -X is unset, then defaulted to GET — so --input was ignored
|
||||
// at preview time, producing the misleading "GET is read-only" error.
|
||||
func TestApi_DryRunWithInputAutoPromotes_EmitsPlan(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
iostreams.IO.In = strings.NewReader(`{}`)
|
||||
root := withRootHarness(NewCmd(apiDryRunFactory(t)),
|
||||
"/api/v1/knowledge-bases", "--input", "-", "--dry-run", "--format", "json")
|
||||
require.NoError(t, root.Execute(), "--input + --dry-run must succeed (POST auto-promotion)")
|
||||
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Meta struct {
|
||||
DryRun bool `json:"dry_run"`
|
||||
Plan map[string]any `json:"plan"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(out.Bytes(), &env), "expected valid JSON envelope, got %q", out.String())
|
||||
assert.True(t, env.OK)
|
||||
assert.True(t, env.Meta.DryRun)
|
||||
assert.Equal(t, "api.post", env.Meta.Plan["action"], "plan.action must reflect auto-promoted POST")
|
||||
assert.Equal(t, "POST", env.Meta.Plan["method"])
|
||||
}
|
||||
|
||||
// TestApi_DryRunWithPost_EmitsPlan: POST + --dry-run + --input - must emit
|
||||
// the standard envelope with action=api.post, method/path echoed, and the
|
||||
// stdin body parsed as JSON under plan.body. No SDK call expected (factory
|
||||
// would panic if Client() were touched).
|
||||
func TestApi_DryRunWithPost_EmitsPlan(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
cmd := NewCmd(apiDryRunFactory(t))
|
||||
// StdinReader is on Options; set it via the recovered options binding by
|
||||
// running through the root harness with --input - and trusting iostreams.IO.In.
|
||||
// Simplest path: write to IO.In directly via SetForTest's reader swap.
|
||||
iostreams.IO.In = strings.NewReader(`{"name":"foo"}`)
|
||||
root := withRootHarness(cmd,
|
||||
"/api/v1/knowledge-bases", "-X", "POST", "--input", "-", "--dry-run", "--format", "json")
|
||||
require.NoError(t, root.Execute(), "POST + --dry-run must succeed (exit 0) without SDK")
|
||||
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Meta struct {
|
||||
DryRun bool `json:"dry_run"`
|
||||
Plan map[string]any `json:"plan"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(out.Bytes(), &env), "expected valid JSON envelope, got %q", out.String())
|
||||
assert.True(t, env.OK)
|
||||
assert.True(t, env.Meta.DryRun)
|
||||
assert.Equal(t, "api.post", env.Meta.Plan["action"], "plan.action must lowercase the method")
|
||||
assert.Equal(t, "POST", env.Meta.Plan["method"], "plan.method must be uppercase")
|
||||
assert.Equal(t, "/api/v1/knowledge-bases", env.Meta.Plan["path"])
|
||||
// Body decoded as JSON object (best-effort) for downstream agent inspection.
|
||||
body, ok := env.Meta.Plan["body"].(map[string]any)
|
||||
require.True(t, ok, "plan.body must be a JSON object when --input is valid JSON, got %T", env.Meta.Plan["body"])
|
||||
assert.Equal(t, "foo", body["name"])
|
||||
}
|
||||
|
||||
// TestApi_FieldBody_TypedDryRun: -F/--field builds a typed JSON object body —
|
||||
// true/false/null and numbers are typed, everything else stays a string — and
|
||||
// the resolved body is surfaced in the dry-run plan for agent inspection.
|
||||
func TestApi_FieldBody_TypedDryRun(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
root := withRootHarness(NewCmd(apiDryRunFactory(t)),
|
||||
"/api/v1/x", "-X", "POST",
|
||||
"-F", "name=Hello", "-F", "enabled=true", "-F", "count=3",
|
||||
"-F", "ratio=1.5", "-F", "empty=null",
|
||||
"--dry-run", "--format", "json")
|
||||
require.NoError(t, root.Execute(), "-F + --dry-run must succeed without SDK")
|
||||
|
||||
var env struct {
|
||||
Meta struct {
|
||||
Plan map[string]any `json:"plan"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(out.Bytes(), &env), "envelope: %q", out.String())
|
||||
body, ok := env.Meta.Plan["body"].(map[string]any)
|
||||
require.True(t, ok, "plan.body must be a JSON object, got %T", env.Meta.Plan["body"])
|
||||
assert.Equal(t, "Hello", body["name"], "non-numeric stays string")
|
||||
assert.Equal(t, true, body["enabled"], "true -> JSON bool")
|
||||
assert.Equal(t, float64(3), body["count"], "integer -> JSON number")
|
||||
assert.Equal(t, 1.5, body["ratio"], "float -> JSON number")
|
||||
assert.Nil(t, body["empty"], "null -> JSON null")
|
||||
}
|
||||
|
||||
// TestApi_FieldBody_AutoPromotesPost: -F on its own (no -X) auto-promotes
|
||||
// GET -> POST, matching -d/--input body behavior.
|
||||
func TestApi_FieldBody_AutoPromotesPost(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
root := withRootHarness(NewCmd(apiDryRunFactory(t)),
|
||||
"/api/v1/x", "-F", "a=b", "--dry-run", "--format", "json")
|
||||
require.NoError(t, root.Execute())
|
||||
var env struct {
|
||||
Meta struct {
|
||||
Plan map[string]any `json:"plan"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(out.Bytes(), &env))
|
||||
assert.Equal(t, "POST", env.Meta.Plan["method"], "-F must auto-promote GET -> POST")
|
||||
}
|
||||
|
||||
// TestApi_FieldBody_ConflictsWithData: -F and -d are mutually exclusive — the
|
||||
// conflict is reported as input.invalid_argument (exit 5), consistent with the
|
||||
// existing -d/--input conflict.
|
||||
func TestApi_FieldBody_ConflictsWithData(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
root := withRootHarness(NewCmd(apiDryRunFactory(t)),
|
||||
"/api/v1/x", "-X", "POST", "-F", "a=b", "-d", "{}", "--dry-run", "--format", "json")
|
||||
err := root.Execute()
|
||||
require.Error(t, err, "-F + -d must conflict")
|
||||
var ce *cmdutil.Error
|
||||
require.ErrorAs(t, err, &ce)
|
||||
assert.Equal(t, cmdutil.CodeInputInvalidArgument, ce.Code)
|
||||
assert.Equal(t, 5, cmdutil.ExitCode(err))
|
||||
}
|
||||
|
||||
// TestApi_FieldBody_Malformed: a -F value without '=' (or empty key) is a typed
|
||||
// input error, not a panic or a silently-dropped field.
|
||||
func TestApi_FieldBody_Malformed(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
root := withRootHarness(NewCmd(apiDryRunFactory(t)),
|
||||
"/api/v1/x", "-X", "POST", "-F", "nope", "--dry-run", "--format", "json")
|
||||
err := root.Execute()
|
||||
require.Error(t, err)
|
||||
var ce *cmdutil.Error
|
||||
require.ErrorAs(t, err, &ce)
|
||||
assert.Equal(t, cmdutil.CodeInputInvalidArgument, ce.Code)
|
||||
assert.Contains(t, err.Error(), "key=value")
|
||||
}
|
||||
@@ -0,0 +1,605 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
"github.com/Tencent/WeKnora/cli/internal/prompt"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// fakeAPISvc is a test double for Service that delegates each call to a
|
||||
// caller-supplied do function, giving full control over per-call responses.
|
||||
type fakeAPISvc struct {
|
||||
do func(method, path string, body any) (*http.Response, error)
|
||||
}
|
||||
|
||||
func (f *fakeAPISvc) Raw(_ context.Context, method, path string, body any) (*http.Response, error) {
|
||||
return f.do(method, path, body)
|
||||
}
|
||||
|
||||
// newTestClient stands up an httptest server with the supplied handler and
|
||||
// returns an *sdk.Client targeting it plus a teardown closure. The real SDK is
|
||||
// used so we exercise the same Raw() code path as production (header
|
||||
// injection, JSON marshalling, etc.).
|
||||
func newTestClient(t *testing.T, h http.HandlerFunc) (*sdk.Client, func()) {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(h)
|
||||
return sdk.NewClient(srv.URL), srv.Close
|
||||
}
|
||||
|
||||
func TestAPI_GetSuccess(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
cli, stop := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet || r.URL.Path != "/api/v1/foo" {
|
||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"hello":"world"}`))
|
||||
})
|
||||
defer stop()
|
||||
|
||||
if err := runAPI(context.Background(), &Options{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, cli, "GET", "/api/v1/foo", false); err != nil {
|
||||
t.Fatalf("runAPI: %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
if !strings.Contains(got, `"hello":"world"`) {
|
||||
t.Errorf("expected raw JSON body in stdout, got %q", got)
|
||||
}
|
||||
if !strings.HasSuffix(got, "\n") {
|
||||
t.Errorf("expected trailing newline appended, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPI_GetSuccess_JSON(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
cli, stop := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("X-Request-Id", "req-123")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"value":42}`))
|
||||
})
|
||||
defer stop()
|
||||
|
||||
if err := runAPI(context.Background(), &Options{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, cli, "GET", "/api/v1/foo", false); err != nil {
|
||||
t.Fatalf("runAPI: %v", err)
|
||||
}
|
||||
// The server response is passed through directly under envelope.data — no
|
||||
// {status, headers, body} wrapper — so consumers project at the server's
|
||||
// own depth (e.g. .data.value here).
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode envelope JSON: %v\n%s", err, out.String())
|
||||
}
|
||||
if !env.OK {
|
||||
t.Errorf("envelope ok: want true, got false")
|
||||
}
|
||||
if v, ok := env.Data["value"]; !ok || v.(float64) != 42 {
|
||||
t.Errorf("data.value: want 42 directly under .data (no .body nesting), got %v", env.Data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAPI_InlineDataBody verifies -d/--data sends an inline JSON body and
|
||||
// auto-promotes the method to POST.
|
||||
func TestAPI_InlineDataBody(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
var seenBody []byte
|
||||
var seenMethod string
|
||||
cli, stop := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
seenMethod = r.Method
|
||||
seenBody, _ = io.ReadAll(r.Body)
|
||||
_, _ = w.Write([]byte(`{"id":"new"}`))
|
||||
})
|
||||
defer stop()
|
||||
|
||||
opts := &Options{Data: `{"name":"foo"}`}
|
||||
if err := runAPI(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, cli, resolveMethod(opts), "/api/v1/things", false); err != nil {
|
||||
t.Fatalf("runAPI: %v", err)
|
||||
}
|
||||
if seenMethod != http.MethodPost {
|
||||
t.Errorf("inline -d should auto-promote to POST, got %s", seenMethod)
|
||||
}
|
||||
if string(seenBody) != `{"name":"foo"}` {
|
||||
t.Errorf("server received body %q, want the inline --data JSON", seenBody)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAPI_InlineDataMalformed_RejectedAsInput pins that a malformed -d body is
|
||||
// rejected as input.invalid_argument before any request.
|
||||
func TestAPI_InlineDataMalformed_RejectedAsInput(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
called := false
|
||||
cli, stop := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
called = true
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
})
|
||||
defer stop()
|
||||
|
||||
opts := &Options{Data: `{bad`}
|
||||
err := runAPI(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, cli, "POST", "/api/v1/things", false)
|
||||
var typed *cmdutil.Error
|
||||
if !errors.As(err, &typed) || typed.Code != cmdutil.CodeInputInvalidArgument {
|
||||
t.Errorf("want input.invalid_argument for malformed -d, got %v", err)
|
||||
}
|
||||
if called {
|
||||
t.Error("server must not be called with a malformed inline body")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPI_PostWithStdinInput(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
var seenBody []byte
|
||||
var seenMethod, seenPath string
|
||||
cli, stop := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
seenMethod = r.Method
|
||||
seenPath = r.URL.Path
|
||||
seenBody, _ = io.ReadAll(r.Body)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"id":"new"}`))
|
||||
})
|
||||
defer stop()
|
||||
|
||||
opts := &Options{Input: "-", StdinReader: strings.NewReader(`{"name":"foo"}`)}
|
||||
if err := runAPI(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, cli, "POST", "/api/v1/things", false); err != nil {
|
||||
t.Fatalf("runAPI: %v", err)
|
||||
}
|
||||
if seenMethod != http.MethodPost || seenPath != "/api/v1/things" {
|
||||
t.Errorf("server saw %s %s, want POST /api/v1/things", seenMethod, seenPath)
|
||||
}
|
||||
if string(seenBody) != `{"name":"foo"}` {
|
||||
t.Errorf("server received body %q, want %q", seenBody, `{"name":"foo"}`)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAPI_MalformedInputJSON_RejectedAsInput pins that a non-JSON --input body
|
||||
// is rejected with input.invalid_argument (exit 5) at the boundary, not the
|
||||
// confusing network.error (exit 7, "retryable") the SDK's marshal step
|
||||
// produced. The server must never be called.
|
||||
func TestAPI_MalformedInputJSON_RejectedAsInput(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
called := false
|
||||
cli, stop := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
called = true
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
})
|
||||
defer stop()
|
||||
|
||||
opts := &Options{Input: "-", StdinReader: strings.NewReader(`{bad json`)}
|
||||
err := runAPI(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, cli, "POST", "/api/v1/things", false)
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for malformed --input JSON")
|
||||
}
|
||||
var typed *cmdutil.Error
|
||||
if !errors.As(err, &typed) || typed.Code != cmdutil.CodeInputInvalidArgument {
|
||||
t.Errorf("want input.invalid_argument, got %v", err)
|
||||
}
|
||||
if called {
|
||||
t.Error("server must not be called with a malformed body")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAPI_InputFile verifies --input <file> reads the request body from disk.
|
||||
func TestAPI_InputFile(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
tmp := filepath.Join(t.TempDir(), "body.json")
|
||||
payload := `{"k":"from-file"}`
|
||||
if err := os.WriteFile(tmp, []byte(payload), 0o600); err != nil {
|
||||
t.Fatalf("write temp: %v", err)
|
||||
}
|
||||
var seenBody []byte
|
||||
cli, stop := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
seenBody, _ = io.ReadAll(r.Body)
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
})
|
||||
defer stop()
|
||||
|
||||
opts := &Options{Input: tmp}
|
||||
if err := runAPI(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, cli, "POST", "/api/v1/x", false); err != nil {
|
||||
t.Fatalf("runAPI: %v", err)
|
||||
}
|
||||
if string(seenBody) != payload {
|
||||
t.Errorf("body from --input: got %q, want %q", seenBody, payload)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAPI_InputDash_Stdin verifies the "--input -" form: the payload comes
|
||||
// from opts.StdinReader (production-default iostreams.IO.In).
|
||||
func TestAPI_InputDash_Stdin(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
var seenBody []byte
|
||||
cli, stop := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
seenBody, _ = io.ReadAll(r.Body)
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
})
|
||||
defer stop()
|
||||
|
||||
payload := `{"k":"from-stdin"}`
|
||||
opts := &Options{Input: "-", StdinReader: strings.NewReader(payload)}
|
||||
if err := runAPI(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, cli, "POST", "/api/v1/x", false); err != nil {
|
||||
t.Fatalf("runAPI: %v", err)
|
||||
}
|
||||
if string(seenBody) != payload {
|
||||
t.Errorf("body from --input -: got %q, want %q", seenBody, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPI_NotFound(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
cli, stop := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"error":"missing"}`))
|
||||
})
|
||||
defer stop()
|
||||
|
||||
err := runAPI(context.Background(), &Options{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, cli, "GET", "/api/v1/missing", false)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 404")
|
||||
}
|
||||
if !cmdutil.IsNotFound(err) {
|
||||
t.Errorf("expected resource.not_found, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPI_AcceptsArbitraryMethod(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
var seenMethod string
|
||||
cli, stop := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
seenMethod = r.Method
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
defer stop()
|
||||
for _, m := range []string{"OPTIONS", "PATCH", "TRACE", "CUSTOM"} {
|
||||
t.Run(m, func(t *testing.T) {
|
||||
seenMethod = ""
|
||||
err := runAPI(context.Background(), &Options{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, cli, m, "/api/v1/things", false)
|
||||
if err != nil {
|
||||
t.Fatalf("expected method %q to be accepted, got %v", m, err)
|
||||
}
|
||||
if seenMethod != m {
|
||||
t.Errorf("server saw method %q, want %q", seenMethod, m)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPI_EmptyMethodRejected(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
err := runAPI(context.Background(), &Options{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, nil, "", "/api/v1/things", false)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty method")
|
||||
}
|
||||
var fe *cmdutil.FlagError
|
||||
if !errors.As(err, &fe) {
|
||||
t.Errorf("expected FlagError, got %T %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPI_PathWithoutSlash(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
err := runAPI(context.Background(), &Options{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, nil, "GET", "api/v1/things", false)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing leading slash")
|
||||
}
|
||||
var ce *cmdutil.Error
|
||||
if !asTypedError(err, &ce) || ce.Code != cmdutil.CodeInputInvalidArgument {
|
||||
t.Errorf("expected input.invalid_argument, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// withRootHarness wraps `weknora api ...` under a synthetic root cmd that
|
||||
// registers the global persistent flags (mirrors addGlobalFlags in
|
||||
// cmd/root.go). Required because api's NewCmd doesn't register --yes /
|
||||
// --format / --jq itself — it inherits them from root in production.
|
||||
func withRootHarness(api *cobra.Command, args ...string) *cobra.Command {
|
||||
root := &cobra.Command{Use: "weknora"}
|
||||
pf := root.PersistentFlags()
|
||||
pf.BoolP("yes", "y", false, "")
|
||||
pf.String("format", "", "")
|
||||
pf.StringP("jq", "q", "", "")
|
||||
root.AddCommand(api)
|
||||
root.SetArgs(append([]string{"api"}, args...))
|
||||
root.SetContext(context.Background())
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
return root
|
||||
}
|
||||
|
||||
// TestAPI_DELETE_RequiresConfirmation pins the exit-10 protocol on the
|
||||
// escape-hatch DELETE path: agent invokes `weknora api DELETE /...` without
|
||||
// -y/--yes, must get input.confirmation_required + exit 10. Confirmation is
|
||||
// enforced in NewCmd.RunE (not runAPI), so the test drives the cobra cmd.
|
||||
func TestAPI_DELETE_RequiresConfirmation(t *testing.T) {
|
||||
iostreams.SetForTest(t) // non-TTY
|
||||
f := &cmdutil.Factory{
|
||||
Client: func() (*sdk.Client, error) { return nil, nil },
|
||||
Prompter: func() prompt.Prompter { return prompt.AgentPrompter{} },
|
||||
}
|
||||
root := withRootHarness(NewCmd(f), "/api/v1/knowledge-bases/kb_xxx", "-X", "DELETE")
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected confirmation_required error for DELETE without -y")
|
||||
}
|
||||
var ce *cmdutil.Error
|
||||
if !asTypedError(err, &ce) || ce.Code != cmdutil.CodeInputConfirmationRequired {
|
||||
t.Errorf("want input.confirmation_required, got %v", err)
|
||||
}
|
||||
if got := cmdutil.ExitCode(err); got != 10 {
|
||||
t.Errorf("exit code = %d, want 10", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAPI_DELETE_WithYes_Proceeds: -y/--yes opt-in skips confirmation and
|
||||
// dispatches to the SDK. Server returns 200 to verify the happy-path lands
|
||||
// on the response body emit.
|
||||
func TestAPI_DELETE_WithYes_Proceeds(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
called := false
|
||||
cli, stop := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
t.Errorf("expected DELETE, got %s", r.Method)
|
||||
}
|
||||
called = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
defer stop()
|
||||
f := &cmdutil.Factory{
|
||||
Client: func() (*sdk.Client, error) { return cli, nil },
|
||||
Prompter: func() prompt.Prompter { return prompt.AgentPrompter{} },
|
||||
}
|
||||
root := withRootHarness(NewCmd(f), "/api/v1/knowledge-bases/kb_xxx", "-X", "DELETE", "-y")
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if !called {
|
||||
t.Error("DELETE handler not called - confirmation may have blocked")
|
||||
}
|
||||
}
|
||||
|
||||
// asTypedError is a tiny wrapper around errors.As that keeps the call sites
|
||||
// concise. Returns true on success, populating dst.
|
||||
func asTypedError(err error, dst **cmdutil.Error) bool {
|
||||
for e := err; e != nil; {
|
||||
if t, ok := e.(*cmdutil.Error); ok {
|
||||
*dst = t
|
||||
return true
|
||||
}
|
||||
type unwrapper interface{ Unwrap() error }
|
||||
u, ok := e.(unwrapper)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
e = u.Unwrap()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestAPI_PaginateMergesPages(t *testing.T) {
|
||||
pages := [][]byte{
|
||||
[]byte(`{"success":true,"data":[{"id":"1"},{"id":"2"}],"total":5,"page":1,"page_size":2}`),
|
||||
[]byte(`{"success":true,"data":[{"id":"3"},{"id":"4"}],"total":5,"page":2,"page_size":2}`),
|
||||
[]byte(`{"success":true,"data":[{"id":"5"}],"total":5,"page":3,"page_size":2}`),
|
||||
}
|
||||
idx := 0
|
||||
svc := &fakeAPISvc{do: func(method, path string, _ any) (*http.Response, error) {
|
||||
if idx >= len(pages) {
|
||||
return nil, fmt.Errorf("too many calls; idx=%d", idx)
|
||||
}
|
||||
body := pages[idx]
|
||||
idx++
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewReader(body)),
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
}}
|
||||
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
|
||||
opts := &Options{}
|
||||
if err := runAPI(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "GET", "/api/v1/knowledge-base?page=1&page_size=2", true); err != nil {
|
||||
t.Fatalf("runAPI: %v", err)
|
||||
}
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data struct {
|
||||
Data []map[string]string `json:"data"`
|
||||
Total int `json:"total"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("unmarshal: %v\n%s", err, out.String())
|
||||
}
|
||||
got := env.Data
|
||||
if len(got.Data) != 5 || got.Total != 5 {
|
||||
t.Errorf("got %d records (total %d), want 5/5", len(got.Data), got.Total)
|
||||
}
|
||||
if idx != 3 {
|
||||
t.Errorf("called %d times, want 3", idx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPI_PaginateIgnoredForPOST(t *testing.T) {
|
||||
// --paginate should be a no-op for non-GET methods (no pagination
|
||||
// semantic for POST/PUT/DELETE). Single call expected.
|
||||
called := 0
|
||||
svc := &fakeAPISvc{do: func(method, path string, _ any) (*http.Response, error) {
|
||||
called++
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewReader([]byte(`{"success":true,"data":[],"total":5,"page":1,"page_size":2}`))),
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
}}
|
||||
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
|
||||
opts := &Options{Input: "-", StdinReader: strings.NewReader(`{"name":"foo"}`)}
|
||||
if err := runAPI(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "POST", "/api/v1/knowledge-base", true); err != nil {
|
||||
t.Fatalf("runAPI: %v", err)
|
||||
}
|
||||
if called != 1 {
|
||||
t.Errorf("called %d times, want 1 (POST should not paginate)", called)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPI_PaginateNoMetadataPassesThrough(t *testing.T) {
|
||||
// If response doesn't look paginated (no total/page/page_size), --paginate
|
||||
// should fall back to single-call envelope behavior (same shape as api without --paginate).
|
||||
called := 0
|
||||
svc := &fakeAPISvc{do: func(method, path string, _ any) (*http.Response, error) {
|
||||
called++
|
||||
hdr := make(http.Header)
|
||||
hdr.Set("Content-Type", "application/json")
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewReader([]byte(`{"hello":"world"}`))),
|
||||
Header: hdr,
|
||||
}, nil
|
||||
}}
|
||||
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
|
||||
opts := &Options{}
|
||||
if err := runAPI(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "GET", "/api/v1/whoami", true); err != nil {
|
||||
t.Fatalf("runAPI: %v", err)
|
||||
}
|
||||
if called != 1 {
|
||||
t.Errorf("called %d times, want 1 (non-paginated response)", called)
|
||||
}
|
||||
// The non-paginated fallback must emit the SAME flat shape as a single
|
||||
// call: the server response directly under .data (no {status,headers,body}
|
||||
// wrapper), so --paginate and single-call project identically.
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("non-paginated fallback must emit envelope JSON, unmarshal failed: %v\n%s", err, out.String())
|
||||
}
|
||||
if !env.OK {
|
||||
t.Errorf("envelope ok: want true, got false")
|
||||
}
|
||||
if v, ok := env.Data["hello"]; !ok || v.(string) != "world" {
|
||||
t.Errorf("fallback must pass the body through flat under .data (.data.hello), got %v", env.Data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAPI_PaginateServerCapsPageSize covers the case where the user
|
||||
// requests --page_size=50 but the server caps page_size at a smaller
|
||||
// value (e.g. 2). Termination must count actually-collected records
|
||||
// (len(allData)) not requested-page-count (page*pageSize) — otherwise
|
||||
// we'd break early and silently truncate results.
|
||||
func TestAPI_PaginateServerCapsPageSize(t *testing.T) {
|
||||
// User asks page_size=10; server only ever returns 2 per page (cap).
|
||||
// Total = 5 records; should make 3 calls (2+2+1) and return all 5.
|
||||
pages := [][]byte{
|
||||
[]byte(`{"success":true,"data":[{"id":"1"},{"id":"2"}],"total":5,"page":1,"page_size":2}`),
|
||||
[]byte(`{"success":true,"data":[{"id":"3"},{"id":"4"}],"total":5,"page":2,"page_size":2}`),
|
||||
[]byte(`{"success":true,"data":[{"id":"5"}],"total":5,"page":3,"page_size":2}`),
|
||||
}
|
||||
idx := 0
|
||||
svc := &fakeAPISvc{do: func(_, _ string, _ any) (*http.Response, error) {
|
||||
if idx >= len(pages) {
|
||||
return nil, fmt.Errorf("too many calls; idx=%d", idx)
|
||||
}
|
||||
body := pages[idx]
|
||||
idx++
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewReader(body)),
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
}}
|
||||
var stdout bytes.Buffer
|
||||
iostreams.IO.Out = &stdout
|
||||
defer func() { iostreams.IO.Out = os.Stdout }()
|
||||
|
||||
opts := &Options{}
|
||||
// User requests page_size=10; server caps at 2 each response.
|
||||
if err := runAPI(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "GET", "/api/v1/items?page=1&page_size=10", true); err != nil {
|
||||
t.Fatalf("runAPI: %v", err)
|
||||
}
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data struct {
|
||||
Data []map[string]string `json:"data"`
|
||||
Total int `json:"total"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("unmarshal: %v\n%s", err, stdout.String())
|
||||
}
|
||||
got := env.Data
|
||||
if len(got.Data) != 5 {
|
||||
t.Errorf("got %d records, want 5 (server-capped page_size should not cause truncation)", len(got.Data))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAPI_PUT_RequiresConfirmation pins the exit-10 write gate on the
|
||||
// escape-hatch PUT path: `weknora api -X PUT /...` mutates server state the
|
||||
// same way a typed `kb update` does, so it must require -y (exit 10) rather
|
||||
// than silently writing. Regression: only DELETE was gated, letting an agent
|
||||
// bypass the write-confirmation protocol via raw PUT/PATCH.
|
||||
func TestAPI_PUT_RequiresConfirmation(t *testing.T) {
|
||||
for _, method := range []string{"PUT", "PATCH"} {
|
||||
t.Run(method, func(t *testing.T) {
|
||||
iostreams.SetForTest(t) // non-TTY
|
||||
f := &cmdutil.Factory{
|
||||
Client: func() (*sdk.Client, error) { return nil, nil },
|
||||
Prompter: func() prompt.Prompter { return prompt.AgentPrompter{} },
|
||||
}
|
||||
root := withRootHarness(NewCmd(f), "/api/v1/knowledge-bases/kb_xxx", "-X", method, "-F", "name=x")
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("expected confirmation_required for %s without -y", method)
|
||||
}
|
||||
var ce *cmdutil.Error
|
||||
if !asTypedError(err, &ce) || ce.Code != cmdutil.CodeInputConfirmationRequired {
|
||||
t.Errorf("want input.confirmation_required, got %v", err)
|
||||
}
|
||||
if got := cmdutil.ExitCode(err); got != 10 {
|
||||
t.Errorf("exit code = %d, want 10", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAPI_POST_NotGated: POST is create-shaped and, like typed `kb create`,
|
||||
// intentionally ungated — it must reach the SDK without a confirmation gate.
|
||||
func TestAPI_POST_NotGated(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
called := false
|
||||
cli, stop := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
called = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
defer stop()
|
||||
f := &cmdutil.Factory{
|
||||
Client: func() (*sdk.Client, error) { return cli, nil },
|
||||
Prompter: func() prompt.Prompter { return prompt.AgentPrompter{} },
|
||||
}
|
||||
root := withRootHarness(NewCmd(f), "/api/v1/knowledge-bases", "-X", "POST", "-F", "name=x")
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if !called {
|
||||
t.Error("POST handler not called - POST must not be gated")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Package auth holds the cobra commands for authentication
|
||||
// (login / logout / list / refresh / status / token).
|
||||
package auth
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
// Credential-mode tokens used in the JSON output of auth list / login /
|
||||
// status / token. The string names describe the HTTP credential type rather
|
||||
// than the login flow (e.g. JWT → bearer regardless of whether it was
|
||||
// obtained via password or refresh) so an agent can branch directly on the
|
||||
// header to construct: `Authorization: Bearer <token>` (ModeBearer) or
|
||||
// `X-API-Key: <token>` (ModeAPIKey).
|
||||
const (
|
||||
ModeBearer = "bearer"
|
||||
ModeAPIKey = "api-key"
|
||||
ModeUnknown = "unknown"
|
||||
)
|
||||
|
||||
// modeFromRefs maps the per-profile TokenRef / APIKeyRef presence to a
|
||||
// canonical credential-mode token. Bearer wins when both are present -
|
||||
// matches the precedence in cmdutil.buildClient.
|
||||
func modeFromRefs(apiKeyRef, tokenRef string) string {
|
||||
switch {
|
||||
case tokenRef != "":
|
||||
return ModeBearer
|
||||
case apiKeyRef != "":
|
||||
return ModeAPIKey
|
||||
default:
|
||||
return ModeUnknown
|
||||
}
|
||||
}
|
||||
|
||||
// NewCmdAuth builds the `weknora auth` command tree and registers its
|
||||
// subcommands. Called from cli/cmd/root.go.
|
||||
func NewCmdAuth(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "auth",
|
||||
Short: "Manage authentication credentials and profiles",
|
||||
}
|
||||
cmd.AddCommand(NewCmdLogin(f, nil))
|
||||
cmd.AddCommand(NewCmdLogout(f))
|
||||
cmd.AddCommand(NewCmdList(f))
|
||||
cmd.AddCommand(NewCmdRefresh(f))
|
||||
cmd.AddCommand(NewCmdStatus(f))
|
||||
cmd.AddCommand(NewCmdToken(f))
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/config"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
"github.com/Tencent/WeKnora/cli/internal/prompt"
|
||||
"github.com/Tencent/WeKnora/cli/internal/secrets"
|
||||
"github.com/Tencent/WeKnora/cli/internal/testutil"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
func TestNewCmdAuth_TreeShape(t *testing.T) {
|
||||
cmd := NewCmdAuth(&cmdutil.Factory{})
|
||||
assert.Equal(t, "auth", cmd.Use)
|
||||
subs := map[string]*cobra.Command{}
|
||||
for _, c := range cmd.Commands() {
|
||||
subs[c.Use] = c
|
||||
}
|
||||
assert.Contains(t, subs, "login")
|
||||
assert.Contains(t, subs, "status")
|
||||
}
|
||||
|
||||
func TestNewCmdLogin_FlagsRegistered(t *testing.T) {
|
||||
cmd := NewCmdLogin(&cmdutil.Factory{}, nil)
|
||||
// --format is a persistent root flag (v0.7); only per-command flags here.
|
||||
assert.NotNil(t, cmd.Flags().Lookup("with-token"), "flag with-token missing")
|
||||
// login authenticates the active profile; host/name come from config
|
||||
// (created via `profile add`), so these flags are gone.
|
||||
assert.Nil(t, cmd.Flags().Lookup("host"), "auth login must not declare --host (v0.9)")
|
||||
assert.Nil(t, cmd.Flags().Lookup("name"), "auth login must not declare --name (v0.9)")
|
||||
// --profile is the global persistent override; local registration would
|
||||
// silently shadow it.
|
||||
assert.Nil(t, cmd.Flags().Lookup("profile"), "auth login must not declare a local --profile flag (use the global --profile)")
|
||||
}
|
||||
|
||||
func TestNewCmdLogin_InvokesRunF(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
called := false
|
||||
store := secrets.NewMemStore()
|
||||
f := &cmdutil.Factory{
|
||||
Secrets: func() (secrets.Store, error) { return store, nil },
|
||||
}
|
||||
cmd := NewCmdLogin(f, func(_ context.Context, opts *LoginOptions, _ *cmdutil.FormatOptions, _ *cmdutil.Factory, _ LoginService) error {
|
||||
called = true
|
||||
// host/profile resolve inside runLogin from the active profile;
|
||||
// the runF seam just receives the parsed flags.
|
||||
assert.True(t, opts.WithToken)
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"--with-token"})
|
||||
require.NoError(t, cmd.Execute())
|
||||
assert.True(t, called)
|
||||
}
|
||||
|
||||
func TestLoginServiceFor(t *testing.T) {
|
||||
assert.Nil(t, loginServiceFor(""))
|
||||
assert.NotNil(t, loginServiceFor("https://x"))
|
||||
}
|
||||
|
||||
func TestPersistAPIKey_WritesContext(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
testutil.XDGTempDir(t)
|
||||
store := secrets.NewMemStore()
|
||||
f := &cmdutil.Factory{
|
||||
Config: func() (*config.Config, error) { return config.Load() },
|
||||
Prompter: func() prompt.Prompter { return prompt.AgentPrompter{} },
|
||||
Secrets: func() (secrets.Store, error) { return store, nil },
|
||||
}
|
||||
// persistAPIKey MERGES into the existing active profile record (created
|
||||
// via `profile add`) rather than creating it; seed it first.
|
||||
require.NoError(t, config.Save(&config.Config{
|
||||
CurrentProfile: "ci",
|
||||
Profiles: map[string]config.Profile{"ci": {Host: "https://kb.example.com"}},
|
||||
}))
|
||||
opts := &LoginOptions{
|
||||
Host: "https://kb.example.com",
|
||||
Profile: "ci",
|
||||
APIKey: "sk-zzz",
|
||||
}
|
||||
require.NoError(t, persistAPIKey(opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, nil))
|
||||
v, _ := store.Get("ci", "api_key")
|
||||
assert.Equal(t, "sk-zzz", v)
|
||||
cfg, _ := f.Config()
|
||||
assert.Equal(t, "ci", cfg.CurrentProfile, "active profile unchanged by login")
|
||||
assert.Equal(t, "https://kb.example.com", cfg.Profiles["ci"].Host, "existing host preserved by merge")
|
||||
// APIKeyRef should be the mem:// URI from the store's Ref method.
|
||||
assert.Equal(t, "mem://ci/api_key", cfg.Profiles["ci"].APIKeyRef)
|
||||
}
|
||||
|
||||
func TestPersistJWT_StoresBothTokens(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
testutil.XDGTempDir(t)
|
||||
store := secrets.NewMemStore()
|
||||
f := &cmdutil.Factory{
|
||||
Config: func() (*config.Config, error) { return config.Load() },
|
||||
Prompter: func() prompt.Prompter { return prompt.AgentPrompter{} },
|
||||
Secrets: func() (secrets.Store, error) { return store, nil },
|
||||
}
|
||||
opts := &LoginOptions{
|
||||
Host: "https://x",
|
||||
Profile: "p",
|
||||
}
|
||||
resp := &sdk.LoginResponse{
|
||||
Token: "jwt-acc",
|
||||
RefreshToken: "jwt-ref",
|
||||
User: &sdk.AuthUser{Email: "a@b.c", TenantID: 7},
|
||||
}
|
||||
require.NoError(t, persistJWT(opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, f, resp))
|
||||
a, _ := store.Get("p", "access")
|
||||
r, _ := store.Get("p", "refresh")
|
||||
assert.Equal(t, "jwt-acc", a)
|
||||
assert.Equal(t, "jwt-ref", r)
|
||||
}
|
||||
|
||||
func TestReadStdinTrimmed(t *testing.T) {
|
||||
out, err := readStdinTrimmed(strings.NewReader(" hello \n"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "hello", out)
|
||||
|
||||
out, err = readStdinTrimmed(nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "", out)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// Package auth — dryrun_validation_test.go asserts that --dry-run on
|
||||
// auth subcommands rejects identically to the live path. Before the
|
||||
// surrounding fix, validation lived in runX() and was reached only after
|
||||
// HandleDryRun short-circuited, so --dry-run accepted invocations the live
|
||||
// path would reject.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/config"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
"github.com/Tencent/WeKnora/cli/internal/prompt"
|
||||
"github.com/Tencent/WeKnora/cli/internal/secrets"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// authDryRunFactory builds a Factory whose Client closure panics if invoked —
|
||||
// dry-run must early-exit before any SDK call. Config is supplied via the cfg
|
||||
// argument so each test gets isolated state.
|
||||
func authDryRunFactory(t *testing.T, cfg *config.Config) *cmdutil.Factory {
|
||||
t.Helper()
|
||||
return &cmdutil.Factory{
|
||||
Config: func() (*config.Config, error) { return cfg, nil },
|
||||
Client: func() (*sdk.Client, error) {
|
||||
t.Fatal("dry-run path must not call Factory.Client(); SDK side effect leaked")
|
||||
return nil, nil
|
||||
},
|
||||
Prompter: func() prompt.Prompter {
|
||||
t.Fatal("dry-run path must not call Factory.Prompter(); confirm-prompt side effect leaked")
|
||||
return nil
|
||||
},
|
||||
Secrets: func() (secrets.Store, error) { return secrets.NewMemStore(), nil },
|
||||
}
|
||||
}
|
||||
|
||||
// withRootHarnessAuth wraps an auth subcommand under a synthetic root cmd
|
||||
// that registers the global persistent flags.
|
||||
func withRootHarnessAuth(sub *cobra.Command, args ...string) *cobra.Command {
|
||||
root := &cobra.Command{Use: "weknora"}
|
||||
pf := root.PersistentFlags()
|
||||
pf.BoolP("yes", "y", false, "")
|
||||
pf.String("format", "", "")
|
||||
pf.StringP("jq", "q", "", "")
|
||||
root.AddCommand(sub)
|
||||
root.SetArgs(append([]string{sub.Name()}, args...))
|
||||
root.SetContext(context.Background())
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
return root
|
||||
}
|
||||
|
||||
// TestAuthLogout_DryRun_RejectsNoProfiles: empty config → live path returns
|
||||
// auth.unauthenticated; --dry-run must do the same.
|
||||
func TestAuthLogout_DryRun_RejectsNoProfiles(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
cfg := &config.Config{}
|
||||
root := withRootHarnessAuth(NewCmdLogout(authDryRunFactory(t, cfg)),
|
||||
"--dry-run", "--format", "json")
|
||||
err := root.Execute()
|
||||
require.Error(t, err, "dry-run must reject empty config")
|
||||
var typed *cmdutil.Error
|
||||
require.True(t, errors.As(err, &typed), "expected *cmdutil.Error, got %T %v", err, err)
|
||||
assert.Equal(t, cmdutil.CodeAuthUnauthenticated, typed.Code)
|
||||
}
|
||||
|
||||
// TestAuthLogout_DryRun_RejectsMissingActiveProfile: the active profile name
|
||||
// (e.g. set via the global --profile) has no config entry → live path returns
|
||||
// local.profile_not_found; --dry-run must do the same.
|
||||
func TestAuthLogout_DryRun_RejectsMissingActiveProfile(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "ghost", // active profile with no matching entry
|
||||
Profiles: map[string]config.Profile{"prod": {Host: "https://prod"}},
|
||||
}
|
||||
root := withRootHarnessAuth(NewCmdLogout(authDryRunFactory(t, cfg)),
|
||||
"--dry-run", "--format", "json")
|
||||
err := root.Execute()
|
||||
require.Error(t, err, "dry-run must reject a missing active profile")
|
||||
var typed *cmdutil.Error
|
||||
require.True(t, errors.As(err, &typed), "expected *cmdutil.Error, got %T %v", err, err)
|
||||
assert.Equal(t, cmdutil.CodeLocalProfileNotFound, typed.Code)
|
||||
}
|
||||
|
||||
// TestAuthRefresh_DryRun_RejectsNoCurrentProfile: no current profile → live
|
||||
// path returns auth.unauthenticated; --dry-run must do the same.
|
||||
func TestAuthRefresh_DryRun_RejectsNoCurrentProfile(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
cfg := &config.Config{}
|
||||
root := withRootHarnessAuth(NewCmdRefresh(authDryRunFactory(t, cfg)),
|
||||
"--dry-run", "--format", "json")
|
||||
err := root.Execute()
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.True(t, errors.As(err, &typed))
|
||||
assert.Equal(t, cmdutil.CodeAuthUnauthenticated, typed.Code)
|
||||
}
|
||||
|
||||
// TestAuthRefresh_DryRun_RejectsMissingRefreshToken: API-key-only profile →
|
||||
// live path returns input.invalid_argument ("no refresh token"); --dry-run
|
||||
// must do the same.
|
||||
func TestAuthRefresh_DryRun_RejectsMissingRefreshToken(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "prod",
|
||||
Profiles: map[string]config.Profile{
|
||||
"prod": {Host: "https://prod", APIKeyRef: "k"},
|
||||
},
|
||||
}
|
||||
root := withRootHarnessAuth(NewCmdRefresh(authDryRunFactory(t, cfg)),
|
||||
"--dry-run", "--format", "json")
|
||||
err := root.Execute()
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.True(t, errors.As(err, &typed))
|
||||
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/format"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
"github.com/Tencent/WeKnora/cli/internal/output"
|
||||
)
|
||||
|
||||
type ListOptions struct{}
|
||||
|
||||
// authListFields enumerates the fields surfaced for `--format json` discovery on
|
||||
// `auth list`. Each entry is a per-profile summary row.
|
||||
var authListFields = []string{
|
||||
"name", "host", "user", "mode", "current",
|
||||
}
|
||||
|
||||
type listEntry struct {
|
||||
Name string `json:"name"`
|
||||
Host string `json:"host"`
|
||||
User string `json:"user,omitempty"`
|
||||
Mode string `json:"mode"` // ModeBearer / ModeAPIKey / ModeUnknown
|
||||
Current bool `json:"current"`
|
||||
}
|
||||
|
||||
// NewCmdList builds `weknora auth list`. Per-host enumeration: render one
|
||||
// row per registered profile, marking the active one. Reads only
|
||||
// ~/.config/weknora/config.yaml - no network, no keyring touch.
|
||||
func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List configured authentication profiles",
|
||||
Long: `Show every configured profile (name, host, user, mode, current). Read-only; no network or keyring access.`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(c *cobra.Command, _ []string) error {
|
||||
fopts, err := cmdutil.CheckFormatFlag(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
|
||||
return runList(fopts, f)
|
||||
},
|
||||
}
|
||||
cmdutil.AddFormatFlag(cmd, authListFields...)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "list configured profiles and their credential mode (bearer / api-key)",
|
||||
Examples: []string{"weknora auth list", "weknora auth list --jq '.data[].name'"},
|
||||
Output: "envelope.data is an array of profiles with name, host, mode, and which is active",
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runList(fopts *cmdutil.FormatOptions, f *cmdutil.Factory) error {
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entries := make([]listEntry, 0, len(cfg.Profiles))
|
||||
for name, c := range cfg.Profiles {
|
||||
entries = append(entries, listEntry{
|
||||
Name: name,
|
||||
Host: c.Host,
|
||||
User: c.User,
|
||||
Mode: modeFromRefs(c.APIKeyRef, c.TokenRef),
|
||||
Current: name == cfg.CurrentProfile,
|
||||
})
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool { return entries[i].Name < entries[j].Name })
|
||||
|
||||
if fopts.WantsJSON() {
|
||||
meta := &output.Meta{Count: output.IntPtr(len(entries))}
|
||||
return fopts.Emit(iostreams.IO.Out, entries, meta)
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
fmt.Fprintln(iostreams.IO.Out, "No profiles configured. Run `weknora auth login` to create one.")
|
||||
return nil
|
||||
}
|
||||
tw := tabwriter.NewWriter(iostreams.IO.Out, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, " NAME\tHOST\tUSER\tMODE")
|
||||
for _, e := range entries {
|
||||
marker := " "
|
||||
if e.Current {
|
||||
marker = "* "
|
||||
}
|
||||
fmt.Fprintf(tw, "%s%s\t%s\t%s\t%s\n", marker, e.Name, e.Host, format.DashIfEmpty(e.User), e.Mode)
|
||||
}
|
||||
return tw.Flush()
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/config"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
)
|
||||
|
||||
func newListFactory(cfg *config.Config) *cmdutil.Factory {
|
||||
return &cmdutil.Factory{
|
||||
Config: func() (*config.Config, error) { return cfg, nil },
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_TextRender(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "prod",
|
||||
Profiles: map[string]config.Profile{
|
||||
"prod": {Host: "https://prod", User: "alice@example.com", TokenRef: "keychain://prod/access"},
|
||||
"staging": {Host: "https://staging", APIKeyRef: "keychain://staging/api_key"},
|
||||
},
|
||||
}
|
||||
require.NoError(t, runList(&cmdutil.FormatOptions{Mode: cmdutil.FormatText}, newListFactory(cfg)))
|
||||
|
||||
got := out.String()
|
||||
// One row per profile, current marked with `*`.
|
||||
assert.Contains(t, got, "* prod")
|
||||
assert.Contains(t, got, " staging")
|
||||
// Mode column.
|
||||
assert.Contains(t, got, ModeBearer)
|
||||
assert.Contains(t, got, ModeAPIKey)
|
||||
// Sorted alphabetically - prod after staging? No: "prod" < "staging".
|
||||
assert.Less(t, strings.Index(got, "prod"), strings.Index(got, "staging"),
|
||||
"profiles should render sorted by name")
|
||||
}
|
||||
|
||||
func TestList_Empty(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
require.NoError(t, runList(&cmdutil.FormatOptions{Mode: cmdutil.FormatText}, newListFactory(&config.Config{})))
|
||||
assert.Contains(t, out.String(), "No profiles configured")
|
||||
}
|
||||
|
||||
func TestList_JSON_BareArray(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "prod",
|
||||
Profiles: map[string]config.Profile{
|
||||
"prod": {Host: "https://prod", User: "alice", TokenRef: "tok"},
|
||||
"staging": {Host: "https://staging", APIKeyRef: "key"},
|
||||
},
|
||||
}
|
||||
require.NoError(t, runList(&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, newListFactory(cfg)))
|
||||
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data []listEntry `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(out.Bytes(), &env))
|
||||
assert.True(t, env.OK)
|
||||
got := env.Data
|
||||
require.Len(t, got, 2)
|
||||
// Sorted: prod < staging.
|
||||
assert.Equal(t, "prod", got[0].Name)
|
||||
assert.True(t, got[0].Current)
|
||||
assert.Equal(t, ModeBearer, got[0].Mode)
|
||||
assert.Equal(t, "staging", got[1].Name)
|
||||
assert.False(t, got[1].Current)
|
||||
assert.Equal(t, ModeAPIKey, got[1].Mode)
|
||||
}
|
||||
|
||||
func TestModeFromRefs(t *testing.T) {
|
||||
// Hand-edited config with neither ref set - surface "unknown" rather
|
||||
// than pretending the profile is a valid login.
|
||||
assert.Equal(t, ModeUnknown, modeFromRefs("", ""))
|
||||
assert.Equal(t, ModeBearer, modeFromRefs("", "tok"))
|
||||
assert.Equal(t, ModeAPIKey, modeFromRefs("key", ""))
|
||||
assert.Equal(t, ModeBearer, modeFromRefs("key", "tok"), "JWT wins when both set")
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/config"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
"github.com/Tencent/WeKnora/cli/internal/secrets"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// authLoginFields enumerates the fields surfaced for `--format json` discovery on
|
||||
// `auth login`. The post-login summary has no token values - they stay in the
|
||||
// keyring; agents who need to verify the credential should re-run
|
||||
// `auth status`.
|
||||
var authLoginFields = []string{
|
||||
"profile", "host", "mode", "email", "tenant_id",
|
||||
}
|
||||
|
||||
// LoginOptions is the configuration captured from flags + prompts. Host and
|
||||
// Profile are resolved from the active profile in config (not from flags) -
|
||||
// `auth login` authenticates the already-existing active profile, created
|
||||
// beforehand with `weknora profile add <name> --host <h> --use`.
|
||||
type LoginOptions struct {
|
||||
Host string // resolved from the active profile's Host in config
|
||||
Profile string // resolved active profile name (honors global --profile)
|
||||
WithToken bool // --with-token: read api key from stdin instead of prompting password
|
||||
APIKey string // populated by --with-token from stdin
|
||||
Email string
|
||||
Password string
|
||||
StdinReader io.Reader // override for tests
|
||||
}
|
||||
|
||||
// LoginService is the narrow SDK surface auth login depends on.
|
||||
// *sdk.Client satisfies it implicitly via the new Login(ctx, LoginRequest)
|
||||
// signature added in client/auth.go.
|
||||
type LoginService interface {
|
||||
Login(ctx context.Context, req sdk.LoginRequest) (*sdk.LoginResponse, error)
|
||||
}
|
||||
|
||||
// apiKeyValidator probes /auth/me with the supplied API key so a bad key
|
||||
// fails fast at `auth login --with-token` time rather than on the next
|
||||
// authenticated call.
|
||||
//
|
||||
// Returns the resolved user (used to populate Profile.User / TenantID at
|
||||
// rest, so later `auth list` reflects who owns the key).
|
||||
type apiKeyValidator func(ctx context.Context, host, apiKey string) (*sdk.AuthUser, error)
|
||||
|
||||
// defaultAPIKeyValidator builds a one-shot SDK client with the supplied key
|
||||
// and calls /auth/me. Side-effect-free; no persistence.
|
||||
var defaultAPIKeyValidator apiKeyValidator = func(ctx context.Context, host, apiKey string) (*sdk.AuthUser, error) {
|
||||
resp, err := sdk.NewClient(host, sdk.WithAPIKey(apiKey)).GetCurrentUser(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !resp.Success || resp.Data.User == nil {
|
||||
return nil, fmt.Errorf("server rejected the API key (no user returned)")
|
||||
}
|
||||
return resp.Data.User, nil
|
||||
}
|
||||
|
||||
// NewCmdLogin builds the `weknora auth login` command. runF is the testable
|
||||
// entrypoint (left nil for production; see cli/cmd/auth/login_test.go).
|
||||
func NewCmdLogin(f *cmdutil.Factory, runF func(context.Context, *LoginOptions, *cmdutil.FormatOptions, *cmdutil.Factory, LoginService) error) *cobra.Command {
|
||||
opts := &LoginOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "login",
|
||||
Short: "Authenticate the active profile against its WeKnora server",
|
||||
Long: `Authenticate the active profile by email + password (interactive prompt) or
|
||||
pipe an API key with --with-token.
|
||||
|
||||
` + "`auth login`" + ` operates on the active profile (override with the global
|
||||
--profile flag). Create the profile first with
|
||||
` + "`weknora profile add <name> --host <h> --use`" + `, then run ` + "`weknora auth login`" + `.
|
||||
|
||||
Credentials are persisted to the OS keyring when available; otherwise to a
|
||||
0600 file under $XDG_CONFIG_HOME/weknora/secrets.`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
fopts, err := cmdutil.CheckFormatFlag(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
|
||||
if opts.StdinReader == nil {
|
||||
opts.StdinReader = iostreams.IO.In
|
||||
}
|
||||
if runF != nil {
|
||||
// Test seam: the injected runF supplies its own service and
|
||||
// (where needed) seeds opts.Host / opts.Profile itself.
|
||||
return runF(c.Context(), opts, fopts, f, nil)
|
||||
}
|
||||
return runLogin(c.Context(), opts, fopts, f, nil)
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&opts.WithToken, "with-token", false, "Read an API key from stdin instead of prompting for password")
|
||||
cmdutil.AddFormatFlag(cmd, authLoginFields...)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "authenticate the active profile; --with-token reads an API key from stdin (non-interactive)",
|
||||
Examples: []string{`echo "$WEKNORA_API_KEY" | weknora auth login --with-token`},
|
||||
Output: "envelope.data is {profile, host, mode, email, tenant_id} on success (email matches `auth status`)",
|
||||
Warnings: []string{
|
||||
"a profile must exist and be active first (`weknora profile add <n> --host <url> --use`)",
|
||||
"password login is interactive-only (no flags) — agents must use --with-token with the key piped to stdin",
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
// resolveActiveProfile loads config and resolves the active profile (honoring
|
||||
// the global --profile override) plus its persisted Host. `auth login`
|
||||
// authenticates an already-existing profile; profile creation is `profile
|
||||
// add`'s job. Returns typed errors when no active profile is configured or
|
||||
// the profile lacks a host.
|
||||
func resolveActiveProfile(f *cmdutil.Factory) (name, host string, err error) {
|
||||
// f.Config() already folds the global --profile / WEKNORA_PROFILE override
|
||||
// into cfg.CurrentProfile (same source f.ActiveProfile reads), so one load
|
||||
// resolves the active profile — matches logout/refresh.
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
active := cfg.CurrentProfile
|
||||
if active == "" {
|
||||
msg := "no active profile; run `weknora profile add <name> --host <h> --use` first"
|
||||
if envActive, kind := cmdutil.EnvCredential(); envActive {
|
||||
msg = "no active profile to log in — you are already authenticated this session via " + kind +
|
||||
"; `auth login` only persists a named profile, so run `weknora profile add <name> --host <h> --use` first if you want one"
|
||||
}
|
||||
return "", "", cmdutil.NewError(cmdutil.CodeAuthUnauthenticated, msg)
|
||||
}
|
||||
prof, ok := cfg.Profiles[active]
|
||||
if !ok {
|
||||
return "", "", cmdutil.NewError(cmdutil.CodeLocalProfileNotFound,
|
||||
fmt.Sprintf("active profile %q not found in config", active))
|
||||
}
|
||||
if prof.Host == "" {
|
||||
return "", "", cmdutil.NewError(cmdutil.CodeLocalConfigCorrupt,
|
||||
fmt.Sprintf("profile %q has no host", active))
|
||||
}
|
||||
return active, prof.Host, nil
|
||||
}
|
||||
|
||||
// loginServiceFor returns a fresh SDK client targeting host. login.go cannot
|
||||
// reuse Factory.Client because that closure requires an existing profile.
|
||||
func loginServiceFor(host string) LoginService {
|
||||
if host == "" {
|
||||
return nil
|
||||
}
|
||||
return sdk.NewClient(host)
|
||||
}
|
||||
|
||||
func runLogin(ctx context.Context, opts *LoginOptions, fopts *cmdutil.FormatOptions, f *cmdutil.Factory, svc LoginService) error {
|
||||
// Resolve the target profile + host from the active profile in config.
|
||||
// `auth login` no longer takes --host / --name; it authenticates the
|
||||
// already-existing active profile (override via the global --profile).
|
||||
name, host, err := resolveActiveProfile(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts.Profile = name
|
||||
opts.Host = host
|
||||
if svc == nil {
|
||||
svc = loginServiceFor(host)
|
||||
}
|
||||
|
||||
if err := validateHost(opts.Host); err != nil {
|
||||
return err
|
||||
}
|
||||
// Reject shell-metacharacter / path-like names up-front so opts.Profile
|
||||
// stays safe to interpolate into the keyring namespace, config.yaml
|
||||
// keys, and envelope.error.retry_argv. Matches `profile add`.
|
||||
if err := cmdutil.ValidateProfileName(opts.Profile); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if opts.WithToken {
|
||||
key, err := readStdinTrimmed(opts.StdinReader)
|
||||
if err != nil {
|
||||
return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "read stdin")
|
||||
}
|
||||
if key == "" {
|
||||
return cmdutil.NewError(cmdutil.CodeInputMissingFlag, "--with-token requires an API key piped to stdin")
|
||||
}
|
||||
opts.APIKey = key
|
||||
// Validate against the server before persisting so a typo'd /
|
||||
// expired / wrong-host key fails fast at login time. The probe
|
||||
// is /auth/me - read-only, side-effect-free.
|
||||
user, err := defaultAPIKeyValidator(ctx, opts.Host, key)
|
||||
if err != nil {
|
||||
// Transport errors (connection refused, DNS failure) must not be
|
||||
// surfaced as auth.bad_credential — the key may be fine but the
|
||||
// host is unreachable. Classify via WrapHTTP so network errors
|
||||
// get CodeNetworkError and the hint points at `weknora doctor`.
|
||||
if cmdutil.ClassifyHTTPError(err) == cmdutil.CodeNetworkError {
|
||||
return cmdutil.Wrapf(cmdutil.CodeNetworkError, err, "validate API key: check host reachability")
|
||||
}
|
||||
return cmdutil.Wrapf(cmdutil.CodeAuthBadCredential, err, "validate API key")
|
||||
}
|
||||
return persistAPIKey(opts, fopts, f, user)
|
||||
}
|
||||
|
||||
// Interactive: prompt for email + password. svc is always set by now
|
||||
// (loginServiceFor returns non-nil because resolveActiveProfile guarantees
|
||||
// a non-empty host).
|
||||
if opts.Email == "" || opts.Password == "" {
|
||||
p := f.Prompter()
|
||||
if opts.Email == "" {
|
||||
email, err := p.Input("Email", "")
|
||||
if err != nil {
|
||||
return cmdutil.Wrapf(cmdutil.CodeInputMissingFlag, err, "email prompt")
|
||||
}
|
||||
opts.Email = email
|
||||
}
|
||||
if opts.Password == "" {
|
||||
pw, err := p.Password("Password")
|
||||
if err != nil {
|
||||
return cmdutil.Wrapf(cmdutil.CodeInputMissingFlag, err, "password prompt")
|
||||
}
|
||||
opts.Password = pw
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := svc.Login(ctx, sdk.LoginRequest{Email: opts.Email, Password: opts.Password})
|
||||
if err != nil {
|
||||
// Transport errors (connection refused, DNS failure) must not be
|
||||
// surfaced as auth.bad_credential — credentials may be fine but the
|
||||
// host is unreachable. Classify so network errors get CodeNetworkError.
|
||||
if cmdutil.ClassifyHTTPError(err) == cmdutil.CodeNetworkError {
|
||||
return cmdutil.Wrapf(cmdutil.CodeNetworkError, err, "login: check host reachability")
|
||||
}
|
||||
return cmdutil.Wrapf(cmdutil.CodeAuthBadCredential, err, "login")
|
||||
}
|
||||
if !resp.Success || resp.Token == "" {
|
||||
return cmdutil.NewError(cmdutil.CodeAuthBadCredential, fmt.Sprintf("login refused: %s", resp.Message))
|
||||
}
|
||||
|
||||
return persistJWT(opts, fopts, f, resp)
|
||||
}
|
||||
|
||||
// persistAPIKey saves the --with-token API key and writes the profile.
|
||||
// user is the principal returned by /auth/me during pre-persist validation,
|
||||
// used to populate Profile.User / TenantID so `auth list` reflects who
|
||||
// owns the key.
|
||||
func persistAPIKey(opts *LoginOptions, fopts *cmdutil.FormatOptions, f *cmdutil.Factory, user *sdk.AuthUser) error {
|
||||
store, err := f.Secrets()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
warnOnFileFallback(store)
|
||||
if err := store.Set(opts.Profile, "api_key", opts.APIKey); err != nil {
|
||||
return cmdutil.Wrapf(cmdutil.CodeLocalKeychainDenied, err, "save api key")
|
||||
}
|
||||
// Switching credential mode: this profile is now an API-key profile, so
|
||||
// any leftover JWT refs (from a prior password login) are stale. Set the
|
||||
// api-key ref and clear the token/refresh refs; keep Host + existing
|
||||
// User/TenantID (merged in saveProfileRef via applyUser).
|
||||
mutate := func(prof *config.Profile) {
|
||||
prof.APIKeyRef = store.Ref(opts.Profile, "api_key")
|
||||
prof.TokenRef = ""
|
||||
prof.RefreshRef = ""
|
||||
applyUser(prof, user)
|
||||
}
|
||||
return saveProfileRef(opts, fopts, f, mutate, ModeAPIKey, user)
|
||||
}
|
||||
|
||||
// persistJWT saves access + refresh tokens and writes the profile.
|
||||
func persistJWT(opts *LoginOptions, fopts *cmdutil.FormatOptions, f *cmdutil.Factory, resp *sdk.LoginResponse) error {
|
||||
store, err := f.Secrets()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
warnOnFileFallback(store)
|
||||
if err := store.Set(opts.Profile, "access", resp.Token); err != nil {
|
||||
return cmdutil.Wrapf(cmdutil.CodeLocalKeychainDenied, err, "save access token")
|
||||
}
|
||||
if resp.RefreshToken != "" {
|
||||
if err := store.Set(opts.Profile, "refresh", resp.RefreshToken); err != nil {
|
||||
return cmdutil.Wrapf(cmdutil.CodeLocalKeychainDenied, err, "save refresh token")
|
||||
}
|
||||
}
|
||||
// Switching to a JWT profile: set token refs and drop any stale api-key
|
||||
// ref; keep Host + existing User/TenantID (merged in saveProfileRef via applyUser).
|
||||
mutate := func(prof *config.Profile) {
|
||||
prof.TokenRef = store.Ref(opts.Profile, "access")
|
||||
prof.RefreshRef = store.Ref(opts.Profile, "refresh")
|
||||
prof.APIKeyRef = ""
|
||||
applyUser(prof, resp.User)
|
||||
}
|
||||
return saveProfileRef(opts, fopts, f, mutate, ModeBearer, resp.User)
|
||||
}
|
||||
|
||||
// applyUser overwrites prof.User / prof.TenantID only when the server actually
|
||||
// returned them. A nil user (or empty email) must NOT wipe an existing User
|
||||
// that was set during `profile add` or a prior login.
|
||||
func applyUser(prof *config.Profile, user *sdk.AuthUser) {
|
||||
if user == nil {
|
||||
return
|
||||
}
|
||||
if user.Email != "" {
|
||||
prof.User = user.Email
|
||||
}
|
||||
if user.TenantID != 0 {
|
||||
prof.TenantID = user.TenantID
|
||||
}
|
||||
}
|
||||
|
||||
// loginResult is the typed payload emitted by `--format json`. mode is derived from
|
||||
// whether the server returned a user (password flow) vs API-key flow.
|
||||
type loginResult struct {
|
||||
Profile string `json:"profile"`
|
||||
Host string `json:"host"`
|
||||
Mode string `json:"mode"` // ModeBearer or ModeAPIKey
|
||||
// Email is the authenticated principal's email. Named "email" (not
|
||||
// "user") so the identity field matches `auth status`, which also
|
||||
// exposes it as `email` — one key for one concept across both commands.
|
||||
Email string `json:"email,omitempty"`
|
||||
TenantID uint64 `json:"tenant_id,omitempty"`
|
||||
}
|
||||
|
||||
// saveProfileRef MERGES the new credential refs into the EXISTING profile
|
||||
// record and prints success. The active profile already exists (created via
|
||||
// `profile add`, carrying Host and possibly User/TenantID); mutate sets only
|
||||
// the credential refs + any server-returned user, so re-login never clobbers
|
||||
// the host or wipes an existing user. cfg.CurrentProfile is left untouched -
|
||||
// `auth login` authenticates the already-active profile, it doesn't switch.
|
||||
func saveProfileRef(opts *LoginOptions, fopts *cmdutil.FormatOptions, f *cmdutil.Factory, mutate func(*config.Profile), mode string, user *sdk.AuthUser) error {
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.Profiles == nil {
|
||||
cfg.Profiles = map[string]config.Profile{}
|
||||
}
|
||||
prof := cfg.Profiles[opts.Profile] // existing record (Host, User, TenantID)
|
||||
mutate(&prof)
|
||||
cfg.Profiles[opts.Profile] = prof
|
||||
if err := config.Save(cfg); err != nil {
|
||||
return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "save config")
|
||||
}
|
||||
if fopts.WantsJSON() {
|
||||
// mode reflects the credential actually stored (passed by the caller),
|
||||
// not whether the server happened to return a user — an API-key login
|
||||
// validates via /auth/me and DOES get a user, so deriving mode from
|
||||
// user != nil would mislabel it as bearer.
|
||||
result := loginResult{Profile: opts.Profile, Host: opts.Host, Mode: mode}
|
||||
if user != nil {
|
||||
result.Email = user.Email
|
||||
result.TenantID = user.TenantID
|
||||
}
|
||||
return fopts.Emit(iostreams.IO.Out, result, nil)
|
||||
}
|
||||
who := opts.Profile
|
||||
if user != nil {
|
||||
who = user.Email
|
||||
}
|
||||
fmt.Fprintf(iostreams.IO.Out, "✓ Logged in to %s as %s (profile=%s)\n", opts.Host, who, opts.Profile)
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateHost rejects empty / non-http URLs early so we surface a clean
|
||||
// flag error instead of failing inside the SDK transport.
|
||||
func validateHost(host string) error {
|
||||
_, err := cmdutil.NormalizeHost(host)
|
||||
return err
|
||||
}
|
||||
|
||||
// warnOnFileFallback prints a one-shot stderr advisory when the secrets
|
||||
// store fell back to the plaintext 0600 file backend (keychain unavailable
|
||||
// - typical on headless CI, WSL without DBus, agent containers). Helps
|
||||
// users notice that credentials are NOT in the OS keychain before they're
|
||||
// surprised by it later. doctor's credential_storage check carries the
|
||||
// same info but agents that bypass doctor would otherwise miss it.
|
||||
func warnOnFileFallback(store secrets.Store) {
|
||||
if _, isFile := store.(*secrets.FileStore); !isFile {
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(iostreams.IO.Err, "warning: OS keychain unavailable - credentials will be saved to a 0600 file under $XDG_CONFIG_HOME/weknora/secrets/.")
|
||||
fmt.Fprintln(iostreams.IO.Err, " install / unlock the keyring (or use `weknora doctor` to inspect) for OS-backed storage.")
|
||||
}
|
||||
|
||||
// readStdinTrimmed reads all of r and returns the result with surrounding
|
||||
// whitespace stripped. Empty result is returned as-is for the caller to
|
||||
// validate.
|
||||
func readStdinTrimmed(r io.Reader) (string, error) {
|
||||
if r == nil {
|
||||
return "", nil
|
||||
}
|
||||
b, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(string(b)), nil
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/config"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
"github.com/Tencent/WeKnora/cli/internal/prompt"
|
||||
"github.com/Tencent/WeKnora/cli/internal/secrets"
|
||||
"github.com/Tencent/WeKnora/cli/internal/testutil"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// fakeLoginService captures the email/password it received.
|
||||
type fakeLoginService struct {
|
||||
resp *sdk.LoginResponse
|
||||
err error
|
||||
got struct{ email, password string }
|
||||
}
|
||||
|
||||
func (f *fakeLoginService) Login(_ context.Context, req sdk.LoginRequest) (*sdk.LoginResponse, error) {
|
||||
f.got.email = req.Email
|
||||
f.got.password = req.Password
|
||||
return f.resp, f.err
|
||||
}
|
||||
|
||||
// scriptedPrompter satisfies prompt.Prompter with predetermined values.
|
||||
type scriptedPrompter struct{ email, password string }
|
||||
|
||||
func (s scriptedPrompter) Input(string, string) (string, error) { return s.email, nil }
|
||||
func (s scriptedPrompter) Password(string) (string, error) { return s.password, nil }
|
||||
func (s scriptedPrompter) Confirm(string, bool) (bool, error) { return true, nil }
|
||||
|
||||
func newTestFactoryWithConfig(t *testing.T, p prompt.Prompter) (*cmdutil.Factory, *secrets.MemStore) {
|
||||
t.Helper()
|
||||
testutil.XDGTempDir(t)
|
||||
store := secrets.NewMemStore()
|
||||
return &cmdutil.Factory{
|
||||
Config: func() (*config.Config, error) { return config.Load() },
|
||||
Client: func() (*sdk.Client, error) { panic("client") },
|
||||
Prompter: func() prompt.Prompter { return p },
|
||||
Secrets: func() (secrets.Store, error) { return store, nil },
|
||||
}, store
|
||||
}
|
||||
|
||||
// seedActiveProfile writes an active profile to the (XDG-isolated) config so
|
||||
// runLogin can resolve host/name from it - `auth login` authenticates the
|
||||
// already-existing active profile rather than creating one. Returns nothing;
|
||||
// callers read back via f.Config() to assert merge behavior.
|
||||
func seedActiveProfile(t *testing.T, name string, prof config.Profile) {
|
||||
t.Helper()
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: name,
|
||||
Profiles: map[string]config.Profile{name: prof},
|
||||
}
|
||||
require.NoError(t, config.Save(cfg))
|
||||
}
|
||||
|
||||
func TestRunLogin_PasswordMode(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
f, store := newTestFactoryWithConfig(t, scriptedPrompter{email: "a@b.c", password: "secret"})
|
||||
seedActiveProfile(t, "prod", config.Profile{Host: "https://kb.example.com"})
|
||||
svc := &fakeLoginService{resp: &sdk.LoginResponse{
|
||||
Success: true,
|
||||
Token: "jwt-access",
|
||||
User: &sdk.AuthUser{ID: "u1", Email: "a@b.c", TenantID: 7},
|
||||
}}
|
||||
require.NoError(t, runLogin(context.Background(), &LoginOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, svc))
|
||||
|
||||
assert.Equal(t, "a@b.c", svc.got.email)
|
||||
assert.Equal(t, "secret", svc.got.password)
|
||||
|
||||
got, _ := store.Get("prod", "access")
|
||||
assert.Equal(t, "jwt-access", got)
|
||||
|
||||
cfg, _ := f.Config()
|
||||
assert.Equal(t, "https://kb.example.com", cfg.Profiles["prod"].Host, "host must be preserved from the seeded profile")
|
||||
}
|
||||
|
||||
func TestRunLogin_WithToken(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
f, store := newTestFactoryWithConfig(t, prompt.AgentPrompter{})
|
||||
seedActiveProfile(t, "ci", config.Profile{Host: "https://kb.example.com"})
|
||||
restore := stubAPIKeyValidator(func(_ context.Context, _, _ string) (*sdk.AuthUser, error) {
|
||||
return &sdk.AuthUser{ID: "u1", Email: "ci@example.com", TenantID: 7}, nil
|
||||
})
|
||||
defer restore()
|
||||
opts := &LoginOptions{
|
||||
WithToken: true,
|
||||
StdinReader: strings.NewReader(" sk-1234 \n"),
|
||||
}
|
||||
require.NoError(t, runLogin(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, nil))
|
||||
got, _ := store.Get("ci", "api_key")
|
||||
assert.Equal(t, "sk-1234", got)
|
||||
cfg, _ := f.Config()
|
||||
assert.Equal(t, "ci@example.com", cfg.Profiles["ci"].User, "validator-returned user should be persisted")
|
||||
assert.Equal(t, uint64(7), cfg.Profiles["ci"].TenantID)
|
||||
assert.Equal(t, "https://kb.example.com", cfg.Profiles["ci"].Host, "host preserved from seeded profile")
|
||||
}
|
||||
|
||||
func TestRunLogin_WithToken_JSONReportsAPIKeyMode(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
f, _ := newTestFactoryWithConfig(t, prompt.AgentPrompter{})
|
||||
seedActiveProfile(t, "ci", config.Profile{Host: "https://kb.example.com"})
|
||||
restore := stubAPIKeyValidator(func(_ context.Context, _, _ string) (*sdk.AuthUser, error) {
|
||||
// API-key validation hits /auth/me, which DOES return a user. mode
|
||||
// must still be reported as api-key (derived from credential type,
|
||||
// not from "did the server return a user").
|
||||
return &sdk.AuthUser{ID: "u1", Email: "ci@example.com", TenantID: 7}, nil
|
||||
})
|
||||
defer restore()
|
||||
opts := &LoginOptions{WithToken: true, StdinReader: strings.NewReader("sk-1234")}
|
||||
require.NoError(t, runLogin(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, f, nil))
|
||||
|
||||
var env struct {
|
||||
Data struct {
|
||||
Mode string `json:"mode"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(out.Bytes(), &env))
|
||||
assert.Equal(t, "api-key", env.Data.Mode, "api-key login must report mode=api-key, not bearer")
|
||||
}
|
||||
|
||||
func TestRunLogin_WithToken_ServerRejects(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
f, _ := newTestFactoryWithConfig(t, prompt.AgentPrompter{})
|
||||
seedActiveProfile(t, "ci", config.Profile{Host: "https://kb.example.com"})
|
||||
restore := stubAPIKeyValidator(func(_ context.Context, _, _ string) (*sdk.AuthUser, error) {
|
||||
// Use the SDK-format HTTP error message so ClassifyHTTPError detects
|
||||
// this as an HTTP 401, not a transport/network failure.
|
||||
return nil, errors.New("HTTP error 401: invalid api key")
|
||||
})
|
||||
defer restore()
|
||||
opts := &LoginOptions{
|
||||
WithToken: true,
|
||||
StdinReader: strings.NewReader("sk-bad"),
|
||||
}
|
||||
err := runLogin(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, nil)
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeAuthBadCredential, typed.Code,
|
||||
"server-side rejection must surface as auth.bad_credential, not persist the key")
|
||||
}
|
||||
|
||||
// stubAPIKeyValidator swaps defaultAPIKeyValidator for the test and returns
|
||||
// a restore func to defer.
|
||||
func stubAPIKeyValidator(fn apiKeyValidator) func() {
|
||||
saved := defaultAPIKeyValidator
|
||||
defaultAPIKeyValidator = fn
|
||||
return func() { defaultAPIKeyValidator = saved }
|
||||
}
|
||||
|
||||
func TestRunLogin_WithToken_Empty(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
f, _ := newTestFactoryWithConfig(t, prompt.AgentPrompter{})
|
||||
seedActiveProfile(t, "ci", config.Profile{Host: "https://kb.example.com"})
|
||||
// Validator must NOT be called when stdin is empty - verify by setting
|
||||
// a panic-on-call sentinel.
|
||||
restore := stubAPIKeyValidator(func(_ context.Context, _, _ string) (*sdk.AuthUser, error) {
|
||||
t.Fatal("validator should not be called on empty stdin")
|
||||
return nil, nil
|
||||
})
|
||||
defer restore()
|
||||
opts := &LoginOptions{
|
||||
WithToken: true,
|
||||
StdinReader: strings.NewReader(""),
|
||||
}
|
||||
err := runLogin(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, nil)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "input.missing_flag")
|
||||
}
|
||||
|
||||
func TestRunLogin_BadHost(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
f, _ := newTestFactoryWithConfig(t, prompt.AgentPrompter{})
|
||||
// A non-http host stored on the active profile must be rejected by the
|
||||
// post-resolution validateHost check (config corruption guard).
|
||||
seedActiveProfile(t, "bad", config.Profile{Host: "ftp://nope"})
|
||||
err := runLogin(context.Background(), &LoginOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, nil)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "input.invalid_argument")
|
||||
}
|
||||
|
||||
func TestRunLogin_LoginRefused(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
f, _ := newTestFactoryWithConfig(t, scriptedPrompter{email: "a@b.c", password: "x"})
|
||||
seedActiveProfile(t, "p", config.Profile{Host: "https://x"})
|
||||
svc := &fakeLoginService{resp: &sdk.LoginResponse{Success: false, Message: "bad password"}}
|
||||
err := runLogin(context.Background(), &LoginOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, svc)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "auth.bad_credential")
|
||||
}
|
||||
|
||||
func TestValidateHost(t *testing.T) {
|
||||
require.NoError(t, validateHost("https://kb.example.com"))
|
||||
require.NoError(t, validateHost("http://localhost:8080"))
|
||||
require.Error(t, validateHost(""))
|
||||
require.Error(t, validateHost("ftp://x"))
|
||||
require.Error(t, validateHost("not a url"))
|
||||
}
|
||||
|
||||
// TestLogin_NoHostNoName asserts the --host / --name flags are gone: `auth
|
||||
// login` now authenticates the active profile (create it via `profile add`).
|
||||
func TestLogin_NoHostNoName(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
f, _ := newTestFactoryWithConfig(t, prompt.AgentPrompter{})
|
||||
cmd := NewCmdLogin(f, nil)
|
||||
assert.Nil(t, cmd.Flags().Lookup("host"), "--host flag must be removed")
|
||||
assert.Nil(t, cmd.Flags().Lookup("name"), "--name flag must be removed")
|
||||
assert.NotNil(t, cmd.Flags().Lookup("with-token"), "--with-token must remain")
|
||||
}
|
||||
|
||||
// TestLogin_NoActiveProfile_Errors asserts that with no active profile
|
||||
// configured, login returns a clear auth.unauthenticated error pointing the
|
||||
// user at `profile add`.
|
||||
func TestLogin_NoActiveProfile_Errors(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
f, _ := newTestFactoryWithConfig(t, prompt.AgentPrompter{})
|
||||
// No seedActiveProfile: empty config.
|
||||
err := runLogin(context.Background(), &LoginOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, nil)
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeAuthUnauthenticated, typed.Code)
|
||||
assert.Contains(t, err.Error(), "profile add")
|
||||
}
|
||||
|
||||
// TestLogin_UsesActiveProfileHost asserts login persists credentials under the
|
||||
// active profile using that profile's stored host, and that an existing User
|
||||
// is NOT clobbered when the server (API-key validator) returns it unchanged.
|
||||
func TestLogin_UsesActiveProfileHost(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
f, store := newTestFactoryWithConfig(t, prompt.AgentPrompter{})
|
||||
// Pre-seed "prod" with a host AND a user that must survive re-login.
|
||||
seedActiveProfile(t, "prod", config.Profile{Host: "https://prod.example.com", User: "existing@owner.com"})
|
||||
restore := stubAPIKeyValidator(func(_ context.Context, host, _ string) (*sdk.AuthUser, error) {
|
||||
assert.Equal(t, "https://prod.example.com", host, "validator must be called with the active profile's host")
|
||||
// Server returns nil user (e.g. /auth/me gave no email) - must NOT
|
||||
// wipe the existing User.
|
||||
return &sdk.AuthUser{}, nil
|
||||
})
|
||||
defer restore()
|
||||
opts := &LoginOptions{WithToken: true, StdinReader: strings.NewReader("sk-prod")}
|
||||
require.NoError(t, runLogin(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, nil))
|
||||
|
||||
got, _ := store.Get("prod", "api_key")
|
||||
assert.Equal(t, "sk-prod", got, "api key must be stored under the active profile")
|
||||
|
||||
cfg, _ := f.Config()
|
||||
prof := cfg.Profiles["prod"]
|
||||
assert.Equal(t, "https://prod.example.com", prof.Host, "host must be preserved, not clobbered")
|
||||
assert.Equal(t, "existing@owner.com", prof.User, "existing User must NOT be wiped when server returns none")
|
||||
assert.NotEmpty(t, prof.APIKeyRef, "api-key ref must be set")
|
||||
assert.Equal(t, "prod", cfg.CurrentProfile, "active profile unchanged")
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/config"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
"github.com/Tencent/WeKnora/cli/internal/secrets"
|
||||
)
|
||||
|
||||
type LogoutOptions struct {
|
||||
All bool // --all: clear every profile
|
||||
Yes bool // sourced from the global -y/--yes persistent flag
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
// authLogoutFields enumerates the fields surfaced for `--format json` discovery
|
||||
// on `auth logout`. The result is the list of profile names that were
|
||||
// logged out.
|
||||
var authLogoutFields = []string{"removed"}
|
||||
|
||||
// logoutResult is the typed payload emitted under data.
|
||||
type logoutResult struct {
|
||||
Removed []string `json:"removed"`
|
||||
}
|
||||
|
||||
// NewCmdLogout builds `weknora auth logout`. Clears stored credentials
|
||||
// (keyring + file fallback) and removes the profile entry from config.yaml.
|
||||
// No server-side revocation - local-only credential clear.
|
||||
func NewCmdLogout(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &LogoutOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "logout",
|
||||
Short: "Remove stored credentials for a profile",
|
||||
Long: `Clear keyring + file-fallback secrets for one profile (or all of
|
||||
them with --all) and drop the profile entry from ~/.config/weknora/config.yaml.
|
||||
|
||||
Note: this does NOT revoke the credential server-side - for API keys, you
|
||||
must rotate them in the server UI; for JWT, the token will continue to be
|
||||
accepted until it expires.`,
|
||||
Example: ` weknora auth logout # active profile
|
||||
weknora --profile staging auth logout # specific profile
|
||||
weknora auth logout --all`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(c *cobra.Command, _ []string) error {
|
||||
fopts, err := cmdutil.CheckFormatFlag(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
|
||||
opts.Yes, _ = c.Flags().GetBool("yes")
|
||||
// Pure-local validation runs before the dry-run gate so --dry-run
|
||||
// rejects identically to the live path. Same typed errors as
|
||||
// runLogout (kept there for direct-call callers).
|
||||
cfg, cfgErr := f.Config()
|
||||
if cfgErr != nil {
|
||||
return cfgErr
|
||||
}
|
||||
if len(cfg.Profiles) == 0 {
|
||||
if active, kind := cmdutil.EnvCredential(); active {
|
||||
return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated,
|
||||
"authenticated via "+kind+" (stateless env credential) — nothing is stored to log out; unset "+kind+" to drop it")
|
||||
}
|
||||
return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated, "no profiles configured; nothing to log out")
|
||||
}
|
||||
if _, err := pickLogoutTargets(opts, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.DryRun {
|
||||
planArgs := map[string]any{"profile": cfg.CurrentProfile}
|
||||
if opts.All {
|
||||
planArgs = map[string]any{"all": true}
|
||||
}
|
||||
if handled, err := cmdutil.HandleDryRun(c, true, cmdutil.DryRunPlan{
|
||||
Action: "auth.logout",
|
||||
Args: planArgs,
|
||||
}); handled {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return runLogout(opts, fopts, f)
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&opts.All, "all", false, "Log out of every configured profile")
|
||||
cmdutil.AddFormatFlag(cmd, authLogoutFields...)
|
||||
cmdutil.AddDryRunFlag(cmd, &opts.DryRun)
|
||||
cmdutil.SetRisk(cmd, "auth.logout")
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "clear stored credentials for the active profile (or --all); the profile itself stays registered (host preserved) so `auth login` can re-auth it — use `profile remove` to delete the profile entirely",
|
||||
Examples: []string{
|
||||
"weknora auth logout",
|
||||
"weknora --profile staging auth logout",
|
||||
"weknora auth logout --all",
|
||||
},
|
||||
Output: "envelope.data is {removed: [profile names cleared]}",
|
||||
Warnings: []string{
|
||||
"Requires explicit user approval (exit 10 / input.confirmation_required); never auto-add -y.",
|
||||
"auth logout clears local credentials for this profile; server-side session continues until expiry.",
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runLogout(opts *LogoutOptions, fopts *cmdutil.FormatOptions, f *cmdutil.Factory) error {
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(cfg.Profiles) == 0 {
|
||||
if active, kind := cmdutil.EnvCredential(); active {
|
||||
return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated,
|
||||
"authenticated via "+kind+" (stateless env credential) — nothing is stored to log out; unset "+kind+" to drop it")
|
||||
}
|
||||
return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated, "no profiles configured; nothing to log out")
|
||||
}
|
||||
|
||||
targets, err := pickLogoutTargets(opts, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Destructive-write protocol: confirm before clearing credentials.
|
||||
scope := fmt.Sprintf("%d profile(s) [%s]", len(targets), strings.Join(targets, ", "))
|
||||
retryCmd := []string{"weknora", "auth", "logout", "-y"}
|
||||
if opts.All {
|
||||
retryCmd = []string{"weknora", "auth", "logout", "--all", "-y"}
|
||||
}
|
||||
if err := cmdutil.ConfirmDestructive(f.Prompter(), opts.Yes, fopts.WantsJSON(), "delete", "auth credentials", scope, "auth.logout", retryCmd); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
store, err := f.Secrets()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, name := range targets {
|
||||
clearProfileSecrets(store, cfg.Profiles[name], name)
|
||||
// Keep the profile registered (its host stays) — only clear the
|
||||
// credential refs so it reads as logged-out and can be re-authed with
|
||||
// `auth login`. Deleting the profile entirely is `profile remove`'s job;
|
||||
// that clean logout(who)/remove(what) split matches gh / lark.
|
||||
p := cfg.Profiles[name]
|
||||
p.APIKeyRef = ""
|
||||
p.TokenRef = ""
|
||||
p.RefreshRef = ""
|
||||
cfg.Profiles[name] = p
|
||||
}
|
||||
if err := config.Save(cfg); err != nil {
|
||||
return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "save config")
|
||||
}
|
||||
|
||||
if fopts.WantsJSON() {
|
||||
return fopts.Emit(iostreams.IO.Out, logoutResult{Removed: targets}, nil)
|
||||
}
|
||||
fmt.Fprintf(iostreams.IO.Out, "✓ Logged out of %d profile(s): %s\n", len(targets), strings.Join(targets, ", "))
|
||||
return nil
|
||||
}
|
||||
|
||||
// pickLogoutTargets resolves the set of profiles to clear from flags + config.
|
||||
func pickLogoutTargets(opts *LogoutOptions, cfg *config.Config) ([]string, error) {
|
||||
if opts.All {
|
||||
names := make([]string, 0, len(cfg.Profiles))
|
||||
for n := range cfg.Profiles {
|
||||
names = append(names, n)
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
name := cfg.CurrentProfile
|
||||
if name == "" {
|
||||
return nil, cmdutil.NewError(cmdutil.CodeInputMissingFlag,
|
||||
"no active profile set; use the global --profile <profile> or --all")
|
||||
}
|
||||
if _, ok := cfg.Profiles[name]; !ok {
|
||||
return nil, cmdutil.NewError(cmdutil.CodeLocalProfileNotFound,
|
||||
fmt.Sprintf("profile %q not found in config", name))
|
||||
}
|
||||
return []string{name}, nil
|
||||
}
|
||||
|
||||
// clearProfileSecrets best-effort deletes every secret slot the profile
|
||||
// references. Errors are swallowed because a missing secret is a no-op
|
||||
// (tested in keyring_test.go) - we don't want a stale ref to block logout.
|
||||
func clearProfileSecrets(store secrets.Store, c config.Profile, name string) {
|
||||
if c.TokenRef != "" {
|
||||
_ = store.Delete(name, "access")
|
||||
}
|
||||
if c.RefreshRef != "" {
|
||||
_ = store.Delete(name, "refresh")
|
||||
}
|
||||
if c.APIKeyRef != "" {
|
||||
_ = store.Delete(name, "api_key")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/config"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
"github.com/Tencent/WeKnora/cli/internal/prompt"
|
||||
"github.com/Tencent/WeKnora/cli/internal/secrets"
|
||||
)
|
||||
|
||||
// newLogoutFactory builds a Factory whose Config closure mutates the supplied
|
||||
// cfg in place - runLogout writes back via config.Save which touches disk, so
|
||||
// tests use t.Setenv("XDG_CONFIG_HOME", t.TempDir()) at the call site to
|
||||
// isolate the on-disk file.
|
||||
func newLogoutFactory(t *testing.T, cfg *config.Config, store secrets.Store) *cmdutil.Factory {
|
||||
t.Helper()
|
||||
return &cmdutil.Factory{
|
||||
Config: func() (*config.Config, error) { return cfg, nil },
|
||||
Secrets: func() (secrets.Store, error) { return store, nil },
|
||||
Prompter: func() prompt.Prompter { return &prompt.AgentPrompter{} },
|
||||
}
|
||||
}
|
||||
|
||||
func isolateConfig(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
}
|
||||
|
||||
func TestLogout_CurrentProfile(t *testing.T) {
|
||||
isolateConfig(t)
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
store := secrets.NewMemStore()
|
||||
require.NoError(t, store.Set("prod", "access", "jwt-prod"))
|
||||
require.NoError(t, store.Set("prod", "refresh", "rfr-prod"))
|
||||
require.NoError(t, store.Set("staging", "api_key", "sk-staging"))
|
||||
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "prod",
|
||||
Profiles: map[string]config.Profile{
|
||||
"prod": {Host: "https://prod", TokenRef: store.Ref("prod", "access"), RefreshRef: store.Ref("prod", "refresh")},
|
||||
"staging": {Host: "https://staging", APIKeyRef: store.Ref("staging", "api_key")},
|
||||
},
|
||||
}
|
||||
require.NoError(t, runLogout(&LogoutOptions{Yes: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, newLogoutFactory(t, cfg, store)))
|
||||
|
||||
assert.Equal(t, "prod", cfg.CurrentProfile, "active profile stays selected — logout clears creds, not the profile")
|
||||
require.Contains(t, cfg.Profiles, "prod", "profile stays registered after logout")
|
||||
assert.Empty(t, cfg.Profiles["prod"].APIKeyRef, "credential ref cleared")
|
||||
assert.Empty(t, cfg.Profiles["prod"].TokenRef, "credential ref cleared")
|
||||
assert.Empty(t, cfg.Profiles["prod"].RefreshRef, "credential ref cleared")
|
||||
assert.Equal(t, "https://prod", cfg.Profiles["prod"].Host, "host preserved for re-login")
|
||||
assert.Contains(t, cfg.Profiles, "staging", "non-target profile untouched")
|
||||
|
||||
// Secrets gone for the removed profile, kept for the survivor.
|
||||
if _, err := store.Get("prod", "access"); err == nil {
|
||||
t.Error("prod access secret should be deleted")
|
||||
}
|
||||
if v, _ := store.Get("staging", "api_key"); v != "sk-staging" {
|
||||
t.Errorf("staging secret unexpectedly cleared: %q", v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLogout_ActiveProfileViaOverride exercises targeting a non-default
|
||||
// profile. Production resolves this via the global --profile flag (which
|
||||
// rewrites cfg.CurrentProfile in Factory.Config); here we simulate that by
|
||||
// setting CurrentProfile=staging directly, since runLogout's single target is
|
||||
// the active profile.
|
||||
func TestLogout_ActiveProfileViaOverride(t *testing.T) {
|
||||
isolateConfig(t)
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
store := secrets.NewMemStore()
|
||||
require.NoError(t, store.Set("staging", "api_key", "sk-staging"))
|
||||
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "staging", // global --profile staging resolves to this
|
||||
Profiles: map[string]config.Profile{
|
||||
"prod": {Host: "https://prod", TokenRef: "tok"},
|
||||
"staging": {Host: "https://staging", APIKeyRef: store.Ref("staging", "api_key")},
|
||||
},
|
||||
}
|
||||
require.NoError(t, runLogout(&LogoutOptions{Yes: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, newLogoutFactory(t, cfg, store)))
|
||||
|
||||
require.Contains(t, cfg.Profiles, "staging", "target profile stays registered (creds cleared, not removed)")
|
||||
assert.Empty(t, cfg.Profiles["staging"].APIKeyRef, "target's credential ref cleared")
|
||||
assert.Contains(t, cfg.Profiles, "prod", "non-target profile untouched")
|
||||
}
|
||||
|
||||
func TestLogout_All(t *testing.T) {
|
||||
isolateConfig(t)
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
store := secrets.NewMemStore()
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "prod",
|
||||
Profiles: map[string]config.Profile{
|
||||
"prod": {Host: "https://prod"},
|
||||
"staging": {Host: "https://staging"},
|
||||
},
|
||||
}
|
||||
require.NoError(t, runLogout(&LogoutOptions{All: true, Yes: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, newLogoutFactory(t, cfg, store)))
|
||||
|
||||
// --all clears every profile's credentials but keeps the profiles registered.
|
||||
require.NotEmpty(t, cfg.Profiles, "profiles stay registered after logout --all")
|
||||
for name, p := range cfg.Profiles {
|
||||
assert.Empty(t, p.APIKeyRef, "%s api-key ref cleared", name)
|
||||
assert.Empty(t, p.TokenRef, "%s token ref cleared", name)
|
||||
assert.Empty(t, p.RefreshRef, "%s refresh ref cleared", name)
|
||||
}
|
||||
assert.Equal(t, "prod", cfg.CurrentProfile, "active selection is preserved; logout clears creds, not the profile")
|
||||
}
|
||||
|
||||
func TestLogout_NoProfiles(t *testing.T) {
|
||||
isolateConfig(t)
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
cfg := &config.Config{}
|
||||
err := runLogout(&LogoutOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, newLogoutFactory(t, cfg, secrets.NewMemStore()))
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeAuthUnauthenticated, typed.Code)
|
||||
}
|
||||
|
||||
// TestLogout_ActiveProfileMissing covers the corrupt-config case where the
|
||||
// active profile name (e.g. set via --profile ghost) has no matching entry.
|
||||
func TestLogout_ActiveProfileMissing(t *testing.T) {
|
||||
isolateConfig(t)
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "ghost", // points at a non-existent entry
|
||||
Profiles: map[string]config.Profile{"prod": {Host: "https://prod"}},
|
||||
}
|
||||
err := runLogout(&LogoutOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, newLogoutFactory(t, cfg, secrets.NewMemStore()))
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeLocalProfileNotFound, typed.Code)
|
||||
}
|
||||
|
||||
func TestLogout_NoCurrentNoFlag(t *testing.T) {
|
||||
isolateConfig(t)
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
cfg := &config.Config{
|
||||
Profiles: map[string]config.Profile{"prod": {Host: "https://prod"}},
|
||||
}
|
||||
err := runLogout(&LogoutOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, newLogoutFactory(t, cfg, secrets.NewMemStore()))
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeInputMissingFlag, typed.Code)
|
||||
}
|
||||
|
||||
// TestLogout_NoNameFlag asserts the --name flag is gone (logout now targets
|
||||
// the active profile / global --profile) while --all stands alone.
|
||||
func TestLogout_NoNameFlag(t *testing.T) {
|
||||
isolateConfig(t)
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
cfg := &config.Config{Profiles: map[string]config.Profile{"a": {}}}
|
||||
cmd := NewCmdLogout(newLogoutFactory(t, cfg, secrets.NewMemStore()))
|
||||
assert.Nil(t, cmd.Flags().Lookup("name"), "--name flag must be removed")
|
||||
assert.NotNil(t, cmd.Flags().Lookup("all"), "--all flag must remain")
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
type RefreshOptions struct {
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
// authRefreshFields enumerates the fields surfaced for `--format json` discovery
|
||||
// on `auth refresh`. Token values are intentionally omitted - see refreshResult.
|
||||
var authRefreshFields = []string{"profile"}
|
||||
|
||||
// refreshResult is the typed payload emitted under data on success. Token
|
||||
// values are intentionally NOT included - emitting them would leak secrets
|
||||
// into stdout / agent transcripts. Agents needing to verify the new token
|
||||
// can re-run `weknora auth status` (live API check).
|
||||
type refreshResult struct {
|
||||
Profile string `json:"profile"`
|
||||
}
|
||||
|
||||
// NewCmdRefresh builds `weknora auth refresh`. Renews the JWT access
|
||||
// token by spending the stored refresh_token via POST /auth/refresh -
|
||||
// the standard OAuth refresh-token grant.
|
||||
//
|
||||
// API-key profiles are rejected - they have no refresh semantic;
|
||||
// rotate the key via the server UI instead.
|
||||
func NewCmdRefresh(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &RefreshOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "refresh",
|
||||
Short: "Renew the JWT access token using the stored refresh token",
|
||||
Long: `Reads the refresh token previously stored by ` + "`weknora auth login`" + ` and
|
||||
exchanges it for a new access + refresh token pair via POST /api/v1/auth/refresh.
|
||||
Both new tokens replace the existing entries in the OS keyring.
|
||||
|
||||
API-key profiles are rejected with input.invalid_argument - they have no
|
||||
refresh semantic. Rotate the key in the server UI instead.`,
|
||||
Example: ` weknora auth refresh # refresh the active profile
|
||||
weknora --profile staging auth refresh # refresh a specific profile`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(c *cobra.Command, _ []string) error {
|
||||
fopts, err := cmdutil.CheckFormatFlag(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
|
||||
// Pure-local validation runs before the dry-run gate so --dry-run
|
||||
// rejects identically to the live path. Same typed errors as
|
||||
// runRefresh (kept there for direct-call callers).
|
||||
cfg, cfgErr := f.Config()
|
||||
if cfgErr != nil {
|
||||
return cfgErr
|
||||
}
|
||||
name := cfg.CurrentProfile
|
||||
if name == "" {
|
||||
if active, kind := cmdutil.EnvCredential(); active {
|
||||
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument,
|
||||
"authenticated via "+kind+" (stateless env credential): there is no stored JWT to refresh — env credentials are supplied fresh each call, so no refresh is needed")
|
||||
}
|
||||
return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated,
|
||||
"no active profile configured; run `weknora auth login` to set one up")
|
||||
}
|
||||
prof, ok := cfg.Profiles[name]
|
||||
if !ok {
|
||||
return cmdutil.NewError(cmdutil.CodeLocalProfileNotFound,
|
||||
fmt.Sprintf("profile not found: %s", name))
|
||||
}
|
||||
if prof.Host == "" {
|
||||
return cmdutil.NewError(cmdutil.CodeLocalConfigCorrupt,
|
||||
fmt.Sprintf("profile %q has no host", name))
|
||||
}
|
||||
if prof.RefreshRef == "" {
|
||||
hint := "api-key profiles can't be refreshed - rotate the key in the server UI and run `weknora auth login --with-token`"
|
||||
if prof.APIKeyRef == "" {
|
||||
hint = "no refresh token stored - run `weknora auth login` to authenticate"
|
||||
}
|
||||
return &cmdutil.Error{
|
||||
Code: cmdutil.CodeInputInvalidArgument,
|
||||
Message: fmt.Sprintf("profile %q has no refresh token", name),
|
||||
Hint: hint,
|
||||
}
|
||||
}
|
||||
if handled, err := cmdutil.HandleDryRun(c, opts.DryRun, cmdutil.DryRunPlan{
|
||||
Action: "auth.refresh",
|
||||
Args: map[string]any{},
|
||||
}); handled {
|
||||
return err
|
||||
}
|
||||
return runRefresh(c.Context(), opts, fopts, f, defaultRefresher)
|
||||
},
|
||||
}
|
||||
cmdutil.AddFormatFlag(cmd, authRefreshFields...)
|
||||
cmdutil.AddDryRunFlag(cmd, &opts.DryRun)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "Renew the JWT access token for the active profile (override with the global --profile) using the stored refresh token. API-key profiles are rejected.",
|
||||
Output: "envelope.data has profile name that was refreshed",
|
||||
Examples: []string{
|
||||
"weknora auth refresh",
|
||||
"weknora --profile staging auth refresh",
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
// defaultRefresher constructs a fresh, unauthenticated SDK client targeting
|
||||
// host - the /auth/refresh endpoint reads the refresh token from the body,
|
||||
// so no bearer / api-key header is needed.
|
||||
func defaultRefresher(host string) cmdutil.Refresher {
|
||||
return sdk.NewClient(host)
|
||||
}
|
||||
|
||||
func runRefresh(ctx context.Context, opts *RefreshOptions, fopts *cmdutil.FormatOptions, f *cmdutil.Factory, refresherFor func(host string) cmdutil.Refresher) error {
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := cfg.CurrentProfile
|
||||
if name == "" {
|
||||
if active, kind := cmdutil.EnvCredential(); active {
|
||||
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument,
|
||||
"authenticated via "+kind+" (stateless env credential): there is no stored JWT to refresh — env credentials are supplied fresh each call, so no refresh is needed")
|
||||
}
|
||||
return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated,
|
||||
"no active profile configured; run `weknora auth login` to set one up")
|
||||
}
|
||||
c, ok := cfg.Profiles[name]
|
||||
if !ok {
|
||||
return cmdutil.NewError(cmdutil.CodeLocalProfileNotFound,
|
||||
fmt.Sprintf("profile not found: %s", name))
|
||||
}
|
||||
if c.Host == "" {
|
||||
return cmdutil.NewError(cmdutil.CodeLocalConfigCorrupt,
|
||||
fmt.Sprintf("profile %q has no host", name))
|
||||
}
|
||||
if c.RefreshRef == "" {
|
||||
hint := "api-key profiles can't be refreshed - rotate the key in the server UI and run `weknora auth login --with-token`"
|
||||
if c.APIKeyRef == "" {
|
||||
hint = "no refresh token stored - run `weknora auth login` to authenticate"
|
||||
}
|
||||
return &cmdutil.Error{
|
||||
Code: cmdutil.CodeInputInvalidArgument,
|
||||
Message: fmt.Sprintf("profile %q has no refresh token", name),
|
||||
Hint: hint,
|
||||
}
|
||||
}
|
||||
|
||||
store, err := f.Secrets()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := cmdutil.RefreshAndPersist(ctx, store, refresherFor(c.Host), name); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if fopts.WantsJSON() {
|
||||
return fopts.Emit(iostreams.IO.Out, refreshResult{Profile: name}, nil)
|
||||
}
|
||||
fmt.Fprintf(iostreams.IO.Out, "✓ Refreshed access token for profile %s\n", name)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/config"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
"github.com/Tencent/WeKnora/cli/internal/prompt"
|
||||
"github.com/Tencent/WeKnora/cli/internal/secrets"
|
||||
"github.com/Tencent/WeKnora/cli/internal/testutil"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// fakeRefreshService scripts a RefreshToken response.
|
||||
type fakeRefreshService struct {
|
||||
resp *sdk.RefreshTokenResponse
|
||||
err error
|
||||
gotTok string
|
||||
}
|
||||
|
||||
func (f *fakeRefreshService) RefreshToken(_ context.Context, refreshToken string) (*sdk.RefreshTokenResponse, error) {
|
||||
f.gotTok = refreshToken
|
||||
return f.resp, f.err
|
||||
}
|
||||
|
||||
// stubSvc returns a closure conforming to the refresherFor signature; it
|
||||
// ignores host since the fake doesn't talk to the network.
|
||||
func stubSvc(s cmdutil.Refresher) func(string) cmdutil.Refresher {
|
||||
return func(string) cmdutil.Refresher { return s }
|
||||
}
|
||||
|
||||
func newRefreshFactory(t *testing.T, cfg *config.Config, store *secrets.MemStore) *cmdutil.Factory {
|
||||
t.Helper()
|
||||
testutil.XDGTempDir(t)
|
||||
require.NoError(t, config.Save(cfg))
|
||||
return &cmdutil.Factory{
|
||||
Config: func() (*config.Config, error) { return config.Load() },
|
||||
Client: func() (*sdk.Client, error) { panic("client") },
|
||||
Prompter: func() prompt.Prompter { return prompt.AgentPrompter{} },
|
||||
Secrets: func() (secrets.Store, error) { return store, nil },
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefresh_Happy(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
store := secrets.NewMemStore()
|
||||
require.NoError(t, store.Set("prod", "access", "old-access"))
|
||||
require.NoError(t, store.Set("prod", "refresh", "old-refresh"))
|
||||
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "prod",
|
||||
Profiles: map[string]config.Profile{
|
||||
"prod": {
|
||||
Host: "https://kb.example.com",
|
||||
TokenRef: "mem://prod/access",
|
||||
RefreshRef: "mem://prod/refresh",
|
||||
User: "alice@example.com",
|
||||
},
|
||||
},
|
||||
}
|
||||
f := newRefreshFactory(t, cfg, store)
|
||||
svc := &fakeRefreshService{resp: &sdk.RefreshTokenResponse{
|
||||
Success: true,
|
||||
AccessToken: "new-access",
|
||||
RefreshToken: "new-refresh",
|
||||
}}
|
||||
require.NoError(t, runRefresh(context.Background(), &RefreshOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, stubSvc(svc)))
|
||||
|
||||
assert.Equal(t, "old-refresh", svc.gotTok, "must pass stored refresh token to SDK")
|
||||
gotAccess, _ := store.Get("prod", "access")
|
||||
gotRefresh, _ := store.Get("prod", "refresh")
|
||||
assert.Equal(t, "new-access", gotAccess)
|
||||
assert.Equal(t, "new-refresh", gotRefresh)
|
||||
}
|
||||
|
||||
// TestRefresh_ActiveProfileViaOverride exercises refreshing a non-default
|
||||
// profile. Production resolves this via the global --profile flag (which
|
||||
// rewrites cfg.CurrentProfile in Factory.Config); here we set
|
||||
// CurrentProfile=staging directly, since runRefresh's target is the active
|
||||
// profile.
|
||||
func TestRefresh_ActiveProfileViaOverride(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
store := secrets.NewMemStore()
|
||||
require.NoError(t, store.Set("staging", "refresh", "stg-refresh"))
|
||||
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "staging", // global --profile staging resolves to this
|
||||
Profiles: map[string]config.Profile{
|
||||
"prod": {Host: "https://prod", TokenRef: "mem://prod/access", RefreshRef: "mem://prod/refresh"},
|
||||
"staging": {Host: "https://stg", TokenRef: "mem://staging/access", RefreshRef: "mem://staging/refresh"},
|
||||
},
|
||||
}
|
||||
f := newRefreshFactory(t, cfg, store)
|
||||
svc := &fakeRefreshService{resp: &sdk.RefreshTokenResponse{
|
||||
Success: true, AccessToken: "new-stg-access", RefreshToken: "new-stg-refresh",
|
||||
}}
|
||||
require.NoError(t, runRefresh(context.Background(), &RefreshOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, stubSvc(svc)))
|
||||
|
||||
assert.Equal(t, "stg-refresh", svc.gotTok, "active profile staging must be refreshed")
|
||||
// prod (non-active) is untouched
|
||||
if v, _ := store.Get("prod", "access"); v != "" {
|
||||
t.Errorf("prod must not have been touched, got %q", v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefresh_NoNameFlag asserts the --name flag is gone; refresh targets the
|
||||
// active profile (override via the global --profile).
|
||||
func TestRefresh_NoNameFlag(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
cfg := &config.Config{Profiles: map[string]config.Profile{"a": {Host: "https://a"}}}
|
||||
f := newRefreshFactory(t, cfg, secrets.NewMemStore())
|
||||
cmd := NewCmdRefresh(f)
|
||||
assert.Nil(t, cmd.Flags().Lookup("name"), "--name flag must be removed")
|
||||
}
|
||||
|
||||
func TestRefresh_NoCurrentProfile(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
f := newRefreshFactory(t, &config.Config{}, secrets.NewMemStore())
|
||||
err := runRefresh(context.Background(), &RefreshOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, stubSvc(&fakeRefreshService{}))
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeAuthUnauthenticated, typed.Code)
|
||||
}
|
||||
|
||||
func TestRefresh_APIKeyContext(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
store := secrets.NewMemStore()
|
||||
require.NoError(t, store.Set("ci", "api_key", "sk-123"))
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "ci",
|
||||
Profiles: map[string]config.Profile{"ci": {Host: "https://kb", APIKeyRef: "mem://ci/api_key"}},
|
||||
}
|
||||
f := newRefreshFactory(t, cfg, store)
|
||||
err := runRefresh(context.Background(), &RefreshOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, stubSvc(&fakeRefreshService{}))
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
|
||||
assert.Contains(t, typed.Hint, "api-key", "hint should explain api-key profiles cannot be refreshed")
|
||||
}
|
||||
|
||||
func TestRefresh_NoRefreshTokenStored(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "prod",
|
||||
Profiles: map[string]config.Profile{
|
||||
"prod": {Host: "https://kb", TokenRef: "mem://prod/access", RefreshRef: "mem://prod/refresh"},
|
||||
},
|
||||
}
|
||||
// MemStore is empty - RefreshRef points to a slot that doesn't exist.
|
||||
f := newRefreshFactory(t, cfg, secrets.NewMemStore())
|
||||
err := runRefresh(context.Background(), &RefreshOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, stubSvc(&fakeRefreshService{}))
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeAuthTokenExpired, typed.Code)
|
||||
assert.Contains(t, typed.Hint, "auth login")
|
||||
}
|
||||
|
||||
func TestRefresh_ServerRefused(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
store := secrets.NewMemStore()
|
||||
require.NoError(t, store.Set("prod", "refresh", "stale-refresh"))
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "prod",
|
||||
Profiles: map[string]config.Profile{"prod": {Host: "https://kb", TokenRef: "mem://prod/access", RefreshRef: "mem://prod/refresh"}},
|
||||
}
|
||||
f := newRefreshFactory(t, cfg, store)
|
||||
svc := &fakeRefreshService{resp: &sdk.RefreshTokenResponse{Success: false, Message: "refresh token expired"}}
|
||||
err := runRefresh(context.Background(), &RefreshOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, stubSvc(svc))
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeAuthTokenExpired, typed.Code)
|
||||
assert.Contains(t, typed.Hint, "auth login")
|
||||
// stored access must NOT have been overwritten with empty
|
||||
if v, _ := store.Get("prod", "access"); v == "" {
|
||||
// Was never set in this test, that's fine - main thing is no panic.
|
||||
_ = v
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefresh_TransportError(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
store := secrets.NewMemStore()
|
||||
require.NoError(t, store.Set("prod", "refresh", "ok-refresh"))
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "prod",
|
||||
Profiles: map[string]config.Profile{"prod": {Host: "https://kb", TokenRef: "mem://prod/access", RefreshRef: "mem://prod/refresh"}},
|
||||
}
|
||||
f := newRefreshFactory(t, cfg, store)
|
||||
svc := &fakeRefreshService{err: errors.New("connection reset")}
|
||||
err := runRefresh(context.Background(), &RefreshOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, stubSvc(svc))
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
// network/transport classified as network.error (mirrors auth login's
|
||||
// CodeAuthBadCredential mapping pattern; here we keep network errors
|
||||
// distinct since auth/refresh treats them as retryable).
|
||||
assert.Equal(t, cmdutil.CodeNetworkError, typed.Code)
|
||||
}
|
||||
|
||||
func TestRefresh_JSONOutput(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
store := secrets.NewMemStore()
|
||||
require.NoError(t, store.Set("prod", "refresh", "ok-refresh"))
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "prod",
|
||||
Profiles: map[string]config.Profile{"prod": {Host: "https://kb", TokenRef: "mem://prod/access", RefreshRef: "mem://prod/refresh"}},
|
||||
}
|
||||
f := newRefreshFactory(t, cfg, store)
|
||||
svc := &fakeRefreshService{resp: &sdk.RefreshTokenResponse{Success: true, AccessToken: "a", RefreshToken: "r"}}
|
||||
require.NoError(t, runRefresh(context.Background(), &RefreshOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, f, stubSvc(svc)))
|
||||
|
||||
body := out.String()
|
||||
// payload must not leak the actual token values.
|
||||
assert.NotContains(t, body, "ok-refresh", "output must not leak refresh token")
|
||||
assert.NotContains(t, body, "\"a\"", "output must not leak the new access token")
|
||||
assert.NotContains(t, body, "\"r\"", "output must not leak the new refresh token")
|
||||
// must mention the profile name so agents can confirm what was refreshed
|
||||
assert.True(t, strings.Contains(body, "prod"), "output should reference the refreshed profile")
|
||||
// v0.7 envelope: ok:true is expected
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal([]byte(body), &env))
|
||||
assert.True(t, env.OK, "envelope.ok must be true")
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// authStatusFields enumerates the fields surfaced for `--format json` discovery
|
||||
// on `auth status`. Single-resource shape: filter applies to data itself.
|
||||
var authStatusFields = []string{
|
||||
"profile", "host", "auth_source", "user_id", "username", "email", "is_active",
|
||||
"can_access_all_tenants", "tenant_id", "tenant_name",
|
||||
}
|
||||
|
||||
// StatusService is the narrow SDK surface auth status depends on.
|
||||
type StatusService interface {
|
||||
GetCurrentUser(ctx context.Context) (*sdk.CurrentUserResponse, error)
|
||||
}
|
||||
|
||||
// statusResult is the typed payload emitted by `--format json`. Mirrors the
|
||||
// SDK AuthUser + AuthTenant projection so agents can branch on
|
||||
// can_access_all_tenants (cross-tenant admin) and is_active (disabled
|
||||
// account) without a second round-trip.
|
||||
type statusResult struct {
|
||||
Profile string `json:"profile"`
|
||||
Host string `json:"host,omitempty"`
|
||||
AuthSource string `json:"auth_source,omitempty"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
IsActive bool `json:"is_active,omitempty"`
|
||||
CanAccessAllTenants bool `json:"can_access_all_tenants,omitempty"`
|
||||
TenantID uint64 `json:"tenant_id,omitempty"`
|
||||
TenantName string `json:"tenant_name,omitempty"`
|
||||
}
|
||||
|
||||
// NewCmdStatus builds the `weknora auth status` command.
|
||||
func NewCmdStatus(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Show the active profile, principal, and token state",
|
||||
Long: `Live-check the active credential by calling /auth/me. Reports the user
|
||||
and tenant the server resolves the credential to.
|
||||
|
||||
Exits with auth.unauthenticated when the token is invalid or missing - run
|
||||
` + "`weknora auth login`" + ` (or ` + "`auth refresh`" + ` for JWT profiles) to recover.
|
||||
For JWT profiles the SDK transparently refreshes on 401, so this command
|
||||
usually only surfaces a hard auth failure.`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
fopts, err := cmdutil.CheckFormatFlag(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runStatus(c.Context(), fopts, f, cli)
|
||||
},
|
||||
}
|
||||
cmdutil.AddFormatFlag(cmd, authStatusFields...)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "show the active profile, the authenticated principal, and token state",
|
||||
Examples: []string{"weknora auth status", "weknora auth status --jq .data.tenant_id"},
|
||||
Output: "envelope.data is {profile, host, auth_source, user_id, username, email, tenant_id, tenant_name, ...}; auth_source is 'profile + keyring' or a stateless env credential; exit 3 if unauthenticated",
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runStatus(ctx context.Context, fopts *cmdutil.FormatOptions, f *cmdutil.Factory, svc StatusService) error {
|
||||
if svc == nil {
|
||||
return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated, "no SDK client available; run `weknora auth login`")
|
||||
}
|
||||
resp, err := svc.GetCurrentUser(ctx)
|
||||
if err != nil {
|
||||
return cmdutil.WrapHTTP(err, "fetch current user")
|
||||
}
|
||||
user := resp.Data.User
|
||||
tenant := resp.Data.Tenant
|
||||
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Effective host + auth source. Stateless env credentials
|
||||
// (WEKNORA_TOKEN/WEKNORA_API_KEY) override the profile + keyring for the
|
||||
// client, so report the host they authenticate against (WEKNORA_HOST) and
|
||||
// that they are in effect — not the bypassed config profile's host.
|
||||
host := ""
|
||||
if c, ok := cfg.Profiles[cfg.CurrentProfile]; ok {
|
||||
host = c.Host
|
||||
}
|
||||
authSource := "profile + keyring"
|
||||
if active, kind := cmdutil.EnvCredential(); active {
|
||||
authSource = kind + " env (stateless)"
|
||||
if h := strings.TrimSpace(os.Getenv("WEKNORA_HOST")); h != "" {
|
||||
host = h
|
||||
}
|
||||
}
|
||||
|
||||
if fopts.WantsJSON() {
|
||||
result := statusResult{Profile: cfg.CurrentProfile, Host: host, AuthSource: authSource}
|
||||
if user != nil {
|
||||
result.UserID = user.ID
|
||||
result.Username = user.Username
|
||||
result.Email = user.Email
|
||||
result.IsActive = user.IsActive
|
||||
result.CanAccessAllTenants = user.CanAccessAllTenants
|
||||
result.TenantID = user.TenantID
|
||||
}
|
||||
if tenant != nil {
|
||||
result.TenantName = tenant.Name
|
||||
}
|
||||
return fopts.Emit(iostreams.IO.Out, result, nil)
|
||||
}
|
||||
|
||||
fmt.Fprintf(iostreams.IO.Out, "profile: %s\n", cfg.CurrentProfile)
|
||||
fmt.Fprintf(iostreams.IO.Out, "auth_source: %s\n", authSource)
|
||||
fmt.Fprintf(iostreams.IO.Out, "host: %s\n", host)
|
||||
if user != nil {
|
||||
fmt.Fprintf(iostreams.IO.Out, "user: %s (%s)\n", user.Email, user.ID)
|
||||
fmt.Fprintf(iostreams.IO.Out, "tenant: %d", user.TenantID)
|
||||
if tenant != nil {
|
||||
fmt.Fprintf(iostreams.IO.Out, " (%s)", tenant.Name)
|
||||
}
|
||||
fmt.Fprintln(iostreams.IO.Out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/config"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
"github.com/Tencent/WeKnora/cli/internal/prompt"
|
||||
"github.com/Tencent/WeKnora/cli/internal/testutil"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
type fakeStatusService struct {
|
||||
resp *sdk.CurrentUserResponse
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeStatusService) GetCurrentUser(_ context.Context) (*sdk.CurrentUserResponse, error) {
|
||||
return f.resp, f.err
|
||||
}
|
||||
|
||||
func newCurrentUserResponse(user *sdk.AuthUser, tenant *sdk.AuthTenant) *sdk.CurrentUserResponse {
|
||||
r := &sdk.CurrentUserResponse{Success: true}
|
||||
r.Data.User = user
|
||||
r.Data.Tenant = tenant
|
||||
return r
|
||||
}
|
||||
|
||||
func TestRunStatus_TextOutput(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
testutil.XDGTempDir(t)
|
||||
require.NoError(t, config.Save(&config.Config{
|
||||
CurrentProfile: "prod",
|
||||
Profiles: map[string]config.Profile{
|
||||
"prod": {Host: "https://kb.example.com", TenantID: 7},
|
||||
},
|
||||
}))
|
||||
f := &cmdutil.Factory{
|
||||
Config: func() (*config.Config, error) { return config.Load() },
|
||||
Prompter: func() prompt.Prompter { return prompt.AgentPrompter{} },
|
||||
}
|
||||
svc := &fakeStatusService{
|
||||
resp: newCurrentUserResponse(
|
||||
&sdk.AuthUser{ID: "u1", Email: "alice@example.com", TenantID: 7},
|
||||
&sdk.AuthTenant{ID: 7, Name: "Acme"},
|
||||
),
|
||||
}
|
||||
require.NoError(t, runStatus(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, svc))
|
||||
got := out.String()
|
||||
assert.Contains(t, got, "profile: prod")
|
||||
assert.Contains(t, got, "auth_source: profile + keyring")
|
||||
assert.Contains(t, got, "host: https://kb.example.com")
|
||||
assert.Contains(t, got, "alice@example.com")
|
||||
assert.Contains(t, got, "Acme")
|
||||
}
|
||||
|
||||
func TestRunStatus_JSONOutput(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
testutil.XDGTempDir(t)
|
||||
require.NoError(t, config.Save(&config.Config{
|
||||
CurrentProfile: "prod",
|
||||
Profiles: map[string]config.Profile{"prod": {Host: "https://x"}},
|
||||
}))
|
||||
f := &cmdutil.Factory{Config: func() (*config.Config, error) { return config.Load() }}
|
||||
svc := &fakeStatusService{resp: newCurrentUserResponse(&sdk.AuthUser{ID: "u1", Email: "a@b.c", TenantID: 7}, nil)}
|
||||
require.NoError(t, runStatus(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, f, svc))
|
||||
got := out.String()
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal([]byte(got), &env), "expected valid JSON envelope, got: %q", got)
|
||||
assert.True(t, env.OK, "envelope.ok must be true")
|
||||
assert.Equal(t, "prod", env.Data["profile"], "profile field should be prod")
|
||||
assert.Contains(t, got, `"email":"a@b.c"`)
|
||||
}
|
||||
|
||||
func TestRunStatus_NoSDKClient(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
err := runStatus(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, &cmdutil.Factory{}, nil)
|
||||
require.Error(t, err)
|
||||
assert.True(t, cmdutil.IsAuthError(err))
|
||||
}
|
||||
|
||||
func TestRunStatus_SDKError_Transport(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
testutil.XDGTempDir(t)
|
||||
require.NoError(t, config.Save(&config.Config{CurrentProfile: "p", Profiles: map[string]config.Profile{"p": {Host: "https://x"}}}))
|
||||
f := &cmdutil.Factory{Config: func() (*config.Config, error) { return config.Load() }}
|
||||
err := runStatus(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, &fakeStatusService{err: assert.AnError})
|
||||
require.Error(t, err)
|
||||
// Non-HTTP errors (DNS / TCP) are transport problems, not auth problems -
|
||||
// classify network.error so retry logic / exit code 7 / IsTransient apply.
|
||||
assert.True(t, cmdutil.IsTransient(err), "expected transient/network classification, got %v", err)
|
||||
}
|
||||
|
||||
func TestRunStatus_SDKError_HTTP401(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
testutil.XDGTempDir(t)
|
||||
require.NoError(t, config.Save(&config.Config{CurrentProfile: "p", Profiles: map[string]config.Profile{"p": {Host: "https://x"}}}))
|
||||
f := &cmdutil.Factory{Config: func() (*config.Config, error) { return config.Load() }}
|
||||
err := runStatus(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, &fakeStatusService{err: errors.New("HTTP error 401: invalid token")})
|
||||
require.Error(t, err)
|
||||
assert.True(t, cmdutil.IsAuthError(err))
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
)
|
||||
|
||||
// authTokenFields lists fields surfaced in `--help` as a hint for `--jq`
|
||||
// projection. Single-resource shape: emits the bare token object directly.
|
||||
var authTokenFields = []string{"token", "mode", "profile"}
|
||||
|
||||
type tokenResult struct {
|
||||
Token string `json:"token"`
|
||||
Mode string `json:"mode"` // ModeBearer (JWT) or ModeAPIKey
|
||||
Profile string `json:"profile"`
|
||||
}
|
||||
|
||||
// NewCmdToken builds `weknora auth token`. Prints the active profile's
|
||||
// credential to stdout for use in shell pipelines, e.g.
|
||||
//
|
||||
// WEKNORA_TOKEN=$(weknora auth token)
|
||||
// curl -H "Authorization: Bearer $WEKNORA_TOKEN" ... # JWT mode
|
||||
// curl -H "X-API-Key: $WEKNORA_TOKEN" ... # api-key mode
|
||||
//
|
||||
// The user is responsible for constructing the appropriate header -
|
||||
// `auth list` shows which mode each profile uses.
|
||||
//
|
||||
// Default output: raw token on stdout, no trailing newline (clean $(...)).
|
||||
// `--format json[=fields]` emits a bare {token, mode, profile} object.
|
||||
func NewCmdToken(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "token",
|
||||
Short: "Print the active profile's credential to stdout",
|
||||
Long: `Print the active profile's credential to stdout, with no trailing
|
||||
newline, suitable for shell command substitution.
|
||||
|
||||
The credential is the long-lived API key (mode: api-key) or the access JWT
|
||||
(mode: bearer), depending on how the profile was created. Run ` + "`weknora auth list`" + `
|
||||
to see which mode each profile uses, and construct the matching HTTP header:
|
||||
|
||||
Authorization: Bearer <token> # bearer mode
|
||||
X-API-Key: <token> # api-key mode
|
||||
|
||||
` + "`--profile <name>`" + ` (global flag) selects a non-active profile to read from.`,
|
||||
Example: ` WEKNORA_TOKEN=$(weknora auth token)
|
||||
weknora auth token --profile staging
|
||||
weknora auth token --format json`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(c *cobra.Command, _ []string) error {
|
||||
fopts, err := cmdutil.CheckFormatFlag(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// `auth token` is a scalar scripting helper (WEKNORA_TOKEN=$(...)),
|
||||
// so it defaults to the raw token — overriding the global json
|
||||
// default, the same way `doc download` streams raw bytes. Explicit
|
||||
// --format json / WEKNORA_FORMAT=json still emit the
|
||||
// {token,mode,profile} envelope.
|
||||
fopts.FromEnv()
|
||||
if fopts.Mode == "" {
|
||||
// --jq implies JSON (it filters the envelope); without it,
|
||||
// default to the raw token for clean $(...) capture.
|
||||
if fopts.JQ != "" {
|
||||
fopts.Mode = cmdutil.FormatJSON
|
||||
} else {
|
||||
fopts.Mode = cmdutil.FormatText
|
||||
}
|
||||
}
|
||||
return runToken(f, fopts)
|
||||
},
|
||||
}
|
||||
cmdutil.AddFormatFlag(cmd, authTokenFields...)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "print the active profile's raw credential to stdout for shell capture",
|
||||
Examples: []string{
|
||||
"WEKNORA_TOKEN=$(weknora auth token)",
|
||||
"weknora auth token --profile staging",
|
||||
"weknora auth token --format json",
|
||||
},
|
||||
Output: "raw token on stdout (no envelope, no trailing newline) by default; --format json emits {token, mode, profile}",
|
||||
Warnings: []string{
|
||||
"default output is the bare token, NOT the JSON envelope — it is a scripting helper for shell capture",
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runToken(f *cmdutil.Factory, fopts *cmdutil.FormatOptions) error {
|
||||
// Env credentials are the active credential on the headless path — `auth
|
||||
// token` must surface them (they ARE the token / api key) instead of
|
||||
// erroring on the absence of a stored profile.
|
||||
if active, kind := cmdutil.EnvCredential(); active {
|
||||
mode := ModeBearer
|
||||
if kind == "WEKNORA_API_KEY" {
|
||||
mode = ModeAPIKey
|
||||
}
|
||||
return emitToken(fopts, os.Getenv(kind), mode, "(env)")
|
||||
}
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
profileName := cfg.CurrentProfile
|
||||
if f.ProfileOverride != "" {
|
||||
profileName = f.ProfileOverride
|
||||
}
|
||||
if profileName == "" {
|
||||
return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated,
|
||||
"no active profile configured")
|
||||
}
|
||||
ctx, ok := cfg.Profiles[profileName]
|
||||
if !ok {
|
||||
return cmdutil.NewError(cmdutil.CodeLocalProfileNotFound,
|
||||
fmt.Sprintf("profile %q not found", profileName))
|
||||
}
|
||||
|
||||
store, err := f.Secrets()
|
||||
if err != nil {
|
||||
return cmdutil.Wrapf(cmdutil.CodeLocalKeychainDenied, err, "init secrets store")
|
||||
}
|
||||
|
||||
// Resolve the stored credential. Prefer bearer (JWT) when both refs
|
||||
// are present - JWT is the more capable mode and what `buildClient`
|
||||
// uses for the Authorization header (see factory.go buildClient).
|
||||
var token, mode string
|
||||
switch {
|
||||
case ctx.TokenRef != "":
|
||||
v, ferr := cmdutil.LoadSecret(store, profileName, "access")
|
||||
if ferr != nil {
|
||||
return ferr
|
||||
}
|
||||
token, mode = v, ModeBearer
|
||||
case ctx.APIKeyRef != "":
|
||||
v, ferr := cmdutil.LoadSecret(store, profileName, "api_key")
|
||||
if ferr != nil {
|
||||
return ferr
|
||||
}
|
||||
token, mode = v, ModeAPIKey
|
||||
default:
|
||||
return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated,
|
||||
fmt.Sprintf("profile %q has no stored credential; run `weknora auth login`", profileName))
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated,
|
||||
fmt.Sprintf("profile %q credential is empty in keyring; run `weknora auth login`", profileName))
|
||||
}
|
||||
|
||||
return emitToken(fopts, token, mode, profileName)
|
||||
}
|
||||
|
||||
// emitToken renders a resolved credential: the {token, mode, profile} envelope
|
||||
// under --format json, else the raw token on stdout (no trailing newline, for
|
||||
// clean $(weknora auth token) capture) with a TTY-only leak hint on stderr.
|
||||
func emitToken(fopts *cmdutil.FormatOptions, token, mode, profile string) error {
|
||||
if token == "" {
|
||||
return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated, "active credential is empty")
|
||||
}
|
||||
if fopts.WantsJSON() {
|
||||
return fopts.Emit(iostreams.IO.Out, tokenResult{Token: token, Mode: mode, Profile: profile}, nil)
|
||||
}
|
||||
|
||||
// No trailing newline - clean $(weknora auth token) substitution.
|
||||
fmt.Fprint(iostreams.IO.Out, token)
|
||||
// Defensive hint to stderr when stdout is an interactive terminal:
|
||||
// the user likely didn't mean to display the secret on screen.
|
||||
// stderr-only so scripts (always non-TTY) are unaffected. Mode-specific
|
||||
// because api-key tokens are long-lived and rotation is the only
|
||||
// recourse on leak - bearer tokens self-expire via refresh.
|
||||
if iostreams.IO.IsStdoutTTY() {
|
||||
fmt.Fprintln(iostreams.IO.Err)
|
||||
fmt.Fprintln(iostreams.IO.Err, "hint: pipe to $(weknora auth token) to capture; this terminal scrollback now contains the secret")
|
||||
if mode == ModeAPIKey {
|
||||
fmt.Fprintln(iostreams.IO.Err, "note: api-key credentials are long-lived - rotate via your auth provider if exposed (no `auth refresh` path)")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/config"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
"github.com/Tencent/WeKnora/cli/internal/secrets"
|
||||
)
|
||||
|
||||
// tokenTestFactory wires a config + in-memory secrets store the same way
|
||||
// the production Factory does, so runToken exercises the real LoadSecret
|
||||
// path.
|
||||
func tokenTestFactory(t *testing.T, cfg *config.Config, store *secrets.MemStore) *cmdutil.Factory {
|
||||
t.Helper()
|
||||
f := &cmdutil.Factory{
|
||||
Config: func() (*config.Config, error) { return cfg, nil },
|
||||
Secrets: func() (secrets.Store, error) { return store, nil },
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// TestAuthToken_DefaultIsRawToken locks the scripting contract: with no
|
||||
// explicit --format, `weknora auth token` emits the raw token (so
|
||||
// WEKNORA_TOKEN=$(weknora auth token) works), NOT the JSON envelope — even
|
||||
// though the global --format default is json. Explicit --format json still
|
||||
// emits the {token,mode,profile} envelope (covered by the JSON test below).
|
||||
func TestAuthToken_DefaultIsRawToken(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "prod",
|
||||
Profiles: map[string]config.Profile{
|
||||
"prod": {Host: "https://kb.example.com", TokenRef: "prod:access"},
|
||||
},
|
||||
}
|
||||
store := secrets.NewMemStore()
|
||||
_ = store.Set("prod", "access", "jwt-default-xyz")
|
||||
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
cmd := NewCmdToken(tokenTestFactory(t, cfg, store))
|
||||
cmd.PersistentFlags().String("format", "", "") // mirror the root persistent flag, unset
|
||||
cmd.SetArgs([]string{})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if got := out.String(); got != "jwt-default-xyz" {
|
||||
t.Errorf("default output must be the raw token (not an envelope); got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthToken_JQImpliesJSON: --jq filters the envelope, so passing it without
|
||||
// an explicit --format must resolve to JSON (not the raw-token default), else
|
||||
// the filter would be silently dropped.
|
||||
func TestAuthToken_JQImpliesJSON(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "prod",
|
||||
Profiles: map[string]config.Profile{
|
||||
"prod": {Host: "https://kb.example.com", TokenRef: "prod:access"},
|
||||
},
|
||||
}
|
||||
store := secrets.NewMemStore()
|
||||
_ = store.Set("prod", "access", "jwt-jq-xyz")
|
||||
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
cmd := NewCmdToken(tokenTestFactory(t, cfg, store))
|
||||
cmd.PersistentFlags().String("format", "", "")
|
||||
cmd.PersistentFlags().StringP("jq", "q", "", "")
|
||||
// Structural projection: proves jq ran on the JSON envelope (object out),
|
||||
// not the raw-token text path (which would print the bare string).
|
||||
cmd.SetArgs([]string{"--jq", ".data | {token}"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
|
||||
t.Fatalf("--jq without --format must filter the JSON envelope, got non-JSON %q: %v", out.String(), err)
|
||||
}
|
||||
if got["token"] != "jwt-jq-xyz" {
|
||||
t.Errorf("jq projection wrong: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthToken_BearerMode_PlainOutput(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "prod",
|
||||
Profiles: map[string]config.Profile{
|
||||
"prod": {Host: "https://kb.example.com", TokenRef: "prod:access", RefreshRef: "prod:refresh"},
|
||||
},
|
||||
}
|
||||
store := secrets.NewMemStore()
|
||||
_ = store.Set("prod", "access", "jwt-token-xyz")
|
||||
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
err := runToken(tokenTestFactory(t, cfg, store), &cmdutil.FormatOptions{Mode: cmdutil.FormatText})
|
||||
if err != nil {
|
||||
t.Fatalf("runToken: %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
if got != "jwt-token-xyz" {
|
||||
t.Errorf("expected raw token, got %q", got)
|
||||
}
|
||||
if strings.HasSuffix(got, "\n") {
|
||||
t.Errorf("output must NOT end with newline (clean $(...) substitution); got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthToken_APIKeyMode_PlainOutput(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "ci",
|
||||
Profiles: map[string]config.Profile{
|
||||
"ci": {Host: "https://kb.example.com", APIKeyRef: "ci:api_key"},
|
||||
},
|
||||
}
|
||||
store := secrets.NewMemStore()
|
||||
_ = store.Set("ci", "api_key", "sk_test_apikey_42")
|
||||
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
if err := runToken(tokenTestFactory(t, cfg, store), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}); err != nil {
|
||||
t.Fatalf("runToken: %v", err)
|
||||
}
|
||||
if got := out.String(); got != "sk_test_apikey_42" {
|
||||
t.Errorf("expected api-key value, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthToken_JSON(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "prod",
|
||||
Profiles: map[string]config.Profile{
|
||||
"prod": {Host: "https://kb.example.com", TokenRef: "prod:access"},
|
||||
},
|
||||
}
|
||||
store := secrets.NewMemStore()
|
||||
_ = store.Set("prod", "access", "jwt-xyz")
|
||||
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
if err := runToken(tokenTestFactory(t, cfg, store), &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}); err != nil {
|
||||
t.Fatalf("runToken: %v", err)
|
||||
}
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data struct {
|
||||
Token string `json:"token"`
|
||||
Mode string `json:"mode"`
|
||||
Profile string `json:"profile"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("parse: %v\n%s", err, out.String())
|
||||
}
|
||||
got := env.Data
|
||||
if got.Token != "jwt-xyz" || got.Mode != "bearer" || got.Profile != "prod" {
|
||||
t.Errorf("payload wrong: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthToken_JSON_JQProjection(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "ci",
|
||||
Profiles: map[string]config.Profile{
|
||||
"ci": {Host: "https://kb.example.com", APIKeyRef: "ci:api_key"},
|
||||
},
|
||||
}
|
||||
store := secrets.NewMemStore()
|
||||
_ = store.Set("ci", "api_key", "sk_42")
|
||||
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
// jq projects from the envelope; .data | {token} extracts from the data object inside envelope.
|
||||
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON, JQ: ".data | {token}"}
|
||||
if err := runToken(tokenTestFactory(t, cfg, store), fopts); err != nil {
|
||||
t.Fatalf("runToken: %v", err)
|
||||
}
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if _, has := got["mode"]; has {
|
||||
t.Errorf("mode should be filtered out: %+v", got)
|
||||
}
|
||||
if got["token"] != "sk_42" {
|
||||
t.Errorf("token wrong: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthToken_NoCurrentProfile(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
store := secrets.NewMemStore()
|
||||
iostreams.SetForTest(t)
|
||||
err := runToken(tokenTestFactory(t, cfg, store), &cmdutil.FormatOptions{Mode: cmdutil.FormatText})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !cmdutil.IsAuthError(err) {
|
||||
t.Errorf("want auth.* code, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthToken_ProfileOverride(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "prod",
|
||||
Profiles: map[string]config.Profile{
|
||||
"prod": {Host: "https://prod.example.com", TokenRef: "prod:access"},
|
||||
"staging": {Host: "https://staging.example.com", APIKeyRef: "staging:api_key"},
|
||||
},
|
||||
}
|
||||
store := secrets.NewMemStore()
|
||||
_ = store.Set("prod", "access", "prod-jwt")
|
||||
_ = store.Set("staging", "api_key", "staging-key")
|
||||
|
||||
f := tokenTestFactory(t, cfg, store)
|
||||
f.ProfileOverride = "staging"
|
||||
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
if err := runToken(f, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}); err != nil {
|
||||
t.Fatalf("runToken: %v", err)
|
||||
}
|
||||
if got := out.String(); got != "staging-key" {
|
||||
t.Errorf("expected staging-key (override), got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthToken_NoStoredCredential(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "prod",
|
||||
Profiles: map[string]config.Profile{
|
||||
"prod": {Host: "https://kb.example.com", TokenRef: "prod:access"},
|
||||
},
|
||||
}
|
||||
store := secrets.NewMemStore()
|
||||
// no Set - keyring is empty
|
||||
iostreams.SetForTest(t)
|
||||
err := runToken(tokenTestFactory(t, cfg, store), &cmdutil.FormatOptions{Mode: cmdutil.FormatText})
|
||||
if err == nil {
|
||||
t.Fatal("expected auth.unauthenticated, got nil")
|
||||
}
|
||||
if !cmdutil.IsAuthError(err) {
|
||||
t.Errorf("want auth.*, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthToken_ContextWithNoCredentialRefs(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "empty",
|
||||
Profiles: map[string]config.Profile{
|
||||
"empty": {Host: "https://kb.example.com"}, // no TokenRef or APIKeyRef
|
||||
},
|
||||
}
|
||||
store := secrets.NewMemStore()
|
||||
iostreams.SetForTest(t)
|
||||
err := runToken(tokenTestFactory(t, cfg, store), &cmdutil.FormatOptions{Mode: cmdutil.FormatText})
|
||||
if err == nil {
|
||||
t.Fatal("expected auth.unauthenticated, got nil")
|
||||
}
|
||||
if !cmdutil.IsAuthError(err) {
|
||||
t.Errorf("want auth.*, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- stderr advisory tests --------------------------------------------------
|
||||
//
|
||||
// auth token prints the token to stdout unconditionally. When stdout is an
|
||||
// interactive terminal, it ALSO writes a stderr advisory ("you just put the
|
||||
// secret in your scrollback") + a mode-specific rotation note for api-key
|
||||
// credentials. The stdout half must stay clean under all modes so $(...)
|
||||
// substitution is unaffected - tests assert both axes.
|
||||
|
||||
func makeBearerCfg() (*config.Config, *secrets.MemStore) {
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "prod",
|
||||
Profiles: map[string]config.Profile{
|
||||
"prod": {Host: "https://kb.example.com", TokenRef: "prod:access"},
|
||||
},
|
||||
}
|
||||
store := secrets.NewMemStore()
|
||||
_ = store.Set("prod", "access", "jwt-xyz")
|
||||
return cfg, store
|
||||
}
|
||||
|
||||
func makeAPIKeyCfg() (*config.Config, *secrets.MemStore) {
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "ci",
|
||||
Profiles: map[string]config.Profile{
|
||||
"ci": {Host: "https://kb.example.com", APIKeyRef: "ci:api_key"},
|
||||
},
|
||||
}
|
||||
store := secrets.NewMemStore()
|
||||
_ = store.Set("ci", "api_key", "sk_42")
|
||||
return cfg, store
|
||||
}
|
||||
|
||||
func TestAuthToken_NonTTY_NoStderrHint(t *testing.T) {
|
||||
cfg, store := makeBearerCfg()
|
||||
out, errBuf := iostreams.SetForTest(t)
|
||||
if err := runToken(tokenTestFactory(t, cfg, store), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}); err != nil {
|
||||
t.Fatalf("runToken: %v", err)
|
||||
}
|
||||
if out.String() != "jwt-xyz" {
|
||||
t.Errorf("stdout = %q, want %q", out.String(), "jwt-xyz")
|
||||
}
|
||||
if errBuf.Len() != 0 {
|
||||
t.Errorf("non-TTY stderr should be empty (scripts depend on this), got %q", errBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthToken_TTY_BearerMode_StderrHintNoRotationNote(t *testing.T) {
|
||||
cfg, store := makeBearerCfg()
|
||||
out, errBuf := iostreams.SetForTestWithTTY(t)
|
||||
if err := runToken(tokenTestFactory(t, cfg, store), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}); err != nil {
|
||||
t.Fatalf("runToken: %v", err)
|
||||
}
|
||||
if out.String() != "jwt-xyz" {
|
||||
t.Errorf("stdout = %q, want raw token only", out.String())
|
||||
}
|
||||
if !strings.Contains(errBuf.String(), "scrollback") {
|
||||
t.Errorf("expected stderr scrollback hint on TTY, got %q", errBuf.String())
|
||||
}
|
||||
if strings.Contains(errBuf.String(), "api-key") {
|
||||
t.Errorf("bearer mode should not surface the api-key rotation note: %q", errBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthToken_TTY_APIKeyMode_IncludesRotationNote(t *testing.T) {
|
||||
cfg, store := makeAPIKeyCfg()
|
||||
out, errBuf := iostreams.SetForTestWithTTY(t)
|
||||
if err := runToken(tokenTestFactory(t, cfg, store), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}); err != nil {
|
||||
t.Fatalf("runToken: %v", err)
|
||||
}
|
||||
if out.String() != "sk_42" {
|
||||
t.Errorf("stdout = %q, want raw token only", out.String())
|
||||
}
|
||||
stderr := errBuf.String()
|
||||
if !strings.Contains(stderr, "scrollback") {
|
||||
t.Errorf("api-key TTY stderr should still include the scrollback hint, got %q", stderr)
|
||||
}
|
||||
if !strings.Contains(stderr, "long-lived") || !strings.Contains(stderr, "rotate") {
|
||||
t.Errorf("api-key mode should include rotation note, got %q", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthToken_TTY_JSONMode_NoStderrHint(t *testing.T) {
|
||||
// --format json output mode targets script/agent consumers even when stdout
|
||||
// happens to be a TTY (e.g. an IDE running the CLI on the user's
|
||||
// behalf). Hint would pollute their parsing - suppress.
|
||||
cfg, store := makeBearerCfg()
|
||||
_, errBuf := iostreams.SetForTestWithTTY(t)
|
||||
if err := runToken(tokenTestFactory(t, cfg, store), &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}); err != nil {
|
||||
t.Fatalf("runToken: %v", err)
|
||||
}
|
||||
if errBuf.Len() != 0 {
|
||||
t.Errorf("JSON mode should not emit stderr hint, got %q", errBuf.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
// Package chat implements `weknora chat <text>` - the streaming RAG answer
|
||||
// entry point.
|
||||
//
|
||||
// Three output modes share a single SDK call:
|
||||
//
|
||||
// - JSON mode (--format json, the default): buffer a bounded projection of
|
||||
// the stream into one success envelope. The default projection contains
|
||||
// answer events only; --reference adds indexed citations and --verbose
|
||||
// adds reasoning, tools, and lifecycle frames.
|
||||
//
|
||||
// - Text mode (--format text): render the same projection as JSON
|
||||
// directly to iostreams.IO.Out as it arrives.
|
||||
//
|
||||
// - NDJSON mode (--format ndjson): inject a CLI "init" event at stream
|
||||
// head, then pass through every SDK event verbatim as NDJSON lines —
|
||||
// the raw protocol trace, for debugging / advanced consumers.
|
||||
//
|
||||
// The SDK's KnowledgeQAStream callback contract is invoked sequentially on
|
||||
// one goroutine, so no mode needs locking. The runChat core takes a
|
||||
// ChatService interface so tests inject a fake without standing up a real
|
||||
// SSE server.
|
||||
package chat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
"github.com/Tencent/WeKnora/cli/internal/output"
|
||||
"github.com/Tencent/WeKnora/cli/internal/sse"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// chatFields enumerates the fields surfaced for `--jq` projection discovery
|
||||
// on `chat`: the json object's data fields + the raw SDK event vocabulary
|
||||
// used by --format ndjson.
|
||||
var chatFields = []string{
|
||||
"events", "session_id", "assistant_message_id", "kb_id", "query",
|
||||
// NDJSON init + SDK event fields.
|
||||
"type", "kb_id", "profile", "response_type", "content", "done", "knowledge_references", "data",
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
Query string
|
||||
KBID string
|
||||
SessionID string
|
||||
// Reference adds bounded kb_id/chunk_id reference events to JSON/text.
|
||||
Reference bool
|
||||
// Verbose surfaces the projected execution trace in JSON/text,
|
||||
// including reasoning, tools, and lifecycle events.
|
||||
// NDJSON is always raw and is unaffected by presentation flags.
|
||||
Verbose bool
|
||||
}
|
||||
|
||||
// ChatService is the narrow SDK surface this command depends on. *sdk.Client
|
||||
// satisfies it; tests substitute a fake. Compile-time check is at the bottom
|
||||
// of this file.
|
||||
type ChatService interface {
|
||||
CreateSession(ctx context.Context, req *sdk.CreateSessionRequest) (*sdk.Session, error)
|
||||
KnowledgeQAStream(ctx context.Context, sessionID string, req *sdk.KnowledgeQARequest, cb func(*sdk.StreamResponse) error, opts ...sdk.ResourceURLOptions) error
|
||||
}
|
||||
|
||||
// NewCmd builds `weknora chat <text>`.
|
||||
func NewCmd(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &Options{}
|
||||
cmd := &cobra.Command{
|
||||
Use: `chat "<text>"`,
|
||||
Short: "Ask a streaming RAG question against a knowledge base",
|
||||
Long: `Send a query to the WeKnora knowledge-chat endpoint and stream the
|
||||
answer back. By default a fresh session is created on first invocation; pass
|
||||
--session to continue an existing conversation.
|
||||
|
||||
Modes:
|
||||
--format json (default): one JSON envelope with answer events
|
||||
--format text: live human-readable answer stream
|
||||
--format ndjson: raw NDJSON event stream — init line (session_id,
|
||||
kb_id) then SDK events verbatim. Debug / advanced.
|
||||
|
||||
Pass --reference to include bounded kb_id/chunk_id reference indexes. Pass
|
||||
--verbose to include reasoning, tool activity, and lifecycle frames. Combine
|
||||
both flags for the complete projected stream.`,
|
||||
Example: ` weknora chat "What is RRF?" --kb a32a63ff-fb36-4874-bcaa-30f48570a694
|
||||
weknora chat "Summarise this design doc" --kb my-kb --format json
|
||||
weknora chat "Continue?" --session sess_abc`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
opts.Query = strings.TrimSpace(args[0])
|
||||
if opts.Query == "" {
|
||||
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "query argument cannot be empty")
|
||||
}
|
||||
fopts, err := cmdutil.CheckFormatFlag(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
|
||||
kbID, err := f.ResolveKB(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts.KBID = kbID
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runChat(c.Context(), opts, fopts, cli)
|
||||
},
|
||||
}
|
||||
cmdutil.AddKBFlag(cmd)
|
||||
cmd.Flags().StringVar(&opts.SessionID, "session", "", "Continue an existing chat session (skip auto-create)")
|
||||
cmd.Flags().BoolVar(&opts.Reference, "reference", false, "Include indexed references in JSON/text output")
|
||||
cmd.Flags().BoolVar(&opts.Verbose, "verbose", false, "Include reasoning, tools, and lifecycle events in JSON/text output")
|
||||
cmdutil.AddFormatFlag(cmd, chatFields...)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "Ask a RAG question against a knowledge base. Default JSON returns a bounded answer-event projection. --reference adds indexed citations; --verbose adds reasoning, tools, and lifecycle events. --format ndjson streams raw SDK events; --format text renders the selected events live.",
|
||||
RequiredFlags: []string{"<text> (positional)", "--kb"},
|
||||
Examples: []string{
|
||||
`weknora chat "What is RRF?" --kb kb_abc`,
|
||||
`weknora chat "What is RRF?" --kb kb_abc --jq '[.data.events[].content] | join("")'`,
|
||||
},
|
||||
Output: "Default --format json: {ok,data:{events:[answer...],session_id,kb_id,query}}. --reference adds kb_id/chunk_id reference events; --verbose adds execution events. --format ndjson remains raw.",
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
// runChat is the testable core: validate, ensure a session, dispatch the
|
||||
// stream, and route output. Returns a typed error.
|
||||
func runChat(ctx context.Context, opts *Options, fopts *cmdutil.FormatOptions, svc ChatService) error {
|
||||
if opts.Query == "" {
|
||||
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "query argument cannot be empty")
|
||||
}
|
||||
if opts.KBID == "" {
|
||||
// Defensive: the cobra layer resolves KB before runChat; this guards
|
||||
// the direct-test entry point.
|
||||
return cmdutil.NewError(cmdutil.CodeKBIDRequired, "kb id is required")
|
||||
}
|
||||
if svc == nil {
|
||||
return cmdutil.NewError(cmdutil.CodeServerError, "chat: no SDK client available")
|
||||
}
|
||||
|
||||
// --format selects the output shape: json (default) accumulates and
|
||||
// emits one object, ndjson streams raw SDK events, text renders live.
|
||||
sessionID := opts.SessionID
|
||||
autoCreated := false
|
||||
if sessionID == "" {
|
||||
sess, err := svc.CreateSession(ctx, &sdk.CreateSessionRequest{Title: "weknora chat"})
|
||||
if err != nil {
|
||||
// Ctrl-C during session creation: classify as cancelled so the
|
||||
// hint nudges the user toward retry-with-signal-clean, not
|
||||
// "pass --session" as session_create_failed would.
|
||||
if cmdutil.IsCancelled(ctx, err) {
|
||||
return cmdutil.Wrapf(cmdutil.CodeOperationCancelled, err, "chat cancelled")
|
||||
}
|
||||
// Map HTTP-shaped failures, but tag generic transport / unknown
|
||||
// errors as session_create_failed so the dedicated hint fires.
|
||||
code := cmdutil.ClassifyHTTPError(err)
|
||||
if code == cmdutil.CodeNetworkError || code == cmdutil.CodeServerError {
|
||||
code = cmdutil.CodeSessionCreateFailed
|
||||
}
|
||||
return cmdutil.Wrapf(code, err, "create chat session")
|
||||
}
|
||||
sessionID = sess.ID
|
||||
autoCreated = true
|
||||
}
|
||||
|
||||
if fopts != nil && fopts.Mode == cmdutil.FormatNDJSON {
|
||||
return runChatNDJSON(ctx, opts, sessionID, svc)
|
||||
}
|
||||
if fopts != nil && fopts.Mode == cmdutil.FormatJSON {
|
||||
return runChatJSON(ctx, opts, fopts, sessionID, svc)
|
||||
}
|
||||
|
||||
// Surface the auto-created session ID up-front so a user who hits ^C
|
||||
// mid-stream still has the pointer to resume - no need to scroll back
|
||||
// past tokens. Skipped in json/ndjson mode (session_id is in the output).
|
||||
if autoCreated {
|
||||
fmt.Fprintf(iostreams.IO.Err, "session: %s (use --session to continue)\n", sessionID)
|
||||
}
|
||||
|
||||
return runChatText(ctx, opts, sessionID, autoCreated, svc)
|
||||
}
|
||||
|
||||
// runChatNDJSON handles the --format ndjson path: emits a CLI init event at
|
||||
// stream head, then passes every SDK event through verbatim as NDJSON lines.
|
||||
// No buffering — callers parse the stream incrementally.
|
||||
func runChatNDJSON(ctx context.Context, opts *Options, sessionID string, svc ChatService) error {
|
||||
w := iostreams.IO.Out
|
||||
|
||||
// 1. Inject the CLI-managed init event at the head of the stream.
|
||||
// Carries the session pointer + retrieval context callers need for
|
||||
// follow-up threading.
|
||||
initEv := output.InitEvent{
|
||||
SessionID: sessionID,
|
||||
KBID: opts.KBID,
|
||||
Profile: cmdutil.GetProfile(),
|
||||
}
|
||||
if err := output.EmitInit(w, initEv); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 2. Open SDK stream and pass each event through as a bare NDJSON line.
|
||||
req := &sdk.KnowledgeQARequest{
|
||||
Query: opts.Query,
|
||||
KnowledgeBaseIDs: []string{opts.KBID},
|
||||
AgentEnabled: false,
|
||||
WebSearchEnabled: false,
|
||||
Channel: "api",
|
||||
}
|
||||
cb := func(r *sdk.StreamResponse) error {
|
||||
// NDJSON is the raw protocol/debug surface: do not filter events or
|
||||
// mutate their payloads. JSON/text modes own presentation filtering.
|
||||
return output.EmitSDKEvent(w, r)
|
||||
}
|
||||
if err := svc.KnowledgeQAStream(ctx, sessionID, req, cb); err != nil {
|
||||
if cmdutil.IsCancelled(ctx, err) {
|
||||
return cmdutil.Wrapf(cmdutil.CodeOperationCancelled, err, "chat cancelled")
|
||||
}
|
||||
return cmdutil.WrapStream(err, "knowledge qa stream")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// runChatText handles the --format text path. It renders the same
|
||||
// projection as JSON immediately for both terminals and pipes.
|
||||
func runChatText(ctx context.Context, opts *Options, sessionID string, autoCreated bool, svc ChatService) error {
|
||||
req := &sdk.KnowledgeQARequest{
|
||||
Query: opts.Query,
|
||||
KnowledgeBaseIDs: []string{opts.KBID},
|
||||
AgentEnabled: false,
|
||||
WebSearchEnabled: false,
|
||||
Channel: "api",
|
||||
}
|
||||
|
||||
projector := sse.NewProjector(opts.Verbose, opts.Reference, opts.KBID)
|
||||
renderer := sse.NewTextRenderer(iostreams.IO.Out, opts.Verbose)
|
||||
|
||||
cb := func(r *sdk.StreamResponse) error {
|
||||
event, include := projector.Chat(r)
|
||||
if !include {
|
||||
return nil
|
||||
}
|
||||
return renderer.Write(event)
|
||||
}
|
||||
|
||||
streamErr := svc.KnowledgeQAStream(ctx, sessionID, req, cb)
|
||||
if streamErr != nil {
|
||||
// Re-surface the auto-created session id on failure so a user who
|
||||
// missed the start-of-stream notice (it scrolls past mid-stream
|
||||
// tokens, especially on ^C) can still recover with --session.
|
||||
if autoCreated {
|
||||
fmt.Fprintf(iostreams.IO.Err, "session: %s (resume with --session %s)\n", sessionID, sessionID)
|
||||
}
|
||||
// Context cancelled (Ctrl-C) → user-aborted, exit 130 lineage.
|
||||
if cmdutil.IsCancelled(ctx, streamErr) {
|
||||
return cmdutil.Wrapf(cmdutil.CodeOperationCancelled, streamErr, "chat cancelled")
|
||||
}
|
||||
// Stream began (we observed at least one event) but never reached a
|
||||
// terminal Done frame: typed as sse_stream_aborted so the hint
|
||||
// nudges the user toward a retry.
|
||||
if projector.Seen() && !projector.Done() {
|
||||
return cmdutil.Wrapf(cmdutil.CodeSSEStreamAborted, streamErr, "stream aborted before completion")
|
||||
}
|
||||
// Pre-stream HTTP / transport failure: route through the canonical
|
||||
// classifier so 401 / 404 / 5xx still surface their specific codes.
|
||||
return cmdutil.WrapStream(streamErr, "knowledge qa stream")
|
||||
}
|
||||
|
||||
// SDK returned nil but we never saw a Done event - server closed the
|
||||
// connection cleanly mid-stream. Treat as aborted so the user sees the
|
||||
// truncation rather than a silent partial answer. Includes the empty-body
|
||||
// case (Done frame never arrived AND no content): better to surface the
|
||||
// abort than emit ok=true with answer="" - agents can't distinguish the
|
||||
// model genuinely had nothing to say from the stream getting cut.
|
||||
if !projector.Done() {
|
||||
return cmdutil.NewError(cmdutil.CodeSSEStreamAborted, "stream ended without a terminal event")
|
||||
}
|
||||
return renderer.Close()
|
||||
}
|
||||
|
||||
// runChatJSON handles the --format json path (the default): collect the
|
||||
// projection and emit it in one normal success envelope.
|
||||
func runChatJSON(ctx context.Context, opts *Options, fopts *cmdutil.FormatOptions, sessionID string, svc ChatService) error {
|
||||
req := &sdk.KnowledgeQARequest{
|
||||
Query: opts.Query,
|
||||
KnowledgeBaseIDs: []string{opts.KBID},
|
||||
AgentEnabled: false,
|
||||
WebSearchEnabled: false,
|
||||
Channel: "api",
|
||||
}
|
||||
|
||||
projector := sse.NewProjector(opts.Verbose, opts.Reference, opts.KBID)
|
||||
events := make([]sse.ProjectedEvent, 0)
|
||||
cb := func(r *sdk.StreamResponse) error {
|
||||
if event, include := projector.Chat(r); include {
|
||||
events = append(events, event)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := svc.KnowledgeQAStream(ctx, sessionID, req, cb); err != nil {
|
||||
if cmdutil.IsCancelled(ctx, err) {
|
||||
return chatStreamError(cmdutil.Wrapf(cmdutil.CodeOperationCancelled, err, "chat cancelled"), sessionID, projector.AssistantMessageID())
|
||||
}
|
||||
return chatStreamError(cmdutil.WrapStream(err, "knowledge qa stream"), sessionID, projector.AssistantMessageID())
|
||||
}
|
||||
if !projector.Done() {
|
||||
return chatStreamError(
|
||||
cmdutil.NewError(cmdutil.CodeSSEStreamAborted, "stream ended without a terminal event"),
|
||||
sessionID,
|
||||
projector.AssistantMessageID(),
|
||||
)
|
||||
}
|
||||
|
||||
sid := projector.SessionID()
|
||||
if sid == "" {
|
||||
sid = sessionID
|
||||
}
|
||||
data := chatResult{
|
||||
Events: events,
|
||||
SessionID: sid,
|
||||
AssistantMessageID: projector.AssistantMessageID(),
|
||||
KBID: opts.KBID,
|
||||
Query: opts.Query,
|
||||
}
|
||||
|
||||
// Reaching here means fopts.Mode is FormatJSON (the only caller). Route
|
||||
// through FormatOptions.Emit so --jq projection and the success-envelope
|
||||
// contract apply, matching every other non-streaming command. A nil fopts
|
||||
// (direct-test entry) defaults to JSON so the object still emits.
|
||||
if fopts == nil {
|
||||
fopts = &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}
|
||||
}
|
||||
return fopts.Emit(iostreams.IO.Out, data, nil)
|
||||
}
|
||||
|
||||
func chatStreamError(err *cmdutil.Error, sessionID, assistantMessageID string) *cmdutil.Error {
|
||||
detail := map[string]any{"session_id": sessionID}
|
||||
if assistantMessageID != "" {
|
||||
detail["assistant_message_id"] = assistantMessageID
|
||||
}
|
||||
return err.WithDetail(detail)
|
||||
}
|
||||
|
||||
// chatResult is the --format json data payload. The projector controls default
|
||||
// versus verbose coverage.
|
||||
type chatResult struct {
|
||||
Events []sse.ProjectedEvent `json:"events"`
|
||||
SessionID string `json:"session_id"`
|
||||
AssistantMessageID string `json:"assistant_message_id,omitempty"`
|
||||
KBID string `json:"kb_id"`
|
||||
Query string `json:"query"`
|
||||
}
|
||||
|
||||
// compile-time check: the production SDK client implements ChatService.
|
||||
var _ ChatService = (*sdk.Client)(nil)
|
||||
@@ -0,0 +1,656 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
"github.com/Tencent/WeKnora/cli/internal/sse"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// fakeChatService implements ChatService for unit tests. Tests configure the
|
||||
// callback driver via streamEvents (delivered in order) and observe captured
|
||||
// inputs through the exported fields.
|
||||
type fakeChatService struct {
|
||||
createSessionResp *sdk.Session
|
||||
createSessionErr error
|
||||
createCalled bool
|
||||
|
||||
streamErr error
|
||||
streamEvents []*sdk.StreamResponse
|
||||
gotSessionID string
|
||||
gotRequest *sdk.KnowledgeQARequest
|
||||
streamCalled bool
|
||||
cbReturnsError error // if set, callback aborts after first event with this error
|
||||
}
|
||||
|
||||
func (f *fakeChatService) CreateSession(_ context.Context, req *sdk.CreateSessionRequest) (*sdk.Session, error) {
|
||||
f.createCalled = true
|
||||
if f.createSessionErr != nil {
|
||||
return nil, f.createSessionErr
|
||||
}
|
||||
if f.createSessionResp != nil {
|
||||
return f.createSessionResp, nil
|
||||
}
|
||||
// Default: return a deterministic session id derived from the title so
|
||||
// JSON assertions don't depend on uuid generation.
|
||||
return &sdk.Session{ID: "sess_auto", Title: req.Title}, nil
|
||||
}
|
||||
|
||||
func (f *fakeChatService) KnowledgeQAStream(ctx context.Context, sessionID string, req *sdk.KnowledgeQARequest, cb func(*sdk.StreamResponse) error, opts ...sdk.ResourceURLOptions) error {
|
||||
f.streamCalled = true
|
||||
f.gotSessionID = sessionID
|
||||
f.gotRequest = req
|
||||
for _, ev := range f.streamEvents {
|
||||
if err := cb(ev); err != nil {
|
||||
return err
|
||||
}
|
||||
if f.cbReturnsError != nil {
|
||||
return f.cbReturnsError
|
||||
}
|
||||
}
|
||||
return f.streamErr
|
||||
}
|
||||
|
||||
// Sanity: fakeChatService must satisfy ChatService. Mirrors the production
|
||||
// var _ ChatService = (*sdk.Client)(nil) check at the bottom of chat.go.
|
||||
var _ ChatService = (*fakeChatService)(nil)
|
||||
|
||||
// textOpts returns a FormatOptions configured for the text render path —
|
||||
// the most common shape under test.
|
||||
func textOpts() *cmdutil.FormatOptions {
|
||||
return &cmdutil.FormatOptions{Mode: cmdutil.FormatText}
|
||||
}
|
||||
|
||||
func TestChat_StreamMode(t *testing.T) {
|
||||
out, errBuf := iostreams.SetForTestWithTTY(t)
|
||||
svc := &fakeChatService{
|
||||
streamEvents: []*sdk.StreamResponse{
|
||||
{ResponseType: sdk.ResponseTypeAnswer, Content: "Hello "},
|
||||
{ResponseType: sdk.ResponseTypeAnswer, Content: "world"},
|
||||
{ResponseType: sdk.ResponseTypeReferences, KnowledgeReferences: []*sdk.SearchResult{
|
||||
{KnowledgeID: "k1", KnowledgeTitle: "Doc One", Score: 0.42},
|
||||
}},
|
||||
{ResponseType: sdk.ResponseTypeComplete, Done: true},
|
||||
},
|
||||
}
|
||||
opts := &Options{Query: "hi", KBID: "kb_1"}
|
||||
if err := runChat(context.Background(), opts, textOpts(), svc); err != nil {
|
||||
t.Fatalf("runChat: %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
if !strings.Contains(got, "Hello world") {
|
||||
t.Errorf("stdout missing streamed content: %q", got)
|
||||
}
|
||||
if strings.Contains(got, "references") || strings.Contains(got, "Doc One") {
|
||||
t.Errorf("default text output leaked references: %q", got)
|
||||
}
|
||||
// auto-created session must announce itself on stderr
|
||||
if !strings.Contains(errBuf.String(), "session: sess_auto") {
|
||||
t.Errorf("expected stderr session hint, got %q", errBuf.String())
|
||||
}
|
||||
if !svc.createCalled {
|
||||
t.Error("expected CreateSession invocation when SessionID empty")
|
||||
}
|
||||
if svc.gotSessionID != "sess_auto" {
|
||||
t.Errorf("stream sessionID: got %q want sess_auto", svc.gotSessionID)
|
||||
}
|
||||
if svc.gotRequest == nil || svc.gotRequest.Channel != "api" {
|
||||
t.Errorf("expected Channel=api, got %+v", svc.gotRequest)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChat_NDJSON_FirstLineIsInit verifies that the NDJSON path (--format ndjson)
|
||||
// always injects an "init" line first carrying session_id and kb_id.
|
||||
func TestChat_NDJSON_FirstLineIsInit(t *testing.T) {
|
||||
out, errBuf := iostreams.SetForTest(t)
|
||||
|
||||
svc := &fakeChatService{
|
||||
streamEvents: []*sdk.StreamResponse{
|
||||
{ResponseType: sdk.ResponseTypeAnswer, Content: "answer"},
|
||||
{ResponseType: sdk.ResponseTypeComplete, Done: true},
|
||||
},
|
||||
}
|
||||
opts := &Options{Query: "q", KBID: "kb_42"}
|
||||
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatNDJSON}
|
||||
if err := runChat(context.Background(), opts, fopts, svc); err != nil {
|
||||
t.Fatalf("runChat: %v", err)
|
||||
}
|
||||
|
||||
// NDJSON mode must NOT print the session hint to stderr.
|
||||
if errBuf.Len() != 0 {
|
||||
t.Errorf("expected empty stderr in NDJSON mode, got %q", errBuf.String())
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimRight(out.String(), "\n"), "\n")
|
||||
if len(lines) == 0 {
|
||||
t.Fatal("no output")
|
||||
}
|
||||
var first struct {
|
||||
Type string `json:"type"`
|
||||
SessionID string `json:"session_id"`
|
||||
KBID string `json:"kb_id"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(lines[0]), &first); err != nil {
|
||||
t.Fatalf("first line not JSON: %v\n %s", err, lines[0])
|
||||
}
|
||||
if first.Type != "init" {
|
||||
t.Errorf("first line type: got %q, want init", first.Type)
|
||||
}
|
||||
if first.SessionID != "sess_auto" {
|
||||
t.Errorf("init.session_id: got %q, want sess_auto", first.SessionID)
|
||||
}
|
||||
if first.KBID != "kb_42" {
|
||||
t.Errorf("init.kb_id: got %q, want kb_42", first.KBID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChat_NDJSON_PassthroughEvents verifies that the NDJSON path emits
|
||||
// init + N SDK events = N+1 total lines (no buffering, no extra wrapping).
|
||||
func TestChat_NDJSON_PassthroughEvents(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
|
||||
svc := &fakeChatService{
|
||||
streamEvents: []*sdk.StreamResponse{
|
||||
{ResponseType: sdk.ResponseTypeAnswer, Content: "hello"},
|
||||
{ResponseType: sdk.ResponseTypeComplete, Done: true},
|
||||
},
|
||||
}
|
||||
opts := &Options{Query: "q", KBID: "kb_x"}
|
||||
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatNDJSON}
|
||||
if err := runChat(context.Background(), opts, fopts, svc); err != nil {
|
||||
t.Fatalf("runChat: %v", err)
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimRight(out.String(), "\n"), "\n")
|
||||
// 1 init + 2 SDK events = 3 lines.
|
||||
if len(lines) != 3 {
|
||||
t.Fatalf("got %d lines, want 3:\n%s", len(lines), out.String())
|
||||
}
|
||||
// Each must be valid JSON.
|
||||
for i, line := range lines {
|
||||
var obj map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &obj); err != nil {
|
||||
t.Errorf("line %d not valid JSON: %v\n %s", i+1, err, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChat_NonTTY_StreamsSameProjection(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeChatService{
|
||||
streamEvents: []*sdk.StreamResponse{
|
||||
{ResponseType: sdk.ResponseTypeAnswer, Content: "piped"},
|
||||
{ResponseType: sdk.ResponseTypeComplete, Done: true},
|
||||
},
|
||||
}
|
||||
opts := &Options{Query: "q", KBID: "kb"}
|
||||
if err := runChat(context.Background(), opts, textOpts(), svc); err != nil {
|
||||
t.Fatalf("runChat: %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "piped") {
|
||||
t.Errorf("expected accumulated answer, got %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestChat_NonTTY_VerboseIncludesThinking(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeChatService{
|
||||
streamEvents: []*sdk.StreamResponse{
|
||||
{ResponseType: sdk.ResponseTypeThinking, Content: "REASONING"},
|
||||
{ResponseType: sdk.ResponseTypeAnswer, Content: "answer"},
|
||||
{ResponseType: sdk.ResponseTypeComplete, Done: true},
|
||||
},
|
||||
}
|
||||
opts := &Options{Query: "q", KBID: "kb", Verbose: true}
|
||||
if err := runChat(context.Background(), opts, textOpts(), svc); err != nil {
|
||||
t.Fatalf("runChat: %v", err)
|
||||
}
|
||||
if got := out.String(); !strings.Contains(got, "REASONING") || !strings.Contains(got, "answer") {
|
||||
t.Errorf("non-TTY verbose output missing thinking or answer: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChat_SessionIDProvided(t *testing.T) {
|
||||
_, errBuf := iostreams.SetForTestWithTTY(t)
|
||||
svc := &fakeChatService{
|
||||
streamEvents: []*sdk.StreamResponse{{ResponseType: sdk.ResponseTypeComplete, Done: true}},
|
||||
}
|
||||
opts := &Options{Query: "q", KBID: "kb", SessionID: "sess_existing"}
|
||||
if err := runChat(context.Background(), opts, textOpts(), svc); err != nil {
|
||||
t.Fatalf("runChat: %v", err)
|
||||
}
|
||||
if svc.createCalled {
|
||||
t.Error("CreateSession must NOT be invoked when --session is provided")
|
||||
}
|
||||
if svc.gotSessionID != "sess_existing" {
|
||||
t.Errorf("stream sessionID: got %q want sess_existing", svc.gotSessionID)
|
||||
}
|
||||
// No auto-create message because the user supplied the id.
|
||||
if strings.Contains(errBuf.String(), "session:") {
|
||||
t.Errorf("unexpected session hint emitted with explicit --session: %q", errBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestChat_KBIDRequired(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeChatService{}
|
||||
// Run with KBID empty (bypassing the cobra resolver).
|
||||
opts := &Options{Query: "q"}
|
||||
err := runChat(context.Background(), opts, textOpts(), svc)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
var typed *cmdutil.Error
|
||||
if !errors.As(err, &typed) {
|
||||
t.Fatalf("expected *cmdutil.Error, got %T", err)
|
||||
}
|
||||
if typed.Code != cmdutil.CodeKBIDRequired {
|
||||
t.Errorf("code: got %q want %q", typed.Code, cmdutil.CodeKBIDRequired)
|
||||
}
|
||||
if svc.createCalled || svc.streamCalled {
|
||||
t.Error("KB validation must short-circuit before any SDK call")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChat_EmptyQuery(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeChatService{}
|
||||
opts := &Options{Query: "", KBID: "kb"}
|
||||
err := runChat(context.Background(), opts, textOpts(), svc)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
var typed *cmdutil.Error
|
||||
if !errors.As(err, &typed) {
|
||||
t.Fatalf("expected *cmdutil.Error, got %T", err)
|
||||
}
|
||||
if typed.Code != cmdutil.CodeInputInvalidArgument {
|
||||
t.Errorf("code: got %q want %q", typed.Code, cmdutil.CodeInputInvalidArgument)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChat_SDKError_PreStream(t *testing.T) {
|
||||
// SDK fails before any event arrives → ClassifyHTTPError mapping.
|
||||
// "HTTP error 401: ..." → auth.unauthenticated.
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeChatService{
|
||||
streamErr: errors.New("HTTP error 401: token rejected"),
|
||||
}
|
||||
opts := &Options{Query: "q", KBID: "kb"}
|
||||
err := runChat(context.Background(), opts, textOpts(), svc)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
var typed *cmdutil.Error
|
||||
if !errors.As(err, &typed) {
|
||||
t.Fatalf("expected *cmdutil.Error, got %T", err)
|
||||
}
|
||||
if typed.Code != cmdutil.CodeAuthUnauthenticated {
|
||||
t.Errorf("code: got %q want %q", typed.Code, cmdutil.CodeAuthUnauthenticated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChat_SDKError_MidStream_AbortsAsSSE(t *testing.T) {
|
||||
// Some content arrived, then the stream errored without a Done event →
|
||||
// CodeSSEStreamAborted (separate from generic transport failure).
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeChatService{
|
||||
streamEvents: []*sdk.StreamResponse{{Content: "partial"}},
|
||||
streamErr: errors.New("connection reset"),
|
||||
}
|
||||
opts := &Options{Query: "q", KBID: "kb"}
|
||||
err := runChat(context.Background(), opts, textOpts(), svc)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
var typed *cmdutil.Error
|
||||
if !errors.As(err, &typed) {
|
||||
t.Fatalf("expected *cmdutil.Error, got %T", err)
|
||||
}
|
||||
if typed.Code != cmdutil.CodeSSEStreamAborted {
|
||||
t.Errorf("code: got %q want %q", typed.Code, cmdutil.CodeSSEStreamAborted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChat_TerminalSSEError_ClassifiesAsServerError(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeChatService{
|
||||
streamEvents: []*sdk.StreamResponse{
|
||||
{ResponseType: sdk.ResponseTypeAnswer, Content: "partial"},
|
||||
{ResponseType: sdk.ResponseTypeError, Content: "boom", Done: true},
|
||||
},
|
||||
streamErr: sdk.NewSSEStreamError("boom"),
|
||||
}
|
||||
opts := &Options{Query: "q", KBID: "kb"}
|
||||
err := runChat(context.Background(), opts, textOpts(), svc)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
var typed *cmdutil.Error
|
||||
if !errors.As(err, &typed) {
|
||||
t.Fatalf("expected *cmdutil.Error, got %T", err)
|
||||
}
|
||||
if typed.Code != cmdutil.CodeServerError {
|
||||
t.Errorf("code: got %q want %q", typed.Code, cmdutil.CodeServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChat_ContextCancelled(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // simulate Ctrl-C delivered before the SDK returns.
|
||||
svc := &fakeChatService{streamErr: context.Canceled}
|
||||
opts := &Options{Query: "q", KBID: "kb"}
|
||||
err := runChat(ctx, opts, textOpts(), svc)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
var typed *cmdutil.Error
|
||||
if !errors.As(err, &typed) {
|
||||
t.Fatalf("expected *cmdutil.Error, got %T", err)
|
||||
}
|
||||
if typed.Code != cmdutil.CodeOperationCancelled {
|
||||
t.Errorf("code: got %q want %q", typed.Code, cmdutil.CodeOperationCancelled)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChat_SessionCreateFails(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeChatService{
|
||||
createSessionErr: errors.New("dial tcp: connection refused"),
|
||||
}
|
||||
opts := &Options{Query: "q", KBID: "kb"}
|
||||
err := runChat(context.Background(), opts, textOpts(), svc)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
var typed *cmdutil.Error
|
||||
if !errors.As(err, &typed) {
|
||||
t.Fatalf("expected *cmdutil.Error, got %T", err)
|
||||
}
|
||||
if typed.Code != cmdutil.CodeSessionCreateFailed {
|
||||
t.Errorf("code: got %q want %q", typed.Code, cmdutil.CodeSessionCreateFailed)
|
||||
}
|
||||
if svc.streamCalled {
|
||||
t.Error("stream must not be invoked after session creation failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChat_SessionCreate404SurfacesNotFound(t *testing.T) {
|
||||
// HTTP-shaped session-create failures should NOT collapse into the
|
||||
// session_create_failed bucket; they keep their canonical mapping so
|
||||
// agents can react to e.g. resource.not_found.
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeChatService{
|
||||
createSessionErr: errors.New("HTTP error 404: tenant not found"),
|
||||
}
|
||||
opts := &Options{Query: "q", KBID: "kb"}
|
||||
err := runChat(context.Background(), opts, textOpts(), svc)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
var typed *cmdutil.Error
|
||||
if !errors.As(err, &typed) {
|
||||
t.Fatalf("expected *cmdutil.Error, got %T", err)
|
||||
}
|
||||
if typed.Code != cmdutil.CodeResourceNotFound {
|
||||
t.Errorf("code: got %q want %q", typed.Code, cmdutil.CodeResourceNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChat_NDJSON_InitIncludesProfile verifies that when a profile is set,
|
||||
// the NDJSON init event carries the profile field.
|
||||
func TestChat_NDJSON_InitIncludesProfile(t *testing.T) {
|
||||
cmdutil.SetProfile("prod")
|
||||
t.Cleanup(func() { cmdutil.SetProfile("") })
|
||||
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeChatService{
|
||||
streamEvents: []*sdk.StreamResponse{
|
||||
{ResponseType: sdk.ResponseTypeComplete, Done: true},
|
||||
},
|
||||
}
|
||||
opts := &Options{Query: "q", KBID: "kb_x"}
|
||||
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatNDJSON}
|
||||
if err := runChat(context.Background(), opts, fopts, svc); err != nil {
|
||||
t.Fatalf("runChat: %v", err)
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimRight(out.String(), "\n"), "\n")
|
||||
if len(lines) == 0 {
|
||||
t.Fatal("no output")
|
||||
}
|
||||
var initLine struct {
|
||||
Type string `json:"type"`
|
||||
Profile string `json:"profile"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(lines[0]), &initLine); err != nil {
|
||||
t.Fatalf("first line not JSON: %v\n %s", err, lines[0])
|
||||
}
|
||||
if initLine.Type != "init" {
|
||||
t.Errorf("type: got %q, want init", initLine.Type)
|
||||
}
|
||||
if initLine.Profile != "prod" {
|
||||
t.Errorf("profile: got %q, want prod", initLine.Profile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChat_FormatNDJSON_PassthroughsSDKEvents(t *testing.T) {
|
||||
// NDJSON is the raw protocol surface: every SDK event passes through,
|
||||
// including reasoning, regardless of the presentation-oriented --verbose.
|
||||
svc := &fakeChatService{
|
||||
streamEvents: []*sdk.StreamResponse{
|
||||
{ResponseType: sdk.ResponseTypeThinking, Content: "search KB"},
|
||||
{ResponseType: sdk.ResponseTypeAnswer, Content: "hello"},
|
||||
{ResponseType: sdk.ResponseTypeComplete, Done: true, SessionID: "sess_x"},
|
||||
},
|
||||
}
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
|
||||
opts := &Options{Query: "hi", KBID: "kb_x"}
|
||||
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatNDJSON}
|
||||
if err := runChat(context.Background(), opts, fopts, svc); err != nil {
|
||||
t.Fatalf("runChat: %v", err)
|
||||
}
|
||||
lines := strings.Split(strings.TrimRight(out.String(), "\n"), "\n")
|
||||
// 1 init + thinking + answer + complete = 4 lines.
|
||||
if len(lines) != 4 {
|
||||
t.Fatalf("got %d lines, want 4:\n%s", len(lines), out.String())
|
||||
}
|
||||
sawThinking := false
|
||||
for i, line := range lines {
|
||||
var ev map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &ev); err != nil {
|
||||
t.Fatalf("line %d not JSON: %v", i+1, err)
|
||||
}
|
||||
if ev["response_type"] == "thinking" {
|
||||
sawThinking = true
|
||||
}
|
||||
}
|
||||
if !sawThinking {
|
||||
t.Error("thinking event was filtered from raw NDJSON output")
|
||||
}
|
||||
// First line: CLI-injected init event.
|
||||
var initLine map[string]any
|
||||
if err := json.Unmarshal([]byte(lines[0]), &initLine); err != nil {
|
||||
t.Fatalf("line 1 (init) not JSON: %v", err)
|
||||
}
|
||||
if initLine["type"] != "init" {
|
||||
t.Errorf("first line type=%v, want init", initLine["type"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestChat_FormatNDJSON_VerboseDoesNotAlterRawStream(t *testing.T) {
|
||||
// --verbose is presentation-only; NDJSON remains the same raw stream and
|
||||
// therefore still contains thinking alongside answer + complete.
|
||||
svc := &fakeChatService{
|
||||
streamEvents: []*sdk.StreamResponse{
|
||||
{ResponseType: sdk.ResponseTypeThinking, Content: "search KB"},
|
||||
{ResponseType: sdk.ResponseTypeAnswer, Content: "hello"},
|
||||
{ResponseType: sdk.ResponseTypeComplete, Done: true, SessionID: "sess_x"},
|
||||
},
|
||||
}
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
|
||||
opts := &Options{Query: "hi", KBID: "kb_x", Verbose: true}
|
||||
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatNDJSON}
|
||||
if err := runChat(context.Background(), opts, fopts, svc); err != nil {
|
||||
t.Fatalf("runChat: %v", err)
|
||||
}
|
||||
lines := strings.Split(strings.TrimRight(out.String(), "\n"), "\n")
|
||||
// 1 init + thinking + answer + complete = 4 lines.
|
||||
if len(lines) != 4 {
|
||||
t.Fatalf("got %d lines, want 4:\n%s", len(lines), out.String())
|
||||
}
|
||||
var second map[string]any
|
||||
if err := json.Unmarshal([]byte(lines[1]), &second); err != nil {
|
||||
t.Fatalf("line 2 not JSON: %v", err)
|
||||
}
|
||||
if second["response_type"] != "thinking" {
|
||||
t.Errorf("second event response_type=%v, want thinking", second["response_type"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestChat_FormatJSON_ReferenceAddsIndexes(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeChatService{
|
||||
streamEvents: []*sdk.StreamResponse{
|
||||
{ResponseType: sdk.ResponseTypeAnswer, Content: "the answer"},
|
||||
{ResponseType: sdk.ResponseTypeReferences, KnowledgeReferences: []*sdk.SearchResult{
|
||||
{ID: "c1", Content: "BULKY FULL CHUNK CONTENT", ParentChunkID: "p1", KnowledgeTitle: "Doc One", Score: 0.5},
|
||||
}},
|
||||
{ResponseType: sdk.ResponseTypeComplete, Done: true, SessionID: "sess_auto"},
|
||||
},
|
||||
}
|
||||
opts := &Options{Query: "hi", KBID: "kb_1", Reference: true}
|
||||
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}
|
||||
if err := runChat(context.Background(), opts, fopts, svc); err != nil {
|
||||
t.Fatalf("runChat: %v", err)
|
||||
}
|
||||
// A single envelope, not multiple NDJSON lines.
|
||||
outStr := strings.TrimRight(out.String(), "\n")
|
||||
if strings.Contains(outStr, "\n") {
|
||||
t.Fatalf("expected single-line envelope, got multiple lines:\n%s", outStr)
|
||||
}
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data struct {
|
||||
Events []sse.ProjectedEvent `json:"events"`
|
||||
SessionID string `json:"session_id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(outStr), &env); err != nil {
|
||||
t.Fatalf("envelope not JSON: %v\n%s", err, outStr)
|
||||
}
|
||||
if !env.OK {
|
||||
t.Error("ok=false, want true")
|
||||
}
|
||||
wantTypes := []string{"answer", "references"}
|
||||
if len(env.Data.Events) != len(wantTypes) {
|
||||
t.Fatalf("events=%+v", env.Data.Events)
|
||||
}
|
||||
for i, want := range wantTypes {
|
||||
if env.Data.Events[i].ResponseType != want {
|
||||
t.Errorf("events[%d].response_type=%q, want %q", i, env.Data.Events[i].ResponseType, want)
|
||||
}
|
||||
}
|
||||
refs := env.Data.Events[1].KnowledgeReferences
|
||||
if len(refs) != 1 || refs[0].KBID != "kb_1" || refs[0].ChunkID != "c1" || refs[0].ParentChunkID != "p1" {
|
||||
t.Errorf("reference indexes=%+v", refs)
|
||||
}
|
||||
if env.Data.SessionID == "" {
|
||||
t.Error("session_id empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChat_FormatNDJSON_PreservesReflection(t *testing.T) {
|
||||
// Reflection is part of the raw SDK protocol and must not be filtered.
|
||||
svc := &fakeChatService{
|
||||
streamEvents: []*sdk.StreamResponse{
|
||||
{ResponseType: sdk.ResponseTypeReflection, Content: "reflecting"},
|
||||
{ResponseType: sdk.ResponseTypeAnswer, Content: "hello"},
|
||||
{ResponseType: sdk.ResponseTypeComplete, Done: true, SessionID: "sess_x"},
|
||||
},
|
||||
}
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
|
||||
opts := &Options{Query: "hi", KBID: "kb_x"}
|
||||
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatNDJSON}
|
||||
if err := runChat(context.Background(), opts, fopts, svc); err != nil {
|
||||
t.Fatalf("runChat: %v", err)
|
||||
}
|
||||
sawReflection := false
|
||||
for i, line := range strings.Split(strings.TrimRight(out.String(), "\n"), "\n") {
|
||||
var ev map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &ev); err != nil {
|
||||
t.Fatalf("line %d not JSON: %v", i+1, err)
|
||||
}
|
||||
if ev["response_type"] == "reflection" {
|
||||
sawReflection = true
|
||||
}
|
||||
}
|
||||
if !sawReflection {
|
||||
t.Error("reflection event was filtered from raw NDJSON output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChat_FormatJSON_HonorsJQ(t *testing.T) {
|
||||
// --format json routes through FormatOptions.Emit, so --jq projects the
|
||||
// envelope instead of dumping it whole. Guards the agent output protocol
|
||||
// contract: consumers can join answer event fragments.
|
||||
svc := &fakeChatService{
|
||||
streamEvents: []*sdk.StreamResponse{
|
||||
{ResponseType: sdk.ResponseTypeAnswer, Content: "the answer"},
|
||||
{ResponseType: sdk.ResponseTypeReferences, KnowledgeReferences: []*sdk.SearchResult{
|
||||
{ID: "c1", Content: "bulk", KnowledgeTitle: "Doc"},
|
||||
}},
|
||||
{ResponseType: sdk.ResponseTypeComplete, Done: true, SessionID: "sess_x"},
|
||||
},
|
||||
}
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
|
||||
opts := &Options{Query: "hi", KBID: "kb_1"}
|
||||
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON, JQ: `[.data.events[].content] | join("")`}
|
||||
if err := runChat(context.Background(), opts, fopts, svc); err != nil {
|
||||
t.Fatalf("runChat: %v", err)
|
||||
}
|
||||
got := strings.TrimSpace(out.String())
|
||||
if !strings.Contains(got, "the answer") {
|
||||
t.Errorf("jq output missing projected answer: %q", got)
|
||||
}
|
||||
// A projection must not dump the full envelope scaffolding.
|
||||
if strings.Contains(got, "references") || strings.Contains(got, "session_id") {
|
||||
t.Errorf("jq output looks like a full envelope, not a projection: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChat_FormatJSON_StreamErrorIncludesSessionDetail(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeChatService{
|
||||
streamEvents: []*sdk.StreamResponse{{
|
||||
ResponseType: sdk.ResponseTypeAgentQuery,
|
||||
AssistantMessageID: "msg_123",
|
||||
}},
|
||||
streamErr: errors.New("connection reset"),
|
||||
}
|
||||
err := runChat(
|
||||
context.Background(),
|
||||
&Options{Query: "q", KBID: "kb"},
|
||||
&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON},
|
||||
svc,
|
||||
)
|
||||
var typed *cmdutil.Error
|
||||
if !errors.As(err, &typed) {
|
||||
t.Fatalf("expected *cmdutil.Error, got %v", err)
|
||||
}
|
||||
detail, ok := typed.Detail.(map[string]any)
|
||||
if !ok || detail["session_id"] != "sess_auto" || detail["assistant_message_id"] != "msg_123" {
|
||||
t.Errorf("error detail=%v, want session and assistant message ids", typed.Detail)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// Package chunkcmd implements the `chunk` command subtree for managing
|
||||
// document chunks in a knowledge base. The directory is named `chunk/`
|
||||
// to match the cobra subcommand; the Go package is `chunkcmd` to avoid
|
||||
// colliding with cobra's *cobra.Command identifier.
|
||||
//
|
||||
// "chunk" in this subtree refers to indexed pieces of a knowledge
|
||||
// document (server resource: GET/DELETE /chunks/...). Each document
|
||||
// has many chunks; the chunking pipeline produces them at ingest time.
|
||||
package chunkcmd
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
const chunkLong = `Manage and inspect document chunks.
|
||||
|
||||
Chunks are the indexed pieces of a knowledge document (1 doc → many chunks).
|
||||
Use 'chunk list' to enumerate chunks in stored order (RAG admin / debug).`
|
||||
|
||||
// NewCmdChunk builds the parent `chunk` command. Called from cli/cmd/root.go.
|
||||
func NewCmdChunk(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "chunk <subcommand>",
|
||||
Short: "Manage document chunks (RAG retrieval debug)",
|
||||
Long: chunkLong,
|
||||
}
|
||||
cmd.AddCommand(NewCmdList(f))
|
||||
cmd.AddCommand(NewCmdView(f))
|
||||
cmd.AddCommand(NewCmdDelete(f))
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package chunkcmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
"github.com/Tencent/WeKnora/cli/internal/prompt"
|
||||
)
|
||||
|
||||
// chunkDeleteFields enumerates the JSON discovery fields for `chunk delete`.
|
||||
// Tracks the single-id result struct; multi-id mode emits batch envelope.
|
||||
var chunkDeleteFields = []string{"id", "deleted"}
|
||||
|
||||
type DeleteOptions struct {
|
||||
ChunkID string // single-id path
|
||||
DocID string // required: SDK DeleteChunk takes both ids in the route
|
||||
Yes bool // sourced from the global -y/--yes persistent flag
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
// DeleteService is the narrow SDK surface this command depends on.
|
||||
type DeleteService interface {
|
||||
DeleteChunk(ctx context.Context, docID, chunkID string) error
|
||||
}
|
||||
|
||||
// deleteResult is the typed payload emitted on single-id success in JSON mode.
|
||||
type deleteResult struct {
|
||||
ID string `json:"id"`
|
||||
Deleted bool `json:"deleted"`
|
||||
}
|
||||
|
||||
const chunkDeleteLong = `Permanently delete one or more chunks from a document.
|
||||
|
||||
Requires both the chunk id(s) (positional, repeatable) and the parent
|
||||
document id (--doc) because the server route encodes both:
|
||||
DELETE /chunks/{doc}/{id}. All chunks in a multi-id call must share the
|
||||
same --doc. The CLI does not auto-resolve doc id from chunk id because
|
||||
that would add a round-trip and open a race with the ingest pipeline
|
||||
(a chunk could move between documents between resolve and delete).
|
||||
|
||||
Single-id: one confirm prompt, exit 0/1.
|
||||
Multi-id:
|
||||
• Default keep-going: failed deletes do NOT stop the run; failures collected.
|
||||
• One -y/--yes confirms all chunks.
|
||||
• Exit 0 if all succeed; exit 1 if any failed.
|
||||
|
||||
Prompts for confirmation by default when stdout is a TTY and JSON output
|
||||
is not set. Pass -y/--yes (the global flag) to skip the prompt (required
|
||||
in agent / CI / piped contexts).
|
||||
|
||||
Typed exit codes:
|
||||
resource.not_found no chunk with the given id under that doc (exit 4)
|
||||
auth.forbidden caller lacks delete permission on the chunk (exit 3)
|
||||
input.confirmation_required destructive op without -y on a TTY (exit 10)
|
||||
|
||||
AI agents: this is a high-risk write. Without -y/--yes the CLI exits 10
|
||||
and writes input.confirmation_required to stderr. NEVER auto-pass -y
|
||||
without the user's explicit go-ahead — the exit-10 protocol exists
|
||||
exactly to guard against unintended deletes.`
|
||||
|
||||
const chunkDeleteExample = ` weknora chunk delete chunk_abc --doc doc_xyz # interactive confirm
|
||||
weknora chunk delete chunk_abc --doc doc_xyz -y # no prompt
|
||||
weknora chunk delete chunk_abc --doc doc_xyz -y --format json # bare {id, deleted:true} JSON
|
||||
weknora chunk delete c1 c2 c3 --doc doc_xyz -y # delete 3 chunks under same doc, keep-going`
|
||||
|
||||
// NewCmdDelete builds `weknora chunk delete <chunk-id> [<chunk-id>...] --doc <doc-id>`.
|
||||
func NewCmdDelete(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &DeleteOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "delete <chunk-id> [<chunk-id>...] --doc <doc-id>",
|
||||
Short: "Delete one or more chunks from a document (scoped)",
|
||||
Long: chunkDeleteLong,
|
||||
Example: chunkDeleteExample,
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
fopts, err := cmdutil.CheckFormatFlag(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
|
||||
opts.Yes, _ = c.Flags().GetBool("yes")
|
||||
if handled, err := cmdutil.HandleDryRun(c, opts.DryRun, cmdutil.DryRunPlan{
|
||||
Action: "chunk.delete",
|
||||
Args: map[string]any{
|
||||
"chunk_ids": args,
|
||||
"doc": opts.DocID,
|
||||
},
|
||||
}); handled {
|
||||
return err
|
||||
}
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(args) == 1 {
|
||||
opts.ChunkID = args[0]
|
||||
return runDelete(c.Context(), opts, fopts, cli, f.Prompter())
|
||||
}
|
||||
batchRetry := append([]string{"weknora", "chunk", "delete"}, args...)
|
||||
batchRetry = append(batchRetry, "--doc", opts.DocID, "-y")
|
||||
if err := cmdutil.ConfirmDestructiveBatch(f.Prompter(), opts.Yes, fopts.WantsJSON(), "delete", "chunk", len(args), "chunk.delete", batchRetry); err != nil {
|
||||
return err
|
||||
}
|
||||
outcomes, runErr := cmdutil.RunBatch(c.Context(), args, func(ctx context.Context, id string) error {
|
||||
if err := cli.DeleteChunk(ctx, opts.DocID, id); err != nil {
|
||||
return cmdutil.WrapHTTP(err, "delete chunk %s", id)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
// Only emit when the operation actually ran. Pre-flight errors
|
||||
// (e.g. confirmation_required) must leave stdout empty per the
|
||||
// wire contract in README.md.
|
||||
if len(outcomes) > 0 {
|
||||
if emitErr := cmdutil.EmitBatch(outcomes, fopts, iostreams.IO.Out, cmdutil.DeletedAtNow); emitErr != nil {
|
||||
return emitErr
|
||||
}
|
||||
}
|
||||
return runErr
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&opts.DocID, "doc", "", "Parent document id (SDK knowledge_id) the chunks live under")
|
||||
_ = cmd.MarkFlagRequired("doc")
|
||||
cmdutil.AddIgnoredKBFlag(cmd)
|
||||
cmdutil.AddFormatFlag(cmd, chunkDeleteFields...)
|
||||
cmdutil.AddDryRunFlag(cmd, &opts.DryRun)
|
||||
cmdutil.SetRisk(cmd, "chunk.delete")
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "permanently delete one or more chunks from a document",
|
||||
RequiredFlags: []string{"<chunk-id>... (positional, at least one)", "--doc <doc-id>"},
|
||||
Examples: []string{
|
||||
"weknora chunk delete chunk_abc --doc doc_xyz -y",
|
||||
"weknora chunk delete c1 c2 c3 --doc doc_xyz -y",
|
||||
"weknora chunk delete chunk_abc --doc doc_xyz -y --format json",
|
||||
},
|
||||
Output: "envelope.data shape depends on the form: a single chunk id -> {id, deleted:true}; multiple ids -> batch envelope (top-level status success|partial|error, ok=(failures==0), data per-item [{id, ok, error?}], meta.successes/failures — read data[].ok per id).",
|
||||
Warnings: []string{
|
||||
"Requires explicit user approval (exit 10 / input.confirmation_required); never auto-add -y.",
|
||||
"chunk delete is irreversible; loses the chunk + breaks RAG retrieval coherence for downstream queries.",
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runDelete(ctx context.Context, opts *DeleteOptions, fopts *cmdutil.FormatOptions, svc DeleteService, p prompt.Prompter) error {
|
||||
if err := cmdutil.ConfirmDestructive(p, opts.Yes, fopts.WantsJSON(), "delete", "chunk", opts.ChunkID, "chunk.delete", []string{"weknora", "chunk", "delete", opts.ChunkID, "--doc", opts.DocID, "-y"}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := svc.DeleteChunk(ctx, opts.DocID, opts.ChunkID); err != nil {
|
||||
return cmdutil.WrapHTTP(err, "delete chunk %s", opts.ChunkID)
|
||||
}
|
||||
if fopts.WantsJSON() {
|
||||
return fopts.Emit(iostreams.IO.Out, deleteResult{ID: opts.ChunkID, Deleted: true}, nil)
|
||||
}
|
||||
fmt.Fprintf(iostreams.IO.Out, "✓ Deleted chunk %s\n", opts.ChunkID)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package chunkcmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
"github.com/Tencent/WeKnora/cli/internal/testutil"
|
||||
)
|
||||
|
||||
type fakeChunkDeleteSvc struct {
|
||||
gotDocID, gotChunkID string
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeChunkDeleteSvc) DeleteChunk(_ context.Context, docID, chunkID string) error {
|
||||
f.gotDocID = docID
|
||||
f.gotChunkID = chunkID
|
||||
return f.err
|
||||
}
|
||||
|
||||
func TestDelete_NonTTY_NoYes_ExitTen(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeChunkDeleteSvc{}
|
||||
err := runDelete(context.Background(),
|
||||
&DeleteOptions{ChunkID: "c1", DocID: "doc_abc", Yes: false},
|
||||
&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, &testutil.ConfirmPrompter{})
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeInputConfirmationRequired, typed.Code)
|
||||
assert.Empty(t, svc.gotChunkID, "must not call DeleteChunk without confirm")
|
||||
assert.Equal(t, 10, cmdutil.ExitCode(err), "exit 10 per destructive-write protocol")
|
||||
}
|
||||
|
||||
func TestDelete_WithYes_PassesBothIDs(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeChunkDeleteSvc{}
|
||||
require.NoError(t, runDelete(context.Background(),
|
||||
&DeleteOptions{ChunkID: "c1", DocID: "doc_abc", Yes: true},
|
||||
&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, &testutil.ConfirmPrompter{}))
|
||||
assert.Equal(t, "doc_abc", svc.gotDocID)
|
||||
assert.Equal(t, "c1", svc.gotChunkID)
|
||||
}
|
||||
|
||||
func TestDelete_MissingDoc_FlagError(t *testing.T) {
|
||||
cmd := NewCmdDelete(nil)
|
||||
cmd.SetArgs([]string{"c1"}) // no --doc
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
require.Error(t, cmd.Execute())
|
||||
}
|
||||
|
||||
func TestDelete_404_PropagatesNotFound(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeChunkDeleteSvc{err: errors.New("HTTP error 404: not found")}
|
||||
err := runDelete(context.Background(),
|
||||
&DeleteOptions{ChunkID: "missing", DocID: "doc_abc", Yes: true},
|
||||
&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, &testutil.ConfirmPrompter{})
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeResourceNotFound, typed.Code)
|
||||
}
|
||||
|
||||
func TestDelete_TTY_ConfirmYes_Calls(t *testing.T) {
|
||||
_, _ = iostreams.SetForTestWithTTY(t)
|
||||
svc := &fakeChunkDeleteSvc{}
|
||||
p := &testutil.ConfirmPrompter{Answer: true}
|
||||
require.NoError(t, runDelete(context.Background(),
|
||||
&DeleteOptions{ChunkID: "c1", DocID: "doc_abc"},
|
||||
&cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, p))
|
||||
assert.True(t, p.Asked)
|
||||
assert.Equal(t, "c1", svc.gotChunkID)
|
||||
}
|
||||
|
||||
func TestDelete_TTY_ConfirmNo_Aborts(t *testing.T) {
|
||||
_, errBuf := iostreams.SetForTestWithTTY(t)
|
||||
svc := &fakeChunkDeleteSvc{}
|
||||
p := &testutil.ConfirmPrompter{Answer: false}
|
||||
err := runDelete(context.Background(),
|
||||
&DeleteOptions{ChunkID: "c1", DocID: "doc_abc"},
|
||||
&cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, p)
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeUserAborted, typed.Code)
|
||||
assert.Empty(t, svc.gotChunkID, "answer=no must not call DeleteChunk")
|
||||
assert.Contains(t, errBuf.String(), "Aborted")
|
||||
}
|
||||
|
||||
func TestDelete_JSON_BareObject(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeChunkDeleteSvc{}
|
||||
require.NoError(t, runDelete(context.Background(),
|
||||
&DeleteOptions{ChunkID: "c1", DocID: "doc_abc", Yes: true},
|
||||
&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, &testutil.ConfirmPrompter{}))
|
||||
body := out.String()
|
||||
assert.Contains(t, body, `"id":"c1"`)
|
||||
assert.Contains(t, body, `"deleted":true`)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Multi-id (all chunks share --doc, keep-going on failure)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// fakeMultiChunkDeleteSvc records (docID, chunkID) pairs and can fail-on
|
||||
// selected chunkIDs.
|
||||
type fakeMultiChunkDeleteSvc struct {
|
||||
deleted []string // chunk ids successfully deleted
|
||||
docIDs []string // doc id observed for each call
|
||||
failOn map[string]error
|
||||
}
|
||||
|
||||
func (f *fakeMultiChunkDeleteSvc) DeleteChunk(_ context.Context, docID, chunkID string) error {
|
||||
f.docIDs = append(f.docIDs, docID)
|
||||
if e, ok := f.failOn[chunkID]; ok {
|
||||
return e
|
||||
}
|
||||
f.deleted = append(f.deleted, chunkID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestMultiDelete_AllSucceed_SharedDoc(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeMultiChunkDeleteSvc{}
|
||||
docID := "doc_xyz"
|
||||
outcomes, err := cmdutil.RunBatch(context.Background(),
|
||||
[]string{"c1", "c2", "c3"},
|
||||
func(ctx context.Context, id string) error {
|
||||
if err := svc.DeleteChunk(ctx, docID, id); err != nil {
|
||||
return cmdutil.WrapHTTP(err, "delete chunk %s", id)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, outcomes, 3)
|
||||
for _, oc := range outcomes {
|
||||
assert.Nil(t, oc.Err, "expected no error for %s", oc.ID)
|
||||
}
|
||||
// All calls observed the same --doc.
|
||||
for _, d := range svc.docIDs {
|
||||
assert.Equal(t, "doc_xyz", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultiDelete_PartialFailure_KeepsGoing(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeMultiChunkDeleteSvc{failOn: map[string]error{"c2": errors.New("HTTP error 404: not found")}}
|
||||
docID := "doc_xyz"
|
||||
outcomes, err := cmdutil.RunBatch(context.Background(),
|
||||
[]string{"c1", "c2", "c3"},
|
||||
func(ctx context.Context, id string) error {
|
||||
if err := svc.DeleteChunk(ctx, docID, id); err != nil {
|
||||
return cmdutil.WrapHTTP(err, "delete chunk %s", id)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeOperationFailed, typed.Code)
|
||||
require.Len(t, outcomes, 3, "all three ids must appear in outcomes (argv order preserved)")
|
||||
assert.Nil(t, outcomes[0].Err, "c1 should succeed")
|
||||
assert.NotNil(t, outcomes[1].Err, "c2 should fail")
|
||||
assert.Nil(t, outcomes[2].Err, "c3 should succeed (keep-going)")
|
||||
assert.Equal(t, "c2", outcomes[1].ID)
|
||||
}
|
||||
|
||||
func TestMultiDelete_NonTTY_NoYes_RequiresConfirmation(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeMultiChunkDeleteSvc{}
|
||||
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatText}
|
||||
err := cmdutil.ConfirmDestructiveBatch(&testutil.ConfirmPrompter{}, false, fopts.WantsJSON(), "delete", "chunk", 2, "chunk.delete", nil)
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeInputConfirmationRequired, typed.Code)
|
||||
assert.Equal(t, 10, cmdutil.ExitCode(err))
|
||||
assert.Empty(t, svc.deleted)
|
||||
}
|
||||
|
||||
// TestChunkDelete_MultiID_PartialFailure_BatchEnvelope verifies that the JSON
|
||||
// output for a multi-id partial failure uses the batch envelope shape:
|
||||
// {ok:false, data:[{id,ok,result?|error?}...], meta:{count,successes,failures}}.
|
||||
func TestChunkDelete_MultiID_PartialFailure_BatchEnvelope(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeMultiChunkDeleteSvc{failOn: map[string]error{
|
||||
"c2": errors.New("HTTP error 404: not found"),
|
||||
}}
|
||||
docID := "doc_xyz"
|
||||
outcomes, runErr := cmdutil.RunBatch(context.Background(),
|
||||
[]string{"c1", "c2", "c3"},
|
||||
func(ctx context.Context, id string) error {
|
||||
if err := svc.DeleteChunk(ctx, docID, id); err != nil {
|
||||
return cmdutil.WrapHTTP(err, "delete chunk %s", id)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
require.Error(t, runErr)
|
||||
require.Len(t, outcomes, 3)
|
||||
|
||||
var buf bytes.Buffer
|
||||
require.NoError(t, cmdutil.EmitBatch(outcomes, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, &buf, cmdutil.DeletedAtNow))
|
||||
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
OK bool `json:"ok"`
|
||||
Error *struct {
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error,omitempty"`
|
||||
} `json:"data"`
|
||||
Meta struct {
|
||||
Count int `json:"count"`
|
||||
Successes int `json:"successes"`
|
||||
Failures int `json:"failures"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(buf.Bytes(), &env), "envelope must be valid JSON")
|
||||
|
||||
assert.False(t, env.OK, "top-level ok must be false when any item failed")
|
||||
assert.Equal(t, 3, env.Meta.Count)
|
||||
assert.Equal(t, 2, env.Meta.Successes)
|
||||
assert.Equal(t, 1, env.Meta.Failures)
|
||||
require.Len(t, env.Data, 3)
|
||||
assert.Equal(t, "c1", env.Data[0].ID)
|
||||
assert.True(t, env.Data[0].OK)
|
||||
assert.Equal(t, "c2", env.Data[1].ID)
|
||||
assert.False(t, env.Data[1].OK)
|
||||
assert.NotNil(t, env.Data[1].Error)
|
||||
assert.Equal(t, "c3", env.Data[2].ID)
|
||||
assert.True(t, env.Data[2].OK)
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package chunkcmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
"github.com/Tencent/WeKnora/cli/internal/output"
|
||||
"github.com/Tencent/WeKnora/cli/internal/text"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultPageSize = 50
|
||||
maxPageSize = 1000
|
||||
defaultLimit = 50
|
||||
maxLimit = 10000
|
||||
previewWidth = 80
|
||||
)
|
||||
|
||||
// chunkListFields enumerates the fields surfaced for `--format json` discovery on
|
||||
// `chunk list`. Mirrors sdk.Chunk json tags — all 23 fields are projectable
|
||||
// because chunk list returns bare SDK objects.
|
||||
var chunkListFields = []string{
|
||||
"id", "seq_id", "knowledge_id", "knowledge_base_id", "tenant_id",
|
||||
"tag_id", "content", "chunk_index", "is_enabled", "status",
|
||||
"start_at", "end_at", "pre_chunk_id", "next_chunk_id", "chunk_type",
|
||||
"parent_chunk_id", "relation_chunks", "indirect_relation_chunks",
|
||||
"metadata", "content_hash", "image_info", "created_at", "updated_at",
|
||||
}
|
||||
|
||||
// ListService is the narrow SDK surface this command depends on.
|
||||
type ListService interface {
|
||||
ListKnowledgeChunks(ctx context.Context, knowledgeID string, page, pageSize int, chunkTypes ...string) ([]sdk.Chunk, int64, error)
|
||||
}
|
||||
|
||||
type ListOptions struct {
|
||||
// DocID scopes the listing to a single knowledge document (SDK
|
||||
// `knowledge_id`). The chunks SDK does not expose a KB-wide route.
|
||||
DocID string
|
||||
// PageSize is the server batch size (1..1000, default 50).
|
||||
PageSize int
|
||||
// Limit caps the client-side accumulated slice (1..10000, default 50).
|
||||
// Default 50 chosen as domain-tuned for chunk enumeration (RAG debug).
|
||||
Limit int
|
||||
// AllPages walks server pages internally until total exhausted or
|
||||
// --limit hit. Mirrors the session list / doc list pagination pattern.
|
||||
AllPages bool
|
||||
}
|
||||
|
||||
const chunkListLong = `List chunks under a document in stored order.
|
||||
|
||||
'chunk list --doc D' enumerates ALL chunks of document D in ChunkIndex
|
||||
order (the per-doc ordinal assigned at ingest time). This is the
|
||||
admin/debug surface for RAG retrieval — see what the chunking pipeline
|
||||
produced, audit content, find a chunk id to view/delete.
|
||||
|
||||
For relevance-ranked retrieval (the RAG runtime surface), use
|
||||
'search chunks "<query>" --kb K' instead. That command runs hybrid
|
||||
vector + keyword scoring across all chunks of a knowledge base.
|
||||
|
||||
Typed exit codes:
|
||||
input.invalid_argument --limit out of 1..10000 or --page-size out of 1..1000 (exit 5)
|
||||
resource.not_found no document with the given id (exit 4)
|
||||
|
||||
AI agents: prefer 'search chunks' for retrieval tasks. Use 'chunk list'
|
||||
only when you need to enumerate / verify the chunking output of a
|
||||
specific document.`
|
||||
|
||||
const chunkListExample = ` weknora chunk list --doc doc_abc
|
||||
weknora chunk list --doc doc_abc --all-pages --page-size 100
|
||||
weknora chunk list --doc doc_abc --format json | jq '.[] | {id, chunk_index}'`
|
||||
|
||||
// NewCmdList builds `weknora chunk list --doc <doc-id>`.
|
||||
func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &ListOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List chunks of a document (admin/debug, not retrieval)",
|
||||
Long: chunkListLong,
|
||||
Example: chunkListExample,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(c *cobra.Command, _ []string) error {
|
||||
fopts, err := cmdutil.CheckFormatFlag(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
|
||||
// Validate static input before building the client so a bad
|
||||
// --limit/--page-size returns input.invalid_argument (exit 5), not
|
||||
// an auth error (exit 3).
|
||||
if err := validateListOpts(opts); err != nil {
|
||||
return err
|
||||
}
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runList(c.Context(), opts, fopts, cli)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&opts.DocID, "doc", "", "Document id (SDK knowledge_id) to enumerate chunks for")
|
||||
_ = cmd.MarkFlagRequired("doc")
|
||||
cmd.Flags().IntVarP(&opts.Limit, "limit", "L", defaultLimit, "Maximum results to return — client-side cap; meta.has_more/total_count report truncation (1..10000)")
|
||||
cmd.Flags().IntVar(&opts.PageSize, "page-size", defaultPageSize, "Items per server batch (1..1000)")
|
||||
cmd.Flags().BoolVar(&opts.AllPages, "all-pages", false, "Walk all server pages until exhausted (or --limit hit)")
|
||||
cmdutil.AddFormatFlag(cmd, chunkListFields...)
|
||||
cmdutil.AddIgnoredKBFlag(cmd)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "List chunks of a specific document in stored order (admin/debug). Results come with meta.count; use --limit (1..10000) and --all-pages to paginate. Prefer 'search chunks' for RAG retrieval.",
|
||||
RequiredFlags: []string{"--doc"},
|
||||
Examples: []string{"weknora chunk list --doc doc_abc --format json", "weknora chunk list --doc doc_abc --all-pages --format json"},
|
||||
Output: "envelope.data is an array of Chunk objects with id, chunk_index, content, is_enabled; meta.count is the returned count, meta.total_count the document's full chunk count, meta.has_more true when --limit truncated",
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
// validateListOpts checks --limit / --page-size. Called from RunE before the
|
||||
// client is built (so a bad value surfaces as exit 5, not an auth error) and at
|
||||
// runList's top for direct callers; idempotent.
|
||||
func validateListOpts(opts *ListOptions) error {
|
||||
if opts.Limit < 1 || opts.Limit > maxLimit {
|
||||
return &cmdutil.Error{
|
||||
Code: cmdutil.CodeInputInvalidArgument,
|
||||
Message: fmt.Sprintf("--limit must be in 1..%d, got %d", maxLimit, opts.Limit),
|
||||
}
|
||||
}
|
||||
if opts.PageSize < 1 || opts.PageSize > maxPageSize {
|
||||
return &cmdutil.Error{
|
||||
Code: cmdutil.CodeInputInvalidArgument,
|
||||
Message: fmt.Sprintf("--page-size must be in 1..%d, got %d", maxPageSize, opts.PageSize),
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runList(ctx context.Context, opts *ListOptions, fopts *cmdutil.FormatOptions, svc ListService) error {
|
||||
if err := validateListOpts(opts); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var items []sdk.Chunk
|
||||
var serverTotal int64
|
||||
truncated := false
|
||||
if opts.AllPages {
|
||||
accum := make([]sdk.Chunk, 0)
|
||||
for page := 1; ; page++ {
|
||||
chunks, total, err := svc.ListKnowledgeChunks(ctx, opts.DocID, page, opts.PageSize)
|
||||
if err != nil {
|
||||
return cmdutil.WrapHTTP(err, "list chunks for doc %s", opts.DocID)
|
||||
}
|
||||
serverTotal = total
|
||||
accum = append(accum, chunks...)
|
||||
if len(accum) >= opts.Limit {
|
||||
accum = accum[:opts.Limit]
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
if len(chunks) == 0 || int64(len(accum)) >= total {
|
||||
break
|
||||
}
|
||||
}
|
||||
items = accum
|
||||
} else {
|
||||
chunks, total, err := svc.ListKnowledgeChunks(ctx, opts.DocID, 1, opts.PageSize)
|
||||
if err != nil {
|
||||
return cmdutil.WrapHTTP(err, "list chunks for doc %s", opts.DocID)
|
||||
}
|
||||
serverTotal = total
|
||||
items = chunks
|
||||
}
|
||||
if items == nil {
|
||||
items = []sdk.Chunk{} // JSON [] not null
|
||||
}
|
||||
if len(items) > opts.Limit {
|
||||
items = items[:opts.Limit]
|
||||
truncated = true
|
||||
}
|
||||
|
||||
if fopts.WantsJSON() {
|
||||
meta := &output.Meta{Count: output.IntPtr(len(items)), TotalCount: output.IntPtr(int(serverTotal)), HasMore: truncated}
|
||||
return fopts.Emit(iostreams.IO.Out, items, meta)
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
fmt.Fprintln(iostreams.IO.Out, "(no chunks)")
|
||||
return nil
|
||||
}
|
||||
tw := tabwriter.NewWriter(iostreams.IO.Out, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "CHUNK_ID\tINDEX\tTYPE\tENABLED\tPREVIEW\tUPDATED")
|
||||
now := time.Now()
|
||||
for _, c := range items {
|
||||
enabled := "-"
|
||||
if c.IsEnabled {
|
||||
enabled = "yes"
|
||||
}
|
||||
preview := text.OneLine(previewWidth, c.Content)
|
||||
if preview == "" {
|
||||
preview = "-"
|
||||
}
|
||||
typ := c.ChunkType
|
||||
if typ == "" {
|
||||
typ = "-"
|
||||
}
|
||||
fmt.Fprintf(tw, "%s\t%d\t%s\t%s\t%s\t%s\n",
|
||||
c.ID, c.ChunkIndex, typ, enabled, preview, text.FuzzyAgoStr(now, c.UpdatedAt))
|
||||
}
|
||||
return tw.Flush()
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package chunkcmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
type listCall struct {
|
||||
docID string
|
||||
page int
|
||||
pageSize int
|
||||
}
|
||||
|
||||
type fakeListSvc struct {
|
||||
calls []listCall
|
||||
pages [][]sdk.Chunk
|
||||
totals []int64
|
||||
errs []error
|
||||
callIdx int
|
||||
}
|
||||
|
||||
func (f *fakeListSvc) ListKnowledgeChunks(_ context.Context, docID string, page, pageSize int, _ ...string) ([]sdk.Chunk, int64, error) {
|
||||
f.calls = append(f.calls, listCall{docID, page, pageSize})
|
||||
defer func() { f.callIdx++ }()
|
||||
if f.callIdx >= len(f.pages) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
return f.pages[f.callIdx], f.totals[f.callIdx], f.errs[f.callIdx]
|
||||
}
|
||||
|
||||
func TestList_Happy(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeListSvc{
|
||||
pages: [][]sdk.Chunk{{
|
||||
{ID: "c1", ChunkIndex: 0, Content: "hello", ChunkType: "text", IsEnabled: true},
|
||||
{ID: "c2", ChunkIndex: 1, Content: "world", ChunkType: "text", IsEnabled: true},
|
||||
}},
|
||||
totals: []int64{2}, errs: []error{nil},
|
||||
}
|
||||
opts := &ListOptions{DocID: "doc_abc", Limit: 50, PageSize: 50}
|
||||
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
require.Len(t, svc.calls, 1)
|
||||
assert.Equal(t, "doc_abc", svc.calls[0].docID)
|
||||
assert.Equal(t, 1, svc.calls[0].page)
|
||||
assert.Equal(t, 50, svc.calls[0].pageSize)
|
||||
// JSON mode emits a bare array; both ids must be present.
|
||||
body := out.String()
|
||||
assert.Contains(t, body, `"c1"`)
|
||||
assert.Contains(t, body, `"c2"`)
|
||||
}
|
||||
|
||||
// TestList_AllPages_StopsOnEmptyPage exercises the empty-page stop branch:
|
||||
// total is large enough that page*pageSize < total never trips, so the loop
|
||||
// can only terminate when the server returns an empty page.
|
||||
func TestList_AllPages_StopsOnEmptyPage(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeListSvc{
|
||||
pages: [][]sdk.Chunk{
|
||||
{{ID: "c1"}, {ID: "c2"}},
|
||||
{{ID: "c3"}},
|
||||
{}, // empty page → done
|
||||
},
|
||||
// Inflated total isolates the empty-page branch from the
|
||||
// page*pageSize >= total branch.
|
||||
totals: []int64{100, 100, 100},
|
||||
errs: []error{nil, nil, nil},
|
||||
}
|
||||
opts := &ListOptions{DocID: "doc_abc", AllPages: true, PageSize: 2, Limit: 1000}
|
||||
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
assert.Equal(t, 3, len(svc.calls), "must stop on empty page, not loop forever")
|
||||
}
|
||||
|
||||
// TestList_AllPages_StopsOnTotalExhausted exercises the page*pageSize >= total
|
||||
// stop branch: server never returns an empty page in the requested window, so
|
||||
// the loop must exit when accumulated coverage reaches total.
|
||||
func TestList_AllPages_StopsOnTotalExhausted(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeListSvc{
|
||||
pages: [][]sdk.Chunk{
|
||||
{{ID: "c1"}, {ID: "c2"}},
|
||||
{{ID: "c3"}},
|
||||
},
|
||||
totals: []int64{3, 3},
|
||||
errs: []error{nil, nil},
|
||||
}
|
||||
opts := &ListOptions{DocID: "doc_abc", AllPages: true, PageSize: 2, Limit: 1000}
|
||||
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
// After page 2: page*pageSize=4 >= total=3 → stop. No 3rd request.
|
||||
assert.Equal(t, 2, len(svc.calls), "must stop when total exhausted, no extra empty probe")
|
||||
}
|
||||
|
||||
// TestList_AllPages_LimitTruncatesAccumulated exercises the limit-cap stop
|
||||
// branch: pagination halts as soon as accumulated >= limit, and the result
|
||||
// is sliced to exactly --limit items regardless of how the last page over-ran.
|
||||
func TestList_AllPages_LimitTruncatesAccumulated(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeListSvc{
|
||||
pages: [][]sdk.Chunk{
|
||||
{{ID: "c1"}, {ID: "c2"}},
|
||||
{{ID: "c3"}, {ID: "c4"}},
|
||||
{{ID: "c5"}}, // should not be requested — limit hits first
|
||||
},
|
||||
totals: []int64{5, 5, 5},
|
||||
errs: []error{nil, nil, nil},
|
||||
}
|
||||
opts := &ListOptions{DocID: "doc_abc", AllPages: true, PageSize: 2, Limit: 3}
|
||||
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
// After page 2: accum=4 >= limit=3 → stop. Third page never requested.
|
||||
assert.LessOrEqual(t, len(svc.calls), 2, "must not walk past limit-hit point")
|
||||
// Result must be exactly --limit items (server returned 4, sliced to 3).
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data []sdk.Chunk `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(out.Bytes(), &env))
|
||||
got := env.Data
|
||||
assert.Len(t, got, 3, "accumulated must be sliced to exactly --limit")
|
||||
// IDs preserve order: first 3 from the first 2 pages.
|
||||
assert.Equal(t, []string{"c1", "c2", "c3"}, []string{got[0].ID, got[1].ID, got[2].ID})
|
||||
}
|
||||
|
||||
// TestList_JSON_EmitsTotalCount pins that chunk list surfaces the document's
|
||||
// full chunk count as meta.total_count (like doc/session/kb/model list), not
|
||||
// just the returned count — an agent must be able to tell truncation from
|
||||
// completeness.
|
||||
func TestList_JSON_EmitsTotalCount(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeListSvc{
|
||||
pages: [][]sdk.Chunk{{{ID: "c1"}, {ID: "c2"}}},
|
||||
totals: []int64{7},
|
||||
errs: []error{nil},
|
||||
}
|
||||
opts := &ListOptions{DocID: "d1", Limit: 50, PageSize: 50}
|
||||
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
var env struct {
|
||||
Meta struct {
|
||||
Count *int `json:"count"`
|
||||
TotalCount *int `json:"total_count"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(out.Bytes(), &env))
|
||||
require.NotNil(t, env.Meta.TotalCount, "chunk list must emit meta.total_count")
|
||||
assert.Equal(t, 7, *env.Meta.TotalCount)
|
||||
require.NotNil(t, env.Meta.Count)
|
||||
assert.Equal(t, 2, *env.Meta.Count)
|
||||
}
|
||||
|
||||
func TestList_LimitInvalid(t *testing.T) {
|
||||
svc := &fakeListSvc{}
|
||||
for _, lim := range []int{0, -1, 10001} {
|
||||
err := runList(context.Background(), &ListOptions{DocID: "d", Limit: lim, PageSize: 50}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc)
|
||||
require.Error(t, err, "expect error for --limit %d", lim)
|
||||
assert.Contains(t, err.Error(), "input.invalid_argument")
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_PageSizeInvalid(t *testing.T) {
|
||||
svc := &fakeListSvc{}
|
||||
for _, ps := range []int{0, -1, 1001} {
|
||||
err := runList(context.Background(), &ListOptions{DocID: "d", Limit: 50, PageSize: ps}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "input.invalid_argument")
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_MissingDoc_FlagError(t *testing.T) {
|
||||
cmd := NewCmdList(nil)
|
||||
cmd.SetArgs([]string{})
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
require.Error(t, cmd.Execute(), "expect required-flag error for missing --doc")
|
||||
}
|
||||
|
||||
func TestList_Text_TableHeader(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeListSvc{
|
||||
pages: [][]sdk.Chunk{{
|
||||
{ID: "c1", ChunkIndex: 0, Content: "the quick brown fox", ChunkType: "text", IsEnabled: true, UpdatedAt: "2026-05-15T12:00:00Z"},
|
||||
}},
|
||||
totals: []int64{1}, errs: []error{nil},
|
||||
}
|
||||
require.NoError(t, runList(context.Background(), &ListOptions{DocID: "doc_abc", Limit: 50, PageSize: 50}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
|
||||
body := out.String()
|
||||
for _, want := range []string{"CHUNK_ID", "INDEX", "TYPE", "ENABLED", "PREVIEW", "UPDATED", "c1", "text"} {
|
||||
assert.Contains(t, body, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_Text_PreviewTruncatedTo80(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
long := strings.Repeat("a", 200)
|
||||
svc := &fakeListSvc{
|
||||
pages: [][]sdk.Chunk{{{ID: "c1", Content: long, ChunkType: "text", IsEnabled: true}}},
|
||||
totals: []int64{1}, errs: []error{nil},
|
||||
}
|
||||
require.NoError(t, runList(context.Background(), &ListOptions{DocID: "doc_abc", Limit: 50, PageSize: 50}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
|
||||
body := out.String()
|
||||
// 80-col preview means we never see the 100th `a` from the content (only column truncation kicks in).
|
||||
assert.NotContains(t, body, strings.Repeat("a", 100), "preview must be truncated to ~80 chars")
|
||||
}
|
||||
|
||||
func TestList_JSON_BareArray(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeListSvc{
|
||||
pages: [][]sdk.Chunk{{
|
||||
{ID: "c1", KnowledgeID: "doc_abc", KnowledgeBaseID: "kb_x"},
|
||||
}},
|
||||
totals: []int64{1}, errs: []error{nil},
|
||||
}
|
||||
require.NoError(t, runList(context.Background(), &ListOptions{DocID: "doc_abc", Limit: 50, PageSize: 50}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data []sdk.Chunk `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(out.Bytes(), &env))
|
||||
got := env.Data
|
||||
require.Len(t, got, 1)
|
||||
assert.Equal(t, "doc_abc", got[0].KnowledgeID)
|
||||
assert.Equal(t, "kb_x", got[0].KnowledgeBaseID)
|
||||
// SDK snake_case keys must be present.
|
||||
assert.Contains(t, out.String(), `"knowledge_id":"doc_abc"`)
|
||||
assert.NotContains(t, out.String(), `"doc_id"`)
|
||||
}
|
||||
|
||||
func TestList_EmptyResultRendersBareArray(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeListSvc{pages: [][]sdk.Chunk{{}}, totals: []int64{0}, errs: []error{nil}}
|
||||
require.NoError(t, runList(context.Background(), &ListOptions{DocID: "doc_abc", Limit: 50, PageSize: 50}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data []sdk.Chunk `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(out.Bytes(), &env))
|
||||
assert.True(t, env.OK)
|
||||
assert.Len(t, env.Data, 0, "expected empty data array")
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package chunkcmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// chunkViewFields enumerates the 23 SDK Chunk fields surfaced for `--format json`
|
||||
// discovery. JSON is bare SDK pass-through, so keys are snake_case
|
||||
// (`knowledge_id`, `knowledge_base_id`) even though the human KV labels
|
||||
// them as `doc_id` / `kb_id`.
|
||||
var chunkViewFields = []string{
|
||||
"id", "seq_id", "knowledge_id", "knowledge_base_id", "tenant_id",
|
||||
"tag_id", "content", "chunk_index", "is_enabled", "status",
|
||||
"start_at", "end_at", "pre_chunk_id", "next_chunk_id", "chunk_type",
|
||||
"parent_chunk_id", "relation_chunks", "indirect_relation_chunks",
|
||||
"metadata", "content_hash", "image_info", "created_at", "updated_at",
|
||||
}
|
||||
|
||||
// ViewService is the narrow SDK surface this command depends on.
|
||||
type ViewService interface {
|
||||
GetChunkByIDOnly(ctx context.Context, chunkID string) (*sdk.Chunk, error)
|
||||
}
|
||||
|
||||
type ViewOptions struct {
|
||||
ChunkID string
|
||||
}
|
||||
|
||||
const chunkViewLong = `Show a single chunk with all SDK fields.
|
||||
|
||||
Text output is a key-value block; pass --format json for the bare 23-field SDK
|
||||
Chunk object. Content renders verbatim regardless of size — pipe to
|
||||
less or use --format json for large chunks. WeKnora chunks are typically bounded
|
||||
by the ingest pipeline (~1000 tokens / a few KB), so unconditional full
|
||||
rendering is reasonable.
|
||||
|
||||
Scope asymmetry with 'chunk delete':
|
||||
view <id> scope-less (server: GET /chunks/by-id/{id})
|
||||
delete <id> --doc D scoped (server: DELETE /chunks/{doc}/{id})
|
||||
|
||||
The asymmetry is deliberate. Auto-resolving doc id on delete would race
|
||||
the ingest pipeline; forcing --doc on view would add friction with no
|
||||
benefit. AI agents: a chunk id from any source (list, search, agent
|
||||
invoke citation) is sufficient for view.
|
||||
|
||||
Typed exit codes:
|
||||
resource.not_found no chunk with the given id (exit 4)`
|
||||
|
||||
const chunkViewExample = ` weknora chunk view chunk_abc
|
||||
weknora chunk view chunk_abc --format json | jq '.content'
|
||||
weknora chunk view chunk_abc --format json --jq '{id, chunk_index, is_enabled}'`
|
||||
|
||||
// NewCmdView builds `weknora chunk view <chunk-id>`.
|
||||
func NewCmdView(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &ViewOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "view <chunk-id>",
|
||||
Short: "Show a chunk's fields and content (scope-less)",
|
||||
Long: chunkViewLong,
|
||||
Example: chunkViewExample,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
fopts, err := cmdutil.CheckFormatFlag(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
|
||||
opts.ChunkID = args[0]
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runView(c.Context(), opts, fopts, cli)
|
||||
},
|
||||
}
|
||||
cmdutil.AddFormatFlag(cmd, chunkViewFields...)
|
||||
cmdutil.AddIgnoredKBFlag(cmd)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "fetch one chunk's fields and content by id (scope-less; no --doc needed)",
|
||||
RequiredFlags: []string{"<chunk-id> (positional)"},
|
||||
Examples: []string{"weknora chunk view chunk_abc"},
|
||||
Output: "envelope.data is the chunk object (id, content, knowledge_id, ...)",
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runView(ctx context.Context, opts *ViewOptions, fopts *cmdutil.FormatOptions, svc ViewService) error {
|
||||
ch, err := svc.GetChunkByIDOnly(ctx, opts.ChunkID)
|
||||
if err != nil {
|
||||
return cmdutil.WrapHTTP(err, "fetch chunk %s", opts.ChunkID)
|
||||
}
|
||||
if fopts.WantsJSON() {
|
||||
return fopts.Emit(iostreams.IO.Out, ch, nil)
|
||||
}
|
||||
renderChunk(iostreams.IO.Out, ch)
|
||||
return nil
|
||||
}
|
||||
|
||||
// renderChunk prints a single chunk in human-readable KV form.
|
||||
// Field order: id / seq_id / chunk_index / doc_id / kb_id / type / enabled /
|
||||
// status (omit-zero) / start_at (omit-zero) / end_at (omit-zero) /
|
||||
// tag_id (omit-empty) / image_info (omit-empty) / created_at / updated_at /
|
||||
// content (full, no truncation, last entry).
|
||||
//
|
||||
// `doc_id` / `kb_id` are the human-friendly labels for the SDK fields
|
||||
// `knowledge_id` / `knowledge_base_id`; JSON output keeps the SDK names.
|
||||
func renderChunk(w io.Writer, c *sdk.Chunk) {
|
||||
fmt.Fprintf(w, "id: %s\n", c.ID)
|
||||
if c.SeqID != 0 {
|
||||
fmt.Fprintf(w, "seq_id: %d\n", c.SeqID)
|
||||
}
|
||||
fmt.Fprintf(w, "chunk_index: %d\n", c.ChunkIndex)
|
||||
if c.KnowledgeID != "" {
|
||||
fmt.Fprintf(w, "doc_id: %s\n", c.KnowledgeID)
|
||||
}
|
||||
if c.KnowledgeBaseID != "" {
|
||||
fmt.Fprintf(w, "kb_id: %s\n", c.KnowledgeBaseID)
|
||||
}
|
||||
if c.ChunkType != "" {
|
||||
fmt.Fprintf(w, "type: %s\n", c.ChunkType)
|
||||
}
|
||||
enabled := "no"
|
||||
if c.IsEnabled {
|
||||
enabled = "yes"
|
||||
}
|
||||
fmt.Fprintf(w, "enabled: %s\n", enabled)
|
||||
if c.Status != 0 {
|
||||
fmt.Fprintf(w, "status: %d\n", c.Status)
|
||||
}
|
||||
if c.StartAt != 0 {
|
||||
fmt.Fprintf(w, "start_at: %d\n", c.StartAt)
|
||||
}
|
||||
if c.EndAt != 0 {
|
||||
fmt.Fprintf(w, "end_at: %d\n", c.EndAt)
|
||||
}
|
||||
if c.TagID != "" {
|
||||
fmt.Fprintf(w, "tag_id: %s\n", c.TagID)
|
||||
}
|
||||
if c.ImageInfo != "" {
|
||||
fmt.Fprintf(w, "image_info: %s\n", c.ImageInfo)
|
||||
}
|
||||
if c.CreatedAt != "" {
|
||||
fmt.Fprintf(w, "created_at: %s\n", c.CreatedAt)
|
||||
}
|
||||
if c.UpdatedAt != "" {
|
||||
fmt.Fprintf(w, "updated_at: %s\n", c.UpdatedAt)
|
||||
}
|
||||
// Content rendered verbatim, last entry, no truncation.
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintln(w, "content:")
|
||||
fmt.Fprintln(w, c.Content)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package chunkcmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
type fakeViewSvc struct {
|
||||
resp *sdk.Chunk
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeViewSvc) GetChunkByIDOnly(_ context.Context, _ string) (*sdk.Chunk, error) {
|
||||
return f.resp, f.err
|
||||
}
|
||||
|
||||
func TestView_Happy_RendersAllFields(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeViewSvc{resp: &sdk.Chunk{
|
||||
ID: "c1",
|
||||
SeqID: 42,
|
||||
ChunkIndex: 0,
|
||||
KnowledgeID: "doc_abc",
|
||||
KnowledgeBaseID: "kb_abc",
|
||||
ChunkType: "text",
|
||||
IsEnabled: true,
|
||||
Content: "the quick brown fox",
|
||||
CreatedAt: "2026-05-15T11:00:00Z",
|
||||
UpdatedAt: "2026-05-15T12:00:00Z",
|
||||
}}
|
||||
require.NoError(t, runView(context.Background(), &ViewOptions{ChunkID: "c1"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
|
||||
body := out.String()
|
||||
assert.Contains(t, body, "c1")
|
||||
assert.Contains(t, body, "doc_abc")
|
||||
assert.Contains(t, body, "kb_abc")
|
||||
assert.Contains(t, body, "text")
|
||||
assert.Contains(t, body, "the quick brown fox", "content must render in full")
|
||||
}
|
||||
|
||||
func TestView_HumanLabels_DocAndKB(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeViewSvc{resp: &sdk.Chunk{
|
||||
ID: "c1", KnowledgeID: "doc_abc", KnowledgeBaseID: "kb_abc",
|
||||
}}
|
||||
require.NoError(t, runView(context.Background(), &ViewOptions{ChunkID: "c1"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
|
||||
body := out.String()
|
||||
// Text KV uses friendlier DOC_ID / KB_ID labels (the SDK's
|
||||
// knowledge_id / knowledge_base_id are kept only in --format json output).
|
||||
assert.Contains(t, body, "doc_id")
|
||||
assert.Contains(t, body, "kb_id")
|
||||
assert.NotContains(t, body, "knowledge_id")
|
||||
assert.NotContains(t, body, "knowledge_base_id")
|
||||
}
|
||||
|
||||
func TestView_OmitsZeroOrEmpty(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeViewSvc{resp: &sdk.Chunk{ID: "c_min", Content: "x"}}
|
||||
require.NoError(t, runView(context.Background(), &ViewOptions{ChunkID: "c_min"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
|
||||
body := out.String()
|
||||
// status / start_at / end_at all zero → must be omitted from the human KV.
|
||||
assert.NotContains(t, body, "status:")
|
||||
assert.NotContains(t, body, "start_at:")
|
||||
assert.NotContains(t, body, "end_at:")
|
||||
// tag_id / image_info empty → omitted.
|
||||
assert.NotContains(t, body, "tag_id:")
|
||||
assert.NotContains(t, body, "image_info:")
|
||||
}
|
||||
|
||||
func TestView_JSON_BareSDKShape(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeViewSvc{resp: &sdk.Chunk{
|
||||
ID: "c_json", KnowledgeID: "doc_abc", KnowledgeBaseID: "kb_abc",
|
||||
}}
|
||||
require.NoError(t, runView(context.Background(), &ViewOptions{ChunkID: "c_json"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data sdk.Chunk `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(out.Bytes(), &env))
|
||||
got := env.Data
|
||||
assert.Equal(t, "c_json", got.ID)
|
||||
assert.Equal(t, "doc_abc", got.KnowledgeID)
|
||||
// JSON uses SDK snake_case keys (knowledge_id), not text relabel doc_id.
|
||||
assert.Contains(t, out.String(), `"knowledge_id":"doc_abc"`)
|
||||
assert.Contains(t, out.String(), `"knowledge_base_id":"kb_abc"`)
|
||||
assert.NotContains(t, out.String(), `"doc_id"`)
|
||||
assert.NotContains(t, out.String(), `"kb_id"`)
|
||||
}
|
||||
|
||||
func TestView_404(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeViewSvc{err: errors.New("HTTP error 404: not found")}
|
||||
err := runView(context.Background(), &ViewOptions{ChunkID: "missing"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "resource.not_found")
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
// TestCompletion_AllShells smoke-tests cobra's auto-registered completion
|
||||
// command. The output is shell-specific bytecode/script; the test just
|
||||
// asserts each shell produces a non-trivially-sized script and references
|
||||
// the binary name. Guards against future cobra bumps silently breaking
|
||||
// completion for one shell.
|
||||
func TestCompletion_AllShells(t *testing.T) {
|
||||
cases := []struct {
|
||||
shell string
|
||||
mustContain string // signature byte sequence specific to the shell
|
||||
minSizeBytes int
|
||||
}{
|
||||
{"bash", "bash completion", 1024},
|
||||
{"zsh", "#compdef", 1024},
|
||||
{"fish", "complete -c weknora", 1024},
|
||||
{"powershell", "Register-ArgumentCompleter", 1024},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.shell, func(t *testing.T) {
|
||||
cmd := NewRootCmd(&cmdutil.Factory{})
|
||||
var buf bytes.Buffer
|
||||
cmd.SetOut(&buf)
|
||||
cmd.SetErr(&buf)
|
||||
cmd.SetArgs([]string{"completion", tc.shell})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("completion %s: %v", tc.shell, err)
|
||||
}
|
||||
if buf.Len() < tc.minSizeBytes {
|
||||
t.Errorf("%s script suspiciously short: %d bytes", tc.shell, buf.Len())
|
||||
}
|
||||
if !strings.Contains(buf.String(), tc.mustContain) {
|
||||
t.Errorf("%s script missing signature %q (first 200 bytes: %q)",
|
||||
tc.shell, tc.mustContain, buf.String()[:min(200, buf.Len())])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Package configcmd holds the `weknora config` command tree.
|
||||
//
|
||||
// Currently a single read-only `view` subcommand that prints the resolved
|
||||
// CLI configuration and its resolution chain. No mutation, no network.
|
||||
//
|
||||
// Package name `configcmd` (not `config`) avoids colliding with the
|
||||
// internal/config package and matches the cmd-subpackage naming pattern.
|
||||
// The cobra Use: string is "config" — what users type.
|
||||
package configcmd
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
// NewCmd builds the `weknora config` parent command.
|
||||
func NewCmd(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "config",
|
||||
Short: "Inspect the CLI's resolved configuration",
|
||||
}
|
||||
cmd.AddCommand(NewCmdView(f))
|
||||
return cmd
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user