文件预览

youmind-api.ts

查看 youmind-ghost-article 技能包中的文件内容。

文件内容

toolkit/src/youmind-api.ts

/**
 * YouMind OpenAPI client — knowledge mining, search, web search, and article archiving.
 *
 * Usage (CLI):
 *   npx tsx src/youmind-api.ts search "AI 大模型" --top-k 10
 *   npx tsx src/youmind-api.ts web-search "今日AI热点" --freshness day
 *   npx tsx src/youmind-api.ts list-boards
 *   npx tsx src/youmind-api.ts list-materials <board_id>
 *   npx tsx src/youmind-api.ts list-crafts <board_id>
 *   npx tsx src/youmind-api.ts get-material <id>
 *   npx tsx src/youmind-api.ts get-craft <id>
 *   npx tsx src/youmind-api.ts save-article <board_id> --title "..." --file article.md
 *   npx tsx src/youmind-api.ts mine-topics "AI,产品设计" --board <board_id> --top-k 5
 */

import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { loadYouMindConfig, YOUMIND_CONFIG_ERROR_HINT } from './config.js';

// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------

interface YouMindConfig {
  apiKey: string;
  baseUrl: string;
}

function loadConfig(): YouMindConfig {
  const { apiKey, baseUrl } = loadYouMindConfig();
  return {
    apiKey,
    baseUrl,
  };
}

// ---------------------------------------------------------------------------
// HTTP helper
// ---------------------------------------------------------------------------

async function post<T = unknown>(
  endpoint: string,
  body: Record<string, unknown> = {},
  config?: YouMindConfig,
): Promise<T> {
  const cfg = config ?? loadConfig();
  if (!cfg.apiKey) {
    throw new Error(`YouMind API key not configured. ${YOUMIND_CONFIG_ERROR_HINT}`);
  }

  const resp = await fetch(`${cfg.baseUrl}${endpoint}`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'x-api-key': cfg.apiKey,
    },
    body: JSON.stringify(body),
    // createChat 需要等 AI 响应,给 120s;其他 API 15s 足够
    signal: AbortSignal.timeout(endpoint.includes('Chat') || endpoint.includes('Message') ? 120_000 : 15_000),
  });

  if (!resp.ok) {
    const text = await resp.text().catch(() => '');
    throw new Error(`YouMind API ${endpoint} 失败 (${resp.status}): ${text.slice(0, 300)}`);
  }

  return resp.json() as Promise<T>;
}

// ---------------------------------------------------------------------------
// Public API — Search
// ---------------------------------------------------------------------------

export interface SearchResult {
  entity_id?: string;
  entity_type?: string;
  metadata?: { title?: string; content?: string; [k: string]: unknown };
  // normalized fields (filled by search())
  id?: string;
  title?: string;
  content?: string;
  type?: string;
  score?: number;
  [key: string]: unknown;
}

export interface SearchResponse {
  results: SearchResult[];
  [key: string]: unknown;
}

export interface SearchOptions {
  query: string;
  topK?: number;
  filterTypes?: ('article' | 'note' | 'page')[];
  filterSourceIds?: string[];
  filterFields?: ('title' | 'content')[];
  filterUpdatedAt?: { from?: number; to?: number };
}

export async function search(opts: SearchOptions, config?: YouMindConfig): Promise<SearchResponse> {
  const body: Record<string, unknown> = { query: opts.query, scope: 'library' };
  if (opts.topK) body.top_k = opts.topK;
  if (opts.filterTypes) body.filter_types = opts.filterTypes;
  if (opts.filterSourceIds) body.filter_source_ids = opts.filterSourceIds;
  if (opts.filterFields) body.filter_fields = opts.filterFields;
  if (opts.filterUpdatedAt) body.filter_updated_at = opts.filterUpdatedAt;

  const raw = await post<SearchResponse>('/search', body, config);
  // Normalize: API returns entity_id/entity_type/metadata, map to flat fields
  if (raw.results) {
    for (const r of raw.results) {
      r.id = r.id ?? r.entity_id;
      r.type = r.type ?? r.entity_type;
      r.title = r.title ?? r.metadata?.title;
      r.content = r.content ?? r.metadata?.content;
    }
  }
  return raw;
}

// ---------------------------------------------------------------------------
// Public API — Web Search
// ---------------------------------------------------------------------------

export interface WebSearchResult {
  title: string;
  url: string;
  snippet: string;
  date_published?: string | null;
  [key: string]: unknown;
}

export interface WebSearchResponse {
  results: WebSearchResult[];
  formatted_context?: string | null;
  total_results?: number | null;
  [key: string]: unknown;
}

export interface WebSearchOptions {
  query: string;
  freshness?: 'day' | 'week' | 'month' | 'year';
  includeDomains?: string[];
  excludeDomains?: string[];
}

export async function webSearch(opts: WebSearchOptions, config?: YouMindConfig): Promise<WebSearchResponse> {
  const body: Record<string, unknown> = { query: opts.query };
  if (opts.freshness) body.freshness = opts.freshness;
  if (opts.includeDomains) body.include_domains = opts.includeDomains;
  if (opts.excludeDomains) body.exclude_domains = opts.excludeDomains;
  return post<WebSearchResponse>('/webSearch', body, config);
}

// ---------------------------------------------------------------------------
// Public API — Boards
// ---------------------------------------------------------------------------

export interface Board {
  id: string;
  name: string;
  type?: string;
  count?: number;
  [key: string]: unknown;
}

export async function listBoards(config?: YouMindConfig): Promise<Board[]> {
  return post<Board[]>('/listBoards', {}, config);
}

export async function getBoard(id: string, config?: YouMindConfig): Promise<Board> {
  return post<Board>('/getBoard', { id }, config);
}

// ---------------------------------------------------------------------------
// Public API — Materials
// ---------------------------------------------------------------------------

export interface Material {
  id: string;
  title?: string;
  content?: string;
  type?: string;
  board_id?: string;
  url?: string;
  created_at?: string;
  updated_at?: string;
  [key: string]: unknown;
}

export async function listMaterials(boardId: string, groupId?: string, config?: YouMindConfig): Promise<Material[]> {
  const body: Record<string, unknown> = { board_id: boardId };
  if (groupId) body.group_id = groupId;
  return post<Material[]>('/listMaterials', body, config);
}

export async function getMaterial(id: string, config?: YouMindConfig): Promise<Material> {
  return post<Material>('/getMaterial', { id }, config);
}

// ---------------------------------------------------------------------------
// Public API — Crafts (Documents)
// ---------------------------------------------------------------------------

export interface Craft {
  id: string;
  title?: string;
  content?: string;
  type?: string;
  board_id?: string;
  created_at?: string;
  updated_at?: string;
  [key: string]: unknown;
}

export async function listCrafts(boardId: string, groupId?: string, config?: YouMindConfig): Promise<Craft[]> {
  const body: Record<string, unknown> = { board_id: boardId };
  if (groupId) body.group_id = groupId;
  return post<Craft[]>('/listCrafts', body, config);
}

export async function getCraft(id: string, config?: YouMindConfig): Promise<Craft> {
  return post<Craft>('/getCraft', { id }, config);
}

// ---------------------------------------------------------------------------
// Public API — Save article to YouMind
// ---------------------------------------------------------------------------

export interface SavedDocument {
  id: string;
  title: string;
  board_id: string;
  [key: string]: unknown;
}

export async function saveArticle(
  boardId: string,
  title: string,
  markdownContent: string,
  config?: YouMindConfig,
): Promise<SavedDocument> {
  return post<SavedDocument>('/createDocumentByMarkdown', {
    board_id: boardId,
    title,
    content: markdownContent,
  }, config);
}

// ---------------------------------------------------------------------------
// Public API — Knowledge Mining (composite)
// ---------------------------------------------------------------------------

export interface MinedContent {
  source: 'search' | 'material' | 'craft';
  id: string;
  title: string;
  snippet: string;
  relevance?: number;
  updatedAt?: string;
}

export interface MineTopicsOptions {
  topics: string[];
  boardIds?: string[];
  topK?: number;
}

/**
 * 从用户的 YouMind 知识库中挖掘与选题相关的素材。
 * 组合语义搜索 + board 浏览,返回去重后的相关内容摘要。
 */
export async function mineTopics(opts: MineTopicsOptions, config?: YouMindConfig): Promise<MinedContent[]> {
  const cfg = config ?? loadConfig();
  const results: MinedContent[] = [];
  const seenIds = new Set<string>();
  const topK = opts.topK ?? 5;

  // 1. 对每个 topic 做语义搜索
  const searchPromises = opts.topics.map(topic =>
    search({ query: topic, topK, filterTypes: ['article', 'note', 'page'] }, cfg)
      .catch(e => { console.error(`搜索 "${topic}" 失败:`, e.message); return null; })
  );

  const searchResults = await Promise.all(searchPromises);
  for (const res of searchResults) {
    if (!res?.results) continue;
    for (const item of res.results) {
      const id = item.id ?? '';
      if (!id || seenIds.has(id)) continue;
      seenIds.add(id);
      results.push({
        source: 'search',
        id,
        title: item.title ?? '(无标题)',
        snippet: String(item.content ?? '').slice(0, 300),
        relevance: item.score,
        updatedAt: item.updated_at as string | undefined,
      });
    }
  }

  // 2. 浏览指定 board 的最新内容
  if (opts.boardIds?.length) {
    const boardPromises = opts.boardIds.flatMap(bid => [
      listMaterials(bid, undefined, cfg).catch(() => [] as Material[]),
      listCrafts(bid, undefined, cfg).catch(() => [] as Craft[]),
    ]);

    const boardResults = await Promise.all(boardPromises);
    for (const items of boardResults) {
      if (!Array.isArray(items)) continue;
      for (const item of items.slice(0, 20)) {
        const id = item.id ?? '';
        if (!id || seenIds.has(id)) continue;
        seenIds.add(id);
        const isCraft = 'board_id' in item && ('type' in item && (item as Craft).type === 'page');
        results.push({
          source: isCraft ? 'craft' : 'material',
          id,
          title: item.title ?? '(无标题)',
          snippet: String(item.content ?? '').slice(0, 300),
          updatedAt: item.updated_at as string | undefined,
        });
      }
    }
  }

  return results;
}

// ---------------------------------------------------------------------------
// Public API — Chat-based Image Generation
// ---------------------------------------------------------------------------

export interface ChatImageResult {
  chatId: string;
  imageUrls: string[];
  text: string;
}

/**
 * 通过 YouMind Chat API (agent 模式) AI 生图。
 * 流程: createChat(agent) → agent 自动加载 imageGenerate 工具并生图
 *       → 轮询 listMessages 等待 cdn.gooo.ai 图片 URL 出现。
 */
export async function chatGenerateImage(
  prompt: string, config?: YouMindConfig,
): Promise<ChatImageResult> {
  const cfg = config ?? loadConfig();

  // Step 1: createChat 以 agent 模式启动
  const createResp = await post<Record<string, unknown>>('/createChat', {
    message: `请加载生图工具并生成一张图片:${prompt}`,
    message_mode: 'agent',
  }, cfg);

  const chatId = (createResp.id as string) ?? '';
  if (!chatId) throw new Error('createChat 未返回 chat_id');

  // 先检查 createChat 响应是否已经包含生成的图
  const initial = extractImages(createResp);
  if (initial.urls.length) {
    return { chatId, imageUrls: initial.urls, text: '' };
  }

  // Step 2: 轮询 listMessages 等待图片生成完成(最多 120 秒)
  const maxWait = 120_000;
  const interval = 3_000;
  const start = Date.now();
  let lastToolErrors: string[] = [];

  while (Date.now() - start < maxWait) {
    await new Promise(r => setTimeout(r, interval));

    const msgResp = await post<Record<string, unknown>>('/listMessages', { chat_id: chatId }, cfg);
    const extracted = extractImages(msgResp);
    if (extracted.urls.length) {
      return { chatId, imageUrls: extracted.urls, text: '' };
    }
    lastToolErrors = extracted.toolErrors;

    // 检查 agent 是否已结束(所有 message status != pending)
    const messages = (msgResp.messages ?? []) as Record<string, unknown>[];
    const lastAst = [...messages].reverse().find(m => m.role === 'assistant');
    if (lastAst && lastAst.status === 'success') {
      // agent 已完成但没有生成图片
      break;
    }
  }

  // tool 自报错误优先(如 "No image was generated. Please change your prompt and try again.")
  if (lastToolErrors.length) {
    throw new Error(`YouMind generateImage 未生成图片: ${lastToolErrors.join('; ')}`);
  }
  throw new Error('YouMind AI 生图超时或未生成图片');
}

interface ExtractedImages {
  urls: string[];
  /** generateImage 工具自报的错误(如 "No image was generated..."),用于把真实失败原因透传给上层 */
  toolErrors: string[];
}

/**
 * 从 listMessages / createChat 响应里提取 generateImage 工具产物。
 *
 * 优先走稳定契约 `tool_result.files[].file.{original_url,compressed_url}`,
 * 而不是在响应 JSON 文本里正则扫 URL —— `toolResponse` 是给 LLM 看的文案,
 * youapi 端会按 LLM 引导反复调整其格式(如 commit 8df310601d / 1d54fe75e3)。
 * 仅当结构化路径找不到时,才回退到正则兜底,确保旧 schema 也能工作。
 */
function extractImages(resp: Record<string, unknown>): ExtractedImages {
  const urls: string[] = [];
  const toolErrors: string[] = [];
  const seen = new Set<string>();

  const messages = (resp.messages ?? []) as Record<string, unknown>[];
  for (const msg of messages) {
    if ((msg.role as string) !== 'assistant') continue;
    const blocks = (msg.blocks ?? []) as Record<string, unknown>[];
    for (const block of blocks) {
      if ((block.type as string) !== 'tool') continue;
      const toolName = block.tool_name as string | undefined;
      // 兼容 camelCase / snake_case 两种命名
      if (toolName !== 'generateImage' && toolName !== 'generate_image') continue;

      const toolResult = (block.tool_result ?? {}) as Record<string, unknown>;
      const filesRaw = (toolResult.files as unknown[]) ?? [];
      const files = filesRaw.filter((f): f is Record<string, unknown> => !!f && typeof f === 'object');
      const fileList = files.length === 0 && toolResult.file
        ? [toolResult.file as Record<string, unknown>]
        : files;

      if (fileList.length === 0) {
        // tool 显式返回 files=[],说明生成失败;把 toolResponse 当真实错误透出
        const errText = block.tool_response as string | undefined;
        if (errText) toolErrors.push(errText);
        continue;
      }

      for (const fileDto of fileList) {
        const fileMeta = (fileDto.file ?? {}) as Record<string, unknown>;
        const url = (fileMeta.original_url ?? fileMeta.compressed_url) as string | undefined;
        if (url && !seen.has(url)) { seen.add(url); urls.push(url); }
      }
    }
  }

  // 兜底:旧版 schema 或 toolResponse 仍带 URL 的情况
  if (urls.length === 0) {
    const raw = JSON.stringify(resp);
    for (const m of raw.matchAll(/https?:\/\/cdn\.gooo\.ai\/gen-images\/[a-f0-9]+\.(?:jpg|jpeg|png|webp)/gi)) {
      if (!seen.has(m[0])) { seen.add(m[0]); urls.push(m[0]); }
    }
  }

  return { urls, toolErrors };
}

// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------

async function cli() {
  const args = process.argv.slice(2);
  const command = args[0];

  if (!command || command === '--help') {
    console.log(`YouMind API CLI

Commands:
  search <query> [--top-k N] [--types article,note,page] [--board <id>]
  web-search <query> [--freshness day|week|month|year]
  list-boards
  list-materials <board_id>
  list-crafts <board_id>
  get-material <id>
  get-craft <id>
  save-article <board_id> --title "..." --file article.md
  mine-topics "topic1,topic2" [--board <id>] [--top-k N]
  generate-image "prompt description"`);
    return;
  }

  const getArg = (flag: string): string | undefined => {
    const i = args.indexOf(flag);
    return i >= 0 && i + 1 < args.length ? args[i + 1] : undefined;
  };

  const output = (data: unknown) => console.log(JSON.stringify(data, null, 2));

  switch (command) {
    case 'search': {
      const query = args[1];
      if (!query) { console.error('缺少 query 参数'); process.exit(1); }
      const topK = parseInt(getArg('--top-k') ?? '10', 10);
      const types = getArg('--types')?.split(',') as ('article' | 'note' | 'page')[] | undefined;
      const boardId = getArg('--board');
      const res = await search({
        query, topK, filterTypes: types,
        filterSourceIds: boardId ? [boardId] : undefined,
      });
      output(res);
      break;
    }

    case 'web-search': {
      const query = args[1];
      if (!query) { console.error('缺少 query 参数'); process.exit(1); }
      const freshness = getArg('--freshness') as WebSearchOptions['freshness'];
      const res = await webSearch({ query, freshness });
      output(res);
      break;
    }

    case 'list-boards': {
      output(await listBoards());
      break;
    }

    case 'list-materials': {
      const boardId = args[1];
      if (!boardId) { console.error('缺少 board_id 参数'); process.exit(1); }
      output(await listMaterials(boardId));
      break;
    }

    case 'list-crafts': {
      const boardId = args[1];
      if (!boardId) { console.error('缺少 board_id 参数'); process.exit(1); }
      output(await listCrafts(boardId));
      break;
    }

    case 'get-material': {
      const id = args[1];
      if (!id) { console.error('缺少 id 参数'); process.exit(1); }
      output(await getMaterial(id));
      break;
    }

    case 'get-craft': {
      const id = args[1];
      if (!id) { console.error('缺少 id 参数'); process.exit(1); }
      output(await getCraft(id));
      break;
    }

    case 'save-article': {
      const boardId = args[1];
      const title = getArg('--title');
      const file = getArg('--file');
      if (!boardId || !title || !file) {
        console.error('用法: save-article <board_id> --title "..." --file article.md');
        process.exit(1);
      }
      const content = readFileSync(resolve(process.cwd(), file), 'utf-8');
      output(await saveArticle(boardId, title, content));
      break;
    }

    case 'mine-topics': {
      const topicsStr = args[1];
      if (!topicsStr) { console.error('缺少 topics 参数 (逗号分隔)'); process.exit(1); }
      const topics = topicsStr.split(',').map(s => s.trim()).filter(Boolean);
      const boardId = getArg('--board');
      const topK = parseInt(getArg('--top-k') ?? '5', 10);
      const res = await mineTopics({
        topics,
        boardIds: boardId ? [boardId] : undefined,
        topK,
      });
      output(res);
      break;
    }

    case 'generate-image': {
      const prompt = args[1];
      if (!prompt) { console.error('缺少 prompt 参数'); process.exit(1); }
      const res = await chatGenerateImage(prompt);
      output(res);
      break;
    }

    default:
      console.error(`未知命令: ${command}`);
      process.exit(1);
  }
}

// Run CLI if invoked directly
const isMain = process.argv[1]?.endsWith('youmind-api.ts') ||
  process.argv[1]?.endsWith('youmind-api.js');
if (isMain) {
  cli().catch(e => { console.error(e.message); process.exit(1); });
}