← 返回聊天
新建
删除
Models
gpt5.php
gpt5_file.php
gpt5_mini_file.php
openai_chat.php
Tools
get_time.php
get_weather.php
global_search_messages.php
math.php
memo.php
news.php
search_arxiv.php
search_crossref.php
search_github_code.php
search_pubmed.php
search_semantic_scholar.php
stock_market.php
url.php
<?php declare(strict_types=1); /** * memo.php * 备忘录 = 目录(toc) + 页面(pages),默认只返回目录,按需加载页面内容。 * * 文件:__DIR__/memo.json * 结构: * { * "toc": [{"id":"p_xxx","title":"...","desc":"...","updated_at":"..."}], * "order": ["p_xxx","p_yyy"], * "pages": { * "p_xxx": {"title":"...","desc":"...","blocks":[{"id":"b_xxx","text":"..."}],"updated_at":"...","created_at":"..."} * }, * "updated_at":"..." * } */ return [ 'name' => 'memo', 'description' => "会话外“备忘录/知识页”工具(目录 + 分页内容)。除非你确定不需要记忆/备忘录信息,否则应先调用 op=get 获取目录(toc)查看有哪些页面以及每页简介;若需要具体内容,再在同一次调用中通过 pages 指定页码返回对应页内容。支持新增/修改/删除页面,以及对页面内容 blocks 进行插入/更新/删除。", 'parameters' => [ 'type' => 'object', 'properties' => [ 'op' => [ 'type' => 'string', 'description' => "操作类型:get(默认仅目录,可选带 pages 返回内容) / add_page / set_page / delete_page / insert_block / update_block / delete_block", ], // get: 指定需要附带内容的页码(1-based) 'pages' => [ 'type' => ['array', 'null'], 'description' => '仅 op=get 时使用:要返回内容的页码列表(从 1 开始)。不传则只返回目录。', 'items' => ['type' => 'integer'], ], // 页面操作:page 页码(1-based) 'page' => [ 'type' => ['integer', 'null'], 'description' => '页码(从 1 开始)。用于 set_page/delete_page/insert_block/update_block/delete_block。', ], // 新增/修改页面字段 'title' => [ 'type' => ['string', 'null'], 'description' => '页面标题(add_page 必填;set_page 可选)。', ], 'desc' => [ 'type' => ['string', 'null'], 'description' => '页面简介(目录中展示)(add_page 可选;set_page 可选)。', ], 'content' => [ 'type' => ['string', 'null'], 'description' => "页面内容(纯文本/markdown)。用于 add_page/set_page。若你使用 blocks 方式编辑内容,建议留空或仅做整页重写。", ], 'blocks' => [ 'type' => ['array', 'null'], 'description' => "页面内容 blocks(更推荐,结构化更稳):[{id?:string,text:string}, ...]。add_page/set_page 可用;若提供 blocks,会替代 content。", 'items' => [ 'type' => 'object', 'properties' => [ 'id' => ['type' => ['string', 'null']], 'text' => ['type' => 'string'], ], 'required' => ['text'], 'additionalProperties' => false, ], ], // block 操作参数 'block_id' => [ 'type' => ['string', 'null'], 'description' => '块 ID(update_block/delete_block 必填;insert_block 可选 after_block_id)。', ], 'after_block_id' => [ 'type' => ['string', 'null'], 'description' => '仅 insert_block:插入到指定块之后;不传则追加到末尾。', ], 'text' => [ 'type' => ['string', 'null'], 'description' => '仅 insert_block/update_block:块文本内容。', ], // 通用控制 'init_if_missing' => [ 'type' => 'boolean', 'description' => 'memo.json 不存在时是否自动初始化。', 'default' => true, ], 'max_bytes' => [ 'type' => 'integer', 'description' => 'memo.json 最大体积(字节)。超过则拒绝写入,避免无限膨胀。', 'default' => 524288, // 512KB ], 'max_return_chars_per_page' => [ 'type' => 'integer', 'description' => 'get 返回页面内容时,每页最多返回多少字符(避免过长)。', 'default' => 8000, ], ], 'required' => ['op'], 'additionalProperties' => false, ], 'run' => function(array $args, array $context) { $t0 = microtime(true); $op = (string)($args['op'] ?? ''); $init = (bool)($args['init_if_missing'] ?? true); $maxBytes = (int)($args['max_bytes'] ?? 524288); $maxBytes = max(65536, min($maxBytes, 5_000_000)); // 64KB ~ 5MB $maxReturnChars = (int)($args['max_return_chars_per_page'] ?? 8000); $maxReturnChars = max(500, min($maxReturnChars, 100000)); $validOps = ['get','add_page','set_page','delete_page','insert_block','update_block','delete_block']; if (!in_array($op, $validOps, true)) { return ['ok' => false, 'error' => 'Invalid op. Use: ' . implode('/', $validOps)]; } // 固定文件路径(防路径注入) $file = __DIR__ . DIRECTORY_SEPARATOR . 'memo.json'; $tmp = __DIR__ . DIRECTORY_SEPARATOR . 'memo.json.tmp'; $nowIso = date('c'); // ---------- helpers ---------- $genId = function(string $prefix): string { // 简单稳定的随机 ID(不依赖扩展) $rand = bin2hex(random_bytes(8)); return $prefix . '_' . $rand; }; $defaultDoc = function() use ($nowIso) { return [ 'toc' => [], 'order' => [], 'pages' => new stdClass(), 'updated_at' => $nowIso, ]; }; $normalizeDoc = function($doc) use ($defaultDoc) { if (!is_array($doc)) $doc = $defaultDoc(); if (!isset($doc['toc']) || !is_array($doc['toc'])) $doc['toc'] = []; if (!isset($doc['order']) || !is_array($doc['order'])) $doc['order'] = []; if (!isset($doc['pages'])) $doc['pages'] = new stdClass(); if (is_array($doc['pages'])) { // 允许 pages 是数组,统一成关联数组 } elseif (!($doc['pages'] instanceof stdClass)) { $doc['pages'] = new stdClass(); } if (!isset($doc['updated_at'])) $doc['updated_at'] = date('c'); return $doc; }; $loadDoc = function() use ($file, $init, $defaultDoc, $normalizeDoc) { if (!file_exists($file)) { if (!$init) return [false, null, 'memo.json not found']; return [true, $normalizeDoc($defaultDoc()), null]; } $raw = file_get_contents($file); if ($raw === false) return [false, null, 'failed to read memo.json']; $doc = json_decode($raw, true); if (!is_array($doc)) return [false, null, 'memo.json is not valid JSON']; return [true, $normalizeDoc($doc), null]; }; $saveDoc = function(array $doc) use ($tmp, $file, $maxBytes) { $doc['updated_at'] = date('c'); $json = json_encode($doc, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); if ($json === false) return [false, 'json_encode failed']; if (strlen($json) > $maxBytes) { return [false, 'memo too large, refused (max_bytes=' . $maxBytes . ')']; } if (file_put_contents($tmp, $json, LOCK_EX) === false) { return [false, 'failed to write tmp']; } if (!rename($tmp, $file)) { return [false, 'failed to rename tmp to memo.json']; } return [true, null]; }; $buildTocWithPageNumbers = function(array $doc): array { // 用 order 决定页码:1-based $tocMap = []; foreach ($doc['toc'] as $item) { if (isset($item['id'])) $tocMap[(string)$item['id']] = $item; } $out = []; $i = 1; foreach ($doc['order'] as $pid) { $pid = (string)$pid; $item = $tocMap[$pid] ?? ['id' => $pid, 'title' => '(missing)', 'desc' => '', 'updated_at' => null]; $out[] = [ 'page' => $i, 'id' => $pid, 'title' => $item['title'] ?? '', 'desc' => $item['desc'] ?? '', 'updated_at' => $item['updated_at'] ?? null, ]; $i++; } return $out; }; $pageIdByNumber = function(array $doc, int $pageNum) { // pageNum: 1-based if ($pageNum < 1) return null; $idx = $pageNum - 1; if (!array_key_exists($idx, $doc['order'])) return null; return (string)$doc['order'][$idx]; }; $getPage = function(array $doc, string $pageId) { $pages = $doc['pages']; if (is_array($pages)) { return $pages[$pageId] ?? null; } if ($pages instanceof stdClass) { return property_exists($pages, $pageId) ? $pages->{$pageId} : null; } return null; }; $setPage = function(array &$doc, string $pageId, array $pageData) { if (is_array($doc['pages'])) { $doc['pages'][$pageId] = $pageData; return; } if ($doc['pages'] instanceof stdClass) { $doc['pages']->{$pageId} = $pageData; return; } $doc['pages'] = [$pageId => $pageData]; }; $deletePageData = function(array &$doc, string $pageId) { if (is_array($doc['pages'])) { unset($doc['pages'][$pageId]); return; } if ($doc['pages'] instanceof stdClass) { if (property_exists($doc['pages'], $pageId)) unset($doc['pages']->{$pageId}); return; } }; $ensureBlocks = function($content, $blocks, callable $genId): array { // 若提供 blocks,则规范化;否则将 content 变成单 block if (is_array($blocks)) { $out = []; foreach ($blocks as $b) { $text = isset($b['text']) ? (string)$b['text'] : ''; $bid = isset($b['id']) && $b['id'] ? (string)$b['id'] : $genId('b'); $out[] = ['id' => $bid, 'text' => $text]; } return $out; } $text = trim((string)$content); if ($text === '') { // 空页面也给一个空 block,方便后续插入/更新 return [['id' => $genId('b'), 'text' => '']]; } return [['id' => $genId('b'), 'text' => $text]]; }; $blocksToPlainText = function(array $blocks): string { $parts = []; foreach ($blocks as $b) { $parts[] = (string)($b['text'] ?? ''); } return trim(implode("\n", $parts)); }; $truncateUtf8 = function(string $s, int $maxChars): string { if (mb_strlen($s, 'UTF-8') <= $maxChars) return $s; return mb_substr($s, 0, $maxChars, 'UTF-8') . '…'; }; $touchTocItem = function(array &$doc, string $pageId, ?string $title, ?string $desc) use ($nowIso) { $found = false; for ($i = 0; $i < count($doc['toc']); $i++) { if ((string)($doc['toc'][$i]['id'] ?? '') === $pageId) { if ($title !== null) $doc['toc'][$i]['title'] = $title; if ($desc !== null) $doc['toc'][$i]['desc'] = $desc; $doc['toc'][$i]['updated_at'] = date('c'); $found = true; break; } } if (!$found) { $doc['toc'][] = [ 'id' => $pageId, 'title' => $title ?? '', 'desc' => $desc ?? '', 'updated_at' => date('c'), ]; } }; $removeTocItem = function(array &$doc, string $pageId) { $doc['toc'] = array_values(array_filter($doc['toc'], function($it) use ($pageId) { return (string)($it['id'] ?? '') !== $pageId; })); }; // ---------- lock + load ---------- $fp = fopen($file, 'c+'); // 不存在则创建 if ($fp === false) return ['ok' => false, 'error' => 'failed to open memo.json handle']; if (!flock($fp, LOCK_EX)) { fclose($fp); return ['ok' => false, 'error' => 'failed to acquire lock']; } try { [$ok, $doc, $err] = $loadDoc(); if (!$ok) return ['ok' => false, 'error' => $err]; $doc = $normalizeDoc($doc); // ---------- op: get ---------- if ($op === 'get') { $toc = $buildTocWithPageNumbers($doc); $reqPages = $args['pages'] ?? null; if (!is_array($reqPages) || count($reqPages) === 0) { return [ 'ok' => true, 'took_ms' => (int)round((microtime(true) - $t0) * 1000), 'toc' => $toc, 'pages' => null, 'hint' => '如果需要某页内容,请传 pages:[页码...](页码从 1 开始)。', ]; } $pagesOut = []; foreach ($reqPages as $pn) { $pn = (int)$pn; $pid = $pageIdByNumber($doc, $pn); if ($pid === null) { $pagesOut[] = ['page' => $pn, 'found' => false, 'error' => 'page out of range']; continue; } $pageData = $getPage($doc, $pid); if ($pageData === null || !is_array($pageData)) { $pagesOut[] = ['page' => $pn, 'id' => $pid, 'found' => false, 'error' => 'page data missing']; continue; } $blocks = $pageData['blocks'] ?? []; if (!is_array($blocks)) $blocks = []; $plain = $blocksToPlainText($blocks); $pagesOut[] = [ 'page' => $pn, 'id' => $pid, 'found' => true, 'title' => $pageData['title'] ?? '', 'desc' => $pageData['desc'] ?? '', 'created_at' => $pageData['created_at'] ?? null, 'updated_at' => $pageData['updated_at'] ?? null, 'blocks' => $blocks, 'plain_text' => $truncateUtf8($plain, $maxReturnChars), ]; } return [ 'ok' => true, 'took_ms' => (int)round((microtime(true) - $t0) * 1000), 'toc' => $toc, 'pages' => $pagesOut, ]; } // ---------- op: add_page ---------- if ($op === 'add_page') { $title = trim((string)($args['title'] ?? '')); if ($title === '') return ['ok' => false, 'error' => 'add_page requires title']; $desc = isset($args['desc']) ? trim((string)$args['desc']) : ''; $content = $args['content'] ?? ''; $blocks = $args['blocks'] ?? null; $pageId = $genId('p'); $newBlocks = $ensureBlocks($content, is_array($blocks) ? $blocks : null, $genId); $pageData = [ 'title' => $title, 'desc' => $desc, 'blocks' => $newBlocks, 'created_at' => date('c'), 'updated_at' => date('c'), ]; // order + toc + pages $doc['order'][] = $pageId; $touchTocItem($doc, $pageId, $title, $desc); $setPage($doc, $pageId, $pageData); [$saved, $saveErr] = $saveDoc($doc); if (!$saved) return ['ok' => false, 'error' => $saveErr]; return [ 'ok' => true, 'op' => 'add_page', 'page' => count($doc['order']), // 新页在末尾 'id' => $pageId, 'took_ms' => (int)round((microtime(true) - $t0) * 1000), ]; } // ---------- op: set_page ---------- if ($op === 'set_page') { $pageNum = (int)($args['page'] ?? 0); if ($pageNum < 1) return ['ok' => false, 'error' => 'set_page requires page (>=1)']; $pid = $pageIdByNumber($doc, $pageNum); if ($pid === null) return ['ok' => false, 'error' => 'page out of range']; $pageData = $getPage($doc, $pid); if (!is_array($pageData)) { // 若缺失,补齐 $pageData = [ 'title' => '', 'desc' => '', 'blocks' => [], 'created_at' => date('c'), 'updated_at' => date('c'), ]; } $title = array_key_exists('title', $args) ? (is_null($args['title']) ? null : trim((string)$args['title'])) : null; $desc = array_key_exists('desc', $args) ? (is_null($args['desc']) ? null : trim((string)$args['desc'])) : null; // content/blocks:如果提供 blocks 或 content,视为整页重写 blocks $hasBlocks = array_key_exists('blocks', $args) && is_array($args['blocks']); $hasContent = array_key_exists('content', $args) && $args['content'] !== null; if ($title !== null) $pageData['title'] = $title; if ($desc !== null) $pageData['desc'] = $desc; if ($hasBlocks || $hasContent) { $content = $args['content'] ?? ''; $blocks = $hasBlocks ? $args['blocks'] : null; $pageData['blocks'] = $ensureBlocks($content, is_array($blocks) ? $blocks : null, $genId); } $pageData['updated_at'] = date('c'); $setPage($doc, $pid, $pageData); $touchTocItem($doc, $pid, $title, $desc); [$saved, $saveErr] = $saveDoc($doc); if (!$saved) return ['ok' => false, 'error' => $saveErr]; return [ 'ok' => true, 'op' => 'set_page', 'page' => $pageNum, 'id' => $pid, 'took_ms' => (int)round((microtime(true) - $t0) * 1000), ]; } // ---------- op: delete_page ---------- if ($op === 'delete_page') { $pageNum = (int)($args['page'] ?? 0); if ($pageNum < 1) return ['ok' => false, 'error' => 'delete_page requires page (>=1)']; $pid = $pageIdByNumber($doc, $pageNum); if ($pid === null) return ['ok' => false, 'error' => 'page out of range']; // 从 order 删除 $idx = $pageNum - 1; array_splice($doc['order'], $idx, 1); // toc 和 pages 删除 $removeTocItem($doc, $pid); $deletePageData($doc, $pid); [$saved, $saveErr] = $saveDoc($doc); if (!$saved) return ['ok' => false, 'error' => $saveErr]; return [ 'ok' => true, 'op' => 'delete_page', 'deleted_page' => $pageNum, 'deleted_id' => $pid, 'took_ms' => (int)round((microtime(true) - $t0) * 1000), ]; } // ---------- block ops need page ---------- $pageNum = (int)($args['page'] ?? 0); if ($pageNum < 1) return ['ok' => false, 'error' => $op . ' requires page (>=1)']; $pid = $pageIdByNumber($doc, $pageNum); if ($pid === null) return ['ok' => false, 'error' => 'page out of range']; $pageData = $getPage($doc, $pid); if (!is_array($pageData)) { return ['ok' => false, 'error' => 'page data missing']; } if (!isset($pageData['blocks']) || !is_array($pageData['blocks'])) { $pageData['blocks'] = []; } // insert_block if ($op === 'insert_block') { $text = trim((string)($args['text'] ?? '')); if ($text === '') return ['ok' => false, 'error' => 'insert_block requires text']; $after = $args['after_block_id'] ?? null; $newId = $genId('b'); $newBlock = ['id' => $newId, 'text' => $text]; if ($after === null || $after === '') { $pageData['blocks'][] = $newBlock; } else { $inserted = false; for ($i = 0; $i < count($pageData['blocks']); $i++) { if ((string)($pageData['blocks'][$i]['id'] ?? '') === (string)$after) { array_splice($pageData['blocks'], $i + 1, 0, [$newBlock]); $inserted = true; break; } } if (!$inserted) { // after 不存在则追加末尾(更容错) $pageData['blocks'][] = $newBlock; } } $pageData['updated_at'] = date('c'); $setPage($doc, $pid, $pageData); $touchTocItem($doc, $pid, null, null); [$saved, $saveErr] = $saveDoc($doc); if (!$saved) return ['ok' => false, 'error' => $saveErr]; return [ 'ok' => true, 'op' => 'insert_block', 'page' => $pageNum, 'id' => $pid, 'block_id' => $newId, 'took_ms' => (int)round((microtime(true) - $t0) * 1000), ]; } // update_block if ($op === 'update_block') { $bid = (string)($args['block_id'] ?? ''); if ($bid === '') return ['ok' => false, 'error' => 'update_block requires block_id']; $text = (string)($args['text'] ?? ''); // 允许更新为空文本,但要显式传 text if (!array_key_exists('text', $args)) return ['ok' => false, 'error' => 'update_block requires text field']; $updated = false; for ($i = 0; $i < count($pageData['blocks']); $i++) { if ((string)($pageData['blocks'][$i]['id'] ?? '') === $bid) { $pageData['blocks'][$i]['text'] = $text; $updated = true; break; } } if (!$updated) return ['ok' => false, 'error' => 'block_id not found']; $pageData['updated_at'] = date('c'); $setPage($doc, $pid, $pageData); $touchTocItem($doc, $pid, null, null); [$saved, $saveErr] = $saveDoc($doc); if (!$saved) return ['ok' => false, 'error' => $saveErr]; return [ 'ok' => true, 'op' => 'update_block', 'page' => $pageNum, 'id' => $pid, 'block_id' => $bid, 'took_ms' => (int)round((microtime(true) - $t0) * 1000), ]; } // delete_block if ($op === 'delete_block') { $bid = (string)($args['block_id'] ?? ''); if ($bid === '') return ['ok' => false, 'error' => 'delete_block requires block_id']; $before = count($pageData['blocks']); $pageData['blocks'] = array_values(array_filter($pageData['blocks'], function($b) use ($bid) { return (string)($b['id'] ?? '') !== $bid; })); if (count($pageData['blocks']) === $before) return ['ok' => false, 'error' => 'block_id not found']; // 如果删到空,留一个空 block 方便后续编辑 if (count($pageData['blocks']) === 0) { $pageData['blocks'][] = ['id' => $genId('b'), 'text' => '']; } $pageData['updated_at'] = date('c'); $setPage($doc, $pid, $pageData); $touchTocItem($doc, $pid, null, null); [$saved, $saveErr] = $saveDoc($doc); if (!$saved) return ['ok' => false, 'error' => $saveErr]; return [ 'ok' => true, 'op' => 'delete_block', 'page' => $pageNum, 'id' => $pid, 'block_id' => $bid, 'took_ms' => (int)round((microtime(true) - $t0) * 1000), ]; } return ['ok' => false, 'error' => 'unreachable']; } catch (Throwable $e) { return ['ok' => false, 'error' => 'Exception: ' . $e->getMessage()]; } finally { flock($fp, LOCK_UN); fclose($fp); } }, ];
保存