Add personal memory and admin dashboard
This commit is contained in:
@@ -8,6 +8,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import csv
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
@@ -48,6 +49,11 @@ from src.jupyter_runtime import (
|
||||
)
|
||||
from src.mcp_runtime import MCPRuntime, MCPServerProfile
|
||||
from src.openai_compat import OpenAICompatClient, OpenAICompatError
|
||||
from src.personal_memory import (
|
||||
SKILL_MEMORY_DIRNAME,
|
||||
USER_MEMORY_FILENAME,
|
||||
PersonalMemoryManager,
|
||||
)
|
||||
from src.session_store import (
|
||||
DEFAULT_AGENT_SESSION_DIR,
|
||||
StoredAgentSession,
|
||||
@@ -514,6 +520,11 @@ class AgentState:
|
||||
allow_shell=allow_shell,
|
||||
allow_write=allow_write,
|
||||
)
|
||||
self.memory_manager = PersonalMemoryManager(
|
||||
self.session_directory.parent / 'accounts',
|
||||
self.config_for,
|
||||
)
|
||||
self.memory_manager.start()
|
||||
|
||||
@property
|
||||
def cwd(self) -> Path:
|
||||
@@ -582,6 +593,7 @@ class AgentState:
|
||||
'uploads': self.session_directory,
|
||||
'outputs': self.session_directory,
|
||||
'python_env': base / 'python' / '.venv',
|
||||
'memory': base / 'memory',
|
||||
}
|
||||
base = self._account_base(account_id)
|
||||
return {
|
||||
@@ -591,6 +603,7 @@ class AgentState:
|
||||
'uploads': base / 'sessions',
|
||||
'outputs': base / 'sessions',
|
||||
'python_env': base / 'python' / '.venv',
|
||||
'memory': base / 'memory',
|
||||
}
|
||||
|
||||
def _skill_settings_path(self, account_id: str | None) -> Path:
|
||||
@@ -955,6 +968,21 @@ class FeishuOnlineDocRequest(BaseModel):
|
||||
account_id: str | None = None
|
||||
|
||||
|
||||
class AdminLoginRequest(BaseModel):
|
||||
username: str = Field(min_length=1)
|
||||
password: str = Field(min_length=1)
|
||||
|
||||
|
||||
class AdminAccountCreateRequest(BaseModel):
|
||||
account_id: str = Field(min_length=2)
|
||||
|
||||
|
||||
class MemoryUpdateRequest(BaseModel):
|
||||
account_id: str = Field(min_length=1)
|
||||
content: str
|
||||
skill: str | None = None
|
||||
|
||||
|
||||
def _append_runtime_context(current: str | None, addition: str) -> str:
|
||||
parts = [part.strip() for part in (current, addition) if part and part.strip()]
|
||||
return '\n\n'.join(parts)
|
||||
@@ -1163,6 +1191,158 @@ def create_app(state: AgentState) -> FastAPI:
|
||||
)
|
||||
return result
|
||||
|
||||
# ------------- memory ----------------------------------------------------
|
||||
@app.get('/api/memory/user')
|
||||
async def get_user_memory(account_id: str) -> dict[str, Any]:
|
||||
try:
|
||||
return state.memory_manager.list_user_memory(_safe_account_id(account_id))
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
@app.put('/api/memory/user')
|
||||
async def put_user_memory(payload: MemoryUpdateRequest) -> dict[str, Any]:
|
||||
try:
|
||||
return state.memory_manager.update_user_memory(
|
||||
_safe_account_id(payload.account_id),
|
||||
payload.content,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
@app.get('/api/memory/skills')
|
||||
async def get_skill_memories(account_id: str) -> list[dict[str, Any]]:
|
||||
try:
|
||||
return state.memory_manager.list_skill_memories(_safe_account_id(account_id))
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
@app.get('/api/memory/skills/{skill_name}')
|
||||
async def get_skill_memory(skill_name: str, account_id: str) -> dict[str, Any]:
|
||||
try:
|
||||
return state.memory_manager.read_skill_memory(
|
||||
_safe_account_id(account_id),
|
||||
skill_name,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
@app.put('/api/memory/skills/{skill_name}')
|
||||
async def put_skill_memory(
|
||||
skill_name: str,
|
||||
payload: MemoryUpdateRequest,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
return state.memory_manager.update_skill_memory(
|
||||
_safe_account_id(payload.account_id),
|
||||
skill_name,
|
||||
payload.content,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
@app.delete('/api/memory/skills/{skill_name}')
|
||||
async def delete_skill_memory(skill_name: str, account_id: str) -> dict[str, Any]:
|
||||
try:
|
||||
return state.memory_manager.delete_skill_memory(
|
||||
_safe_account_id(account_id),
|
||||
skill_name,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
@app.get('/api/memory/queue')
|
||||
async def get_memory_queue(account_id: str | None = None) -> dict[str, Any]:
|
||||
return state.memory_manager.queue_snapshot(
|
||||
_safe_account_id(account_id) if account_id else None
|
||||
)
|
||||
|
||||
@app.get('/api/memory/events')
|
||||
async def get_memory_events(
|
||||
account_id: str,
|
||||
limit: int = 50,
|
||||
) -> list[dict[str, Any]]:
|
||||
return state.memory_manager.list_recent_events(
|
||||
_safe_account_id(account_id),
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
# ------------- admin -----------------------------------------------------
|
||||
@app.post('/api/admin/login')
|
||||
async def admin_login(payload: AdminLoginRequest) -> dict[str, Any]:
|
||||
if payload.username == 'admin' and payload.password == 'admin':
|
||||
return {'token': _admin_token(), 'username': 'admin'}
|
||||
raise HTTPException(status_code=401, detail='管理员账号或密码错误')
|
||||
|
||||
@app.get('/api/admin/summary')
|
||||
async def admin_summary(token: str) -> dict[str, Any]:
|
||||
_require_admin_token(token)
|
||||
return _build_admin_summary(state)
|
||||
|
||||
@app.get('/api/admin/accounts')
|
||||
async def admin_accounts(token: str) -> list[dict[str, Any]]:
|
||||
_require_admin_token(token)
|
||||
return _list_admin_accounts(state)
|
||||
|
||||
@app.post('/api/admin/accounts')
|
||||
async def admin_create_account(
|
||||
token: str,
|
||||
payload: AdminAccountCreateRequest,
|
||||
) -> dict[str, Any]:
|
||||
_require_admin_token(token)
|
||||
return _admin_create_account(state, payload.account_id)
|
||||
|
||||
@app.delete('/api/admin/accounts/{account_id}')
|
||||
async def admin_delete_account(account_id: str, token: str) -> dict[str, Any]:
|
||||
_require_admin_token(token)
|
||||
return _admin_delete_account(state, account_id)
|
||||
|
||||
@app.get('/api/admin/memory')
|
||||
async def admin_memory(token: str, account_id: str | None = None) -> dict[str, Any]:
|
||||
_require_admin_token(token)
|
||||
safe_account = _safe_account_id(account_id) if account_id else None
|
||||
return {
|
||||
'queue': state.memory_manager.queue_snapshot(safe_account),
|
||||
'events': (
|
||||
state.memory_manager.list_recent_events(safe_account, limit=100)
|
||||
if safe_account
|
||||
else []
|
||||
),
|
||||
}
|
||||
|
||||
@app.get('/api/admin/memory/user')
|
||||
async def admin_user_memory(token: str, account_id: str) -> dict[str, Any]:
|
||||
_require_admin_token(token)
|
||||
return state.memory_manager.list_user_memory(_safe_account_id(account_id))
|
||||
|
||||
@app.put('/api/admin/memory/user')
|
||||
async def admin_update_user_memory(
|
||||
token: str,
|
||||
payload: MemoryUpdateRequest,
|
||||
) -> dict[str, Any]:
|
||||
_require_admin_token(token)
|
||||
return state.memory_manager.update_user_memory(
|
||||
_safe_account_id(payload.account_id),
|
||||
payload.content,
|
||||
)
|
||||
|
||||
@app.get('/api/admin/memory/skills')
|
||||
async def admin_skill_memories(token: str, account_id: str) -> list[dict[str, Any]]:
|
||||
_require_admin_token(token)
|
||||
return state.memory_manager.list_skill_memories(_safe_account_id(account_id))
|
||||
|
||||
@app.put('/api/admin/memory/skills/{skill_name}')
|
||||
async def admin_update_skill_memory(
|
||||
skill_name: str,
|
||||
token: str,
|
||||
payload: MemoryUpdateRequest,
|
||||
) -> dict[str, Any]:
|
||||
_require_admin_token(token)
|
||||
return state.memory_manager.update_skill_memory(
|
||||
_safe_account_id(payload.account_id),
|
||||
skill_name,
|
||||
payload.content,
|
||||
)
|
||||
|
||||
@app.get('/api/models')
|
||||
async def list_models_get(account_id: str | None = None) -> dict[str, Any]:
|
||||
config = state.config_for(account_id)
|
||||
@@ -1450,6 +1630,12 @@ def create_app(state: AgentState) -> FastAPI:
|
||||
requested_session_id,
|
||||
)
|
||||
runtime_context = request.runtime_context
|
||||
memory_context = state.memory_manager.render_injection(
|
||||
request.account_id,
|
||||
state.enabled_skill_names(request.account_id),
|
||||
)
|
||||
if memory_context:
|
||||
runtime_context = _append_runtime_context(runtime_context, memory_context)
|
||||
if jupyter_runtime is not None:
|
||||
runtime_context = _append_runtime_context(
|
||||
runtime_context,
|
||||
@@ -1575,6 +1761,9 @@ def create_app(state: AgentState) -> FastAPI:
|
||||
elapsed_ms = max(0, int((time.perf_counter() - started_at) * 1000))
|
||||
payload = _serialize_run_result(result)
|
||||
payload['elapsed_ms'] = elapsed_ms
|
||||
used_skills = _extract_used_skill_names(payload.get('transcript') or ())
|
||||
if not used_skills:
|
||||
used_skills = tuple(state.enabled_skill_names(request.account_id))
|
||||
if result.session_id:
|
||||
_annotate_last_assistant_elapsed(
|
||||
session_directory,
|
||||
@@ -1591,6 +1780,17 @@ def create_app(state: AgentState) -> FastAPI:
|
||||
fallback_title=fallback_title,
|
||||
)
|
||||
_annotate_transcript_elapsed(payload, elapsed_ms)
|
||||
try:
|
||||
state.memory_manager.enqueue_interaction(
|
||||
account_id=request.account_id,
|
||||
session_id=result.session_id,
|
||||
user_prompt=prompt,
|
||||
assistant_output=result.final_output,
|
||||
skills=used_skills,
|
||||
model=config.model,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
state.run_manager.finish(
|
||||
run_record.run_id,
|
||||
'cancelled' if run_record.cancel_event.is_set() else 'completed',
|
||||
@@ -3288,6 +3488,213 @@ def _safe_account_id(account_id: str | None) -> str:
|
||||
return normalized[:80] or 'default'
|
||||
|
||||
|
||||
def _admin_token() -> str:
|
||||
return os.environ.get('ZK_ADMIN_TOKEN') or 'admin'
|
||||
|
||||
|
||||
def _require_admin_token(token: str) -> None:
|
||||
if token != _admin_token():
|
||||
raise HTTPException(status_code=401, detail='Admin unauthorized')
|
||||
|
||||
|
||||
def _accounts_root(state: AgentState) -> Path:
|
||||
return state.session_directory.parent / 'accounts'
|
||||
|
||||
|
||||
def _users_json_path(state: AgentState) -> Path:
|
||||
return _accounts_root(state) / 'users.json'
|
||||
|
||||
|
||||
def _auth_sessions_json_path(state: AgentState) -> Path:
|
||||
return _accounts_root(state) / 'auth_sessions.json'
|
||||
|
||||
|
||||
def _load_users_file(state: AgentState) -> dict[str, Any]:
|
||||
path = _users_json_path(state)
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding='utf-8'))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
payload = {'users': []}
|
||||
if not isinstance(payload, dict) or not isinstance(payload.get('users'), list):
|
||||
return {'users': []}
|
||||
return payload
|
||||
|
||||
|
||||
def _save_users_file(state: AgentState, payload: dict[str, Any]) -> None:
|
||||
path = _users_json_path(state)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
|
||||
|
||||
|
||||
def _hash_admin_created_password(password: str = '123456') -> str:
|
||||
salt = os.urandom(16).hex()
|
||||
digest = hashlib.scrypt(
|
||||
password.encode('utf-8'),
|
||||
salt=salt.encode('utf-8'),
|
||||
n=16384,
|
||||
r=8,
|
||||
p=1,
|
||||
dklen=64,
|
||||
).hex()
|
||||
return f'{salt}:{digest}'
|
||||
|
||||
|
||||
def _build_admin_summary(state: AgentState) -> dict[str, Any]:
|
||||
accounts = _list_admin_accounts(state)
|
||||
totals = {
|
||||
'accounts': len(accounts),
|
||||
'sessions': sum(item['session_count'] for item in accounts),
|
||||
'tool_calls': sum(item['tool_calls'] for item in accounts),
|
||||
'tokens': sum(item['total_tokens'] for item in accounts),
|
||||
}
|
||||
return {
|
||||
'totals': totals,
|
||||
'memory_queue': state.memory_manager.queue_snapshot(),
|
||||
'accounts': accounts[:20],
|
||||
}
|
||||
|
||||
|
||||
def _list_admin_accounts(state: AgentState) -> list[dict[str, Any]]:
|
||||
users_payload = _load_users_file(state)
|
||||
known_users = {
|
||||
str(item.get('id') or item.get('username') or '').strip()
|
||||
for item in users_payload.get('users', [])
|
||||
if isinstance(item, dict)
|
||||
}
|
||||
accounts_root = _accounts_root(state)
|
||||
directory_users = (
|
||||
{
|
||||
path.name
|
||||
for path in accounts_root.iterdir()
|
||||
if path.is_dir() and path.name not in {'__pycache__'}
|
||||
}
|
||||
if accounts_root.exists()
|
||||
else set()
|
||||
)
|
||||
account_ids = sorted({item for item in known_users | directory_users if item})
|
||||
result: list[dict[str, Any]] = []
|
||||
for account_id in account_ids:
|
||||
base = accounts_root / account_id
|
||||
sessions_dir = base / 'sessions'
|
||||
session_files = _iter_session_files(sessions_dir)
|
||||
session_count = 0
|
||||
tool_calls = 0
|
||||
total_tokens = 0
|
||||
input_tokens = 0
|
||||
output_tokens = 0
|
||||
latest_mtime = 0.0
|
||||
models: dict[str, int] = {}
|
||||
for path in session_files:
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding='utf-8'))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
continue
|
||||
session_count += 1
|
||||
tool_calls += int(data.get('tool_calls') or 0)
|
||||
usage = data.get('usage') if isinstance(data.get('usage'), dict) else {}
|
||||
input_tokens += int(usage.get('input_tokens') or 0)
|
||||
output_tokens += int(usage.get('output_tokens') or 0)
|
||||
total_tokens += int(
|
||||
usage.get('total_tokens')
|
||||
or (usage.get('input_tokens') or 0) + (usage.get('output_tokens') or 0)
|
||||
)
|
||||
model_config = data.get('model_config') if isinstance(data.get('model_config'), dict) else {}
|
||||
model = str(model_config.get('model') or data.get('model') or 'unknown')
|
||||
models[model] = models.get(model, 0) + 1
|
||||
latest_mtime = max(latest_mtime, _session_mtime(path))
|
||||
memory_dir = base / 'memory'
|
||||
result.append(
|
||||
{
|
||||
'account_id': account_id,
|
||||
'registered': account_id in known_users,
|
||||
'session_count': session_count,
|
||||
'tool_calls': tool_calls,
|
||||
'input_tokens': input_tokens,
|
||||
'output_tokens': output_tokens,
|
||||
'total_tokens': total_tokens,
|
||||
'latest_session_at': latest_mtime,
|
||||
'models': models,
|
||||
'user_memory_lines': _count_optional_lines(memory_dir / USER_MEMORY_FILENAME),
|
||||
'skill_memory_count': len(list((memory_dir / SKILL_MEMORY_DIRNAME).glob('*.md')))
|
||||
if (memory_dir / SKILL_MEMORY_DIRNAME).exists()
|
||||
else 0,
|
||||
}
|
||||
)
|
||||
return sorted(result, key=lambda item: item['latest_session_at'], reverse=True)
|
||||
|
||||
|
||||
def _admin_create_account(state: AgentState, account_id: str) -> dict[str, Any]:
|
||||
safe_id = _safe_account_id(account_id)
|
||||
if safe_id == 'default':
|
||||
raise HTTPException(status_code=400, detail='账号名不合法')
|
||||
users = _load_users_file(state)
|
||||
user_rows = users.setdefault('users', [])
|
||||
if any(
|
||||
isinstance(item, dict)
|
||||
and str(item.get('id') or item.get('username') or '').strip() == safe_id
|
||||
for item in user_rows
|
||||
):
|
||||
raise HTTPException(status_code=400, detail='账号已存在')
|
||||
user_rows.append(
|
||||
{
|
||||
'id': safe_id,
|
||||
'username': safe_id,
|
||||
'passwordHash': _hash_admin_created_password(),
|
||||
'createdAt': datetime_utc_iso(),
|
||||
}
|
||||
)
|
||||
_save_users_file(state, users)
|
||||
base = _accounts_root(state) / safe_id
|
||||
(base / 'sessions').mkdir(parents=True, exist_ok=True)
|
||||
state.memory_manager.ensure_account(safe_id)
|
||||
return {'account_id': safe_id, 'created': True, 'initial_password': '123456'}
|
||||
|
||||
|
||||
def _admin_delete_account(state: AgentState, account_id: str) -> dict[str, Any]:
|
||||
safe_id = _safe_account_id(account_id)
|
||||
users = _load_users_file(state)
|
||||
users['users'] = [
|
||||
item
|
||||
for item in users.get('users', [])
|
||||
if not (
|
||||
isinstance(item, dict)
|
||||
and str(item.get('id') or item.get('username') or '').strip() == safe_id
|
||||
)
|
||||
]
|
||||
_save_users_file(state, users)
|
||||
sessions_path = _auth_sessions_json_path(state)
|
||||
try:
|
||||
auth_sessions = json.loads(sessions_path.read_text(encoding='utf-8'))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
auth_sessions = {'sessions': {}}
|
||||
if isinstance(auth_sessions.get('sessions'), dict):
|
||||
auth_sessions['sessions'] = {
|
||||
token: session
|
||||
for token, session in auth_sessions['sessions'].items()
|
||||
if not (isinstance(session, dict) and session.get('accountId') == safe_id)
|
||||
}
|
||||
sessions_path.write_text(
|
||||
json.dumps(auth_sessions, ensure_ascii=False, indent=2) + '\n',
|
||||
encoding='utf-8',
|
||||
)
|
||||
base = _accounts_root(state) / safe_id
|
||||
if base.exists():
|
||||
shutil.rmtree(base)
|
||||
state._clear_agents_for_account(safe_id)
|
||||
return {'account_id': safe_id, 'deleted': True}
|
||||
|
||||
|
||||
def _count_optional_lines(path: Path) -> int:
|
||||
try:
|
||||
return len(path.read_text(encoding='utf-8').splitlines())
|
||||
except OSError:
|
||||
return 0
|
||||
|
||||
|
||||
def datetime_utc_iso() -> str:
|
||||
return time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
|
||||
|
||||
|
||||
def _is_internal_message(content: str) -> bool:
|
||||
return content.lstrip().startswith('<system-reminder>')
|
||||
|
||||
@@ -3302,6 +3709,40 @@ def _strip_session_context(content: str) -> str:
|
||||
return content.strip()
|
||||
|
||||
|
||||
def _extract_used_skill_names(transcript: Any) -> tuple[str, ...]:
|
||||
"""从 transcript 中尽量提取实际调用过的 Skill 名称。"""
|
||||
names: list[str] = []
|
||||
if not isinstance(transcript, (list, tuple)):
|
||||
return ()
|
||||
for entry in transcript:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
tool_calls = entry.get('tool_calls')
|
||||
if not isinstance(tool_calls, list):
|
||||
continue
|
||||
for call in tool_calls:
|
||||
if not isinstance(call, dict):
|
||||
continue
|
||||
function = call.get('function') if isinstance(call.get('function'), dict) else {}
|
||||
tool_name = call.get('name') or function.get('name')
|
||||
if str(tool_name).lower() != 'skill':
|
||||
continue
|
||||
arguments = call.get('arguments') or function.get('arguments')
|
||||
if isinstance(arguments, str):
|
||||
try:
|
||||
arguments = json.loads(arguments)
|
||||
except json.JSONDecodeError:
|
||||
arguments = {}
|
||||
if not isinstance(arguments, dict):
|
||||
continue
|
||||
for key in ('skill', 'skill_name', 'name'):
|
||||
value = arguments.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
names.append(value.strip())
|
||||
break
|
||||
return tuple(dict.fromkeys(names))
|
||||
|
||||
|
||||
def _iter_session_files(directory: Path) -> list[Path]:
|
||||
if not directory.exists():
|
||||
return []
|
||||
|
||||
Reference in New Issue
Block a user