Skip to main content
Glama

motion.search

Search for similar motion patterns or generate CSS/JS implementation code for web animations and transitions.

Instructions

モーションパターンを類似検索、または実装コードを生成します。action: search(デフォルト)で検索、action: generateでCSS/JS実装コードを生成します。

Input Schema

TableJSON Schema
NameRequiredDescriptionDefault
actionNoアクション: search(デフォルト)= モーション検索、generate = 実装コード生成search
queryNo検索クエリ(自然言語、1-500文字)。action: searchで使用。
samplePatternNoサンプルパターンで類似検索。action: searchで使用。
filtersNo検索フィルター。action: searchで使用。
limitNo結果制限(1-50、デフォルト: 10)。action: searchで使用。
minSimilarityNo最小類似度しきい値(0-1、デフォルト: 0.5)。action: searchで使用。
include_js_animationsNoJSアニメーションパターンを検索結果に含める(デフォルト: true)。action: searchで使用。
js_animation_filtersNoJSアニメーション検索フィルター。action: searchで使用。
include_webgl_animationsNoWebGLアニメーションパターンを検索結果に含める(デフォルト: true)。action: searchで使用。
webgl_animation_filtersNoWebGLアニメーション検索フィルター。action: searchで使用。
include_implementationNo検索結果に実装コード(@keyframes, animation, tailwindクラス)を含める(デフォルト: false)。action: searchで使用。
patternNoモーションパターン定義。action: generateで必須。
formatNo出力フォーマット(デフォルト: css)。action: generateで使用。css
optionsNo生成オプション。action: generateで使用。
profile_idNo嗜好プロファイルID(検索結果のリランキングに使用) / Preference profile ID (used for search result reranking)

Implementation Reference

  • motion.search ツールハンドラーの定義。検索 (action: 'search') とコード生成 (action: 'generate') の両方の機能を包含しています。
    export async function motionSearchHandler(input: unknown): Promise<MotionSearchOutput> {
      if (isDevelopment()) {
        logger.info("[MCP Tool] motion.search called", {
          hasInput: input !== null && input !== undefined,
        });
      }
    
      // 入力バリデーション
      let validated: MotionSearchInput;
      try {
        validated = motionSearchInputSchema.parse(input);
      } catch (error) {
        if (isDevelopment()) {
          logger.error("[MCP Tool] motion.search validation error", { error });
        }
        return {
          success: false,
          error: {
            code: MOTION_SEARCH_ERROR_CODES.VALIDATION_ERROR,
            message: error instanceof Error ? error.message : "Invalid input",
          },
        };
      }
    
      // Phase3-3: action分岐
      const action = validated.action ?? "search";
    
      if (action === "generate") {
        // コード生成処理
        return handleGenerateAction(validated);
      }
    
      // 検索処理(既存ロジック)
      return handleSearchAction(validated);
    }
    
    /**
     * action: 'search' の処理
     */
    async function handleSearchAction(validated: MotionSearchInput): Promise<MotionSearchOutput> {
      // サービスファクトリのチェック
      if (!serviceFactory) {
        if (isDevelopment()) {
          logger.error("[MCP Tool] motion.search service factory not set");
        }
        return {
          success: false,
          error: {
            code: MOTION_SEARCH_ERROR_CODES.SERVICE_UNAVAILABLE,
            message: "Motion search service is not available",
          },
        };
      }
    
      try {
        const service = serviceFactory();
    
        // 検索パラメータを構築(v0.1.0: JSアニメーション検索パラメータ追加、v0.1.0: WebGLアニメーション検索パラメータ追加、v0.1.0: include_implementation追加)
        const searchParams: MotionSearchParams = {
          query: validated.query,
          samplePattern: validated.samplePattern,
          filters: validated.filters,
          limit: validated.limit,
          minSimilarity: validated.minSimilarity,
          include_js_animations: validated.include_js_animations,
          js_animation_filters: validated.js_animation_filters,
          include_webgl_animations: validated.include_webgl_animations,
          webgl_animation_filters: validated.webgl_animation_filters,
          include_implementation: validated.include_implementation,
        };
    
        if (isDevelopment()) {
          logger.info("[MCP Tool] motion.search executing search", {
            hasQuery: !!searchParams.query,
            hasSamplePattern: !!searchParams.samplePattern,
            hasFilters: !!searchParams.filters,
            limit: searchParams.limit,
            minSimilarity: searchParams.minSimilarity,
            includeJsAnimations: searchParams.include_js_animations,
            hasJsAnimationFilters: !!searchParams.js_animation_filters,
            includeWebglAnimations: searchParams.include_webgl_animations,
            hasWebglAnimationFilters: !!searchParams.webgl_animation_filters,
            includeImplementation: searchParams.include_implementation,
            diversityThreshold: validated.diversity_threshold ?? 0.3,
            ensureCategoryDiversity: validated.ensure_category_diversity ?? true,
          });
        }
    
        // 検索実行(ハイブリッド検索優先)
        const searchResult = service.searchHybrid
          ? await service.searchHybrid(searchParams)
          : await service.search(searchParams);
    
        // v0.1.0: 多様性フィルタリングを適用
        const diversityThreshold = validated.diversity_threshold ?? 0.3;
        const ensureCategoryDiversity = validated.ensure_category_diversity ?? true;
        const diverseResults = applyDiversityFilter(
          searchResult.results,
          diversityThreshold,
          ensureCategoryDiversity,
          validated.limit
        );
    
        // v0.1.0: include_implementation が true の場合、実装コードを付与
        let results = validated.include_implementation
          ? enrichResultsWithImplementation(diverseResults)
          : diverseResults;
    
        // 嗜好プロファイルによるリランキング / Preference profile reranking
        // motion結果はpattern.idにIDがあるため、top-levelにidをマッピング
        // Motion results have ID in pattern.id, so map id to top-level
        const resultsWithId = results.map((r) => ({ ...r, id: r.pattern.id }));
        results = (await applyPreferenceReranking(
          resultsWithId,
          validated.profile_id,
          prismaClientFactory,
          "motion",
          "motion.search"
        )) as typeof results;
    
        if (isDevelopment()) {
          logger.info("[MCP Tool] motion.search completed", {
            resultsCount: results.length,
            originalCount: searchResult.results.length,
            total: searchResult.total,
            includeImplementation: validated.include_implementation,
            diversityThreshold,
            ensureCategoryDiversity,
          });
        }
    
        return {
          success: true,
          data: {
            results,
            total: searchResult.total,
            query: searchResult.query,
          },
        };
      } catch (error) {
        if (isDevelopment()) {
          logger.error("[MCP Tool] motion.search error", { error });
        }
    
        // エラータイプに基づいてエラーコードを決定
        const errorCode =
          error instanceof Error && error.message.includes("Embedding")
            ? MOTION_SEARCH_ERROR_CODES.EMBEDDING_ERROR
            : MOTION_SEARCH_ERROR_CODES.SEARCH_ERROR;
    
        return {
          success: false,
          error: {
            code: errorCode,
            message: error instanceof Error ? error.message : "Search failed",
          },
        };
      }
    }
  • motion.search ツールの定義(登録用オブジェクト)。入力スキーマや説明が含まれています。
        return {
          success: false,
          error: {
            code: errorCode,
            message: error instanceof Error ? error.message : "Search failed",
          },
        };
      }
    }
    
    /**
     * action: 'generate' の処理(Phase3-3統合)
     * v0.1.0: 重複検出機能追加
     */
    async function handleGenerateAction(validated: MotionSearchInput): Promise<MotionSearchOutput> {
      if (isDevelopment()) {
        logger.info("[MCP Tool] motion.search action: generate", {
  • motion.search ツールの入力パラメータのインターフェース。query, filters, limit などの検索関連パラメータや、action, pattern などの生成関連パラメータを定義。
    /**
     * 検索パラメータ
     */
    export interface MotionSearchParams {
      query?: string | undefined;
      samplePattern?: SamplePattern | undefined;
      filters?: MotionSearchFilters | undefined;
      limit: number;
      minSimilarity: number;
      /** JSアニメーションを検索に含めるか(v0.1.0) */
      include_js_animations?: boolean;
      /** JSアニメーション検索フィルター(v0.1.0) */
      js_animation_filters?: JSAnimationFilters | undefined;
      /** WebGLアニメーションを検索に含めるか(v0.1.0) */
      include_webgl_animations?: boolean;
      /** WebGLアニメーション検索フィルター(v0.1.0) */
      webgl_animation_filters?: WebGLAnimationFilters | undefined;
      /** 検索結果に実装コードを含めるか(v0.1.0) */
      include_implementation?: boolean;
      /** 結果の多様性しきい値(v0.1.0、0.0-1.0、デフォルト: 0.3) */
      diversity_threshold?: number;
      /** カテゴリ分散を強制するか(v0.1.0、デフォルト: true) */
      ensure_category_diversity?: boolean;
    }
    
    /**
     * JSアニメーション検索結果アイテム
     * MotionSearchResultItemにJSアニメーション情報を追加
     */
    export interface JSAnimationSearchResultItem extends Omit<MotionSearchResultItem, "pattern"> {
      /** パターン情報 */
      pattern: MotionSearchResultItem["pattern"];
      /** JSアニメーション固有情報 */
      jsAnimationInfo?: JSAnimationInfo;
    }
    
    /**
     * WebGLアニメーション検索結果アイテム
     * MotionSearchResultItemにWebGLアニメーション情報を追加
     * v0.1.0
     */
    export interface WebGLAnimationSearchResultItem extends Omit<MotionSearchResultItem, "pattern"> {
      /** パターン情報 */
      pattern: MotionSearchResultItem["pattern"];
      /** WebGLアニメーション固有情報 */
      webglAnimationInfo?: WebGLAnimationInfo;
    }
    
    /**
     * 検索結果
     */
    export interface MotionSearchResult {
      results: MotionSearchResultItem[];
      total: number;
      query?: MotionSearchQueryInfo;
    }
    
    // =====================================================
    // サービスインターフェース(DI用)
    // =====================================================
    
    /**
     * モーション検索サービスインターフェース
     */
    export interface IMotionSearchService {
      /**
       * モーションパターンを検索
       */
      search: (params: MotionSearchParams) => Promise<MotionSearchResult>;
    
      /**
       * ハイブリッド検索(ベクトル + 全文検索、RRFマージ)
       * 利用可能な場合に search() の代わりに使用される
       */
      searchHybrid?: (params: MotionSearchParams) => Promise<MotionSearchResult>;
    
      /**
       * テキストからEmbeddingを取得(オプショナル)
       */
      getEmbedding?: (text: string) => Promise<number[]>;
    }
    
    /**
     * コード生成結果インターフェース
     */
    export interface GenerationResult {
      code: string;
      metadata: ImplementationMetadata;
    }
    
    /**
     * コード生成サービスインターフェース(Phase3-3統合用)
     */
    export interface IMotionImplementationService {
      generate: (
        pattern: MotionPatternInput,
        format: ImplementationFormat,
        options: ImplementationOptions
      ) => GenerationResult | null;
    }
    
    /**
     * サービスファクトリ型
     */
    type MotionSearchServiceFactory = () => IMotionSearchService;
    type MotionImplementationServiceFactory = () => IMotionImplementationService | null;
    
    let serviceFactory: MotionSearchServiceFactory | null = null;
    let implementationServiceFactory: MotionImplementationServiceFactory | null = null;
    
    /**
     * サービスファクトリを設定
     */
    export function setMotionSearchServiceFactory(factory: MotionSearchServiceFactory): void {
      serviceFactory = factory;
    }
    
    /**
     * サービスファクトリをリセット
     */
    export function resetMotionSearchServiceFactory(): void {
      serviceFactory = null;
    }
    
    /**
     * コード生成サービスファクトリを設定(Phase3-3統合用)
     */
    export function setMotionImplementationServiceFactory(
      factory: MotionImplementationServiceFactory
    ): void {
      implementationServiceFactory = factory;
    }
    
    /**
     * コード生成サービスファクトリをリセット
     */
    export function resetMotionImplementationServiceFactory(): void {
      implementationServiceFactory = null;
    }
    
    /**
     * PrismaClientファクトリー(嗜好リランキング用DI)
     * PrismaClient factory (DI for preference reranking)
     */
    let prismaClientFactory: (() => IPrismaClient) | null = null;
    
    /**
     * PrismaClientファクトリーを設定(嗜好リランキング用)
     * Set PrismaClient factory (for preference reranking)
     */
    export function setMotionSearchPrismaClientFactory(factory: () => IPrismaClient): void {
      prismaClientFactory = factory;
    }
  • 検索エンジンのコア実装クラス。ベクトル検索、ハイブリッド検索、JSアニメーション検索との統合ロジックを担当。
    export class MotionSearchService implements IMotionSearchService {
      private embeddingService: IEmbeddingService | null = null;
      private prismaClient: IPrismaClient | null = null;
      private jsAnimationSearchService: JSAnimationSearchService | null = null;
    
      /**
       * EmbeddingServiceを取得
       */
      private getEmbeddingService(): IEmbeddingService {
        if (this.embeddingService) {
          return this.embeddingService;
        }
    
        if (embeddingServiceFactory) {
          this.embeddingService = embeddingServiceFactory();
          return this.embeddingService;
        }
    
        throw new Error("EmbeddingService not initialized");
      }
    
      /**
       * PrismaClientを取得
       */
      private getPrismaClient(): IPrismaClient {
        if (this.prismaClient) {
          return this.prismaClient;
        }
    
        if (prismaClientFactory) {
          this.prismaClient = prismaClientFactory();
          return this.prismaClient;
        }
    
        throw new Error("PrismaClient not initialized");
      }
    
      /**
       * JSAnimationSearchServiceを取得(v0.1.0)
       * @returns JSAnimationSearchService or null if not available
       */
      private getJSAnimationSearchService(): JSAnimationSearchService | null {
        if (this.jsAnimationSearchService) {
          return this.jsAnimationSearchService;
        }
    
        if (jsAnimationSearchServiceFactory) {
          this.jsAnimationSearchService = jsAnimationSearchServiceFactory();
          return this.jsAnimationSearchService;
        }
    
        return null; // Graceful degradation - JSAnimation search is optional
      }
    
      /**
       * CSSモーションのベクトル検索SQL・パラメータを構築
       *
       * search() と searchHybrid() の共通ロジックを一元化。
       * CCN削減のため抽出(TDA-Medium-2対応)。
       */
      private buildCSSVectorSearchQuery(
        clause: string,
        whereParams: unknown[],
        vectorString: string,
        similarityThreshold: number,
        limit: number
      ): { sql: string; params: unknown[] } {
        const vectorParamIndex = whereParams.length + 1;
        const similarityParamIndex = whereParams.length + 2;
        const limitParamIndex = whereParams.length + 3;
    
        const whereClauseWithSimilarity = clause
          ? `${clause} AND 1 - (me.embedding <=> $${vectorParamIndex}::vector) >= $${similarityParamIndex}`
          : `WHERE 1 - (me.embedding <=> $${vectorParamIndex}::vector) >= $${similarityParamIndex}`;
    
        const sql = `
          SELECT
            mp.id, mp.name, mp.category, mp.trigger_type,
            mp.animation, mp.properties, mp.source_url, mp.web_page_id,
            1 - (me.embedding <=> $${vectorParamIndex}::vector) as similarity
          FROM motion_patterns mp
          LEFT JOIN motion_embeddings me ON me.motion_pattern_id = mp.id
          ${whereClauseWithSimilarity}
          ORDER BY similarity DESC
          LIMIT $${limitParamIndex}
        `;
    
        return {
          sql,
          params: [...whereParams, vectorString, similarityThreshold, limit],
        };
      }
    
      /**
       * CSSモーションの全文検索SQL・パラメータを構築
       *
       * searchHybrid() の全文検索ロジックを抽出(TDA-Medium-2対応)。
       */
      private buildCSSFulltextSearchQuery(
        clause: string,
        whereParams: unknown[],
        queryText: string,
        limit: number
      ): { sql: string; params: unknown[] } {
        const ftParamIndex = whereParams.length + 1;
        const ftLimitParamIndex = whereParams.length + 2;
    
        const ftConditions = buildFulltextConditions("me.search_vector", ftParamIndex);
        const ftRank = buildFulltextRankExpression("me.search_vector", ftParamIndex);
        const ftWhereBase = clause ? `${clause} AND ${ftConditions}` : `WHERE ${ftConditions}`;
    
        const sql = `
          SELECT
            mp.id, mp.name, mp.category, mp.trigger_type,
            mp.animation, mp.properties, mp.source_url, mp.web_page_id,
            ${ftRank} as similarity
          FROM motion_patterns mp
          LEFT JOIN motion_embeddings me ON me.motion_pattern_id = mp.id
          ${ftWhereBase}
          ORDER BY similarity DESC
          LIMIT $${ftLimitParamIndex}
        `;
    
        return {
          sql,
          params: [...whereParams, queryText, limit],
        };
      }
    
      /**
       * モーションパターンを検索
       */
      async search(params: MotionSearchParams): Promise<MotionSearchResult> {
        const startTime = Date.now();
    
        // v0.1.0: include_js_animations はデフォルトtrue
        const includeJsAnimations = params.include_js_animations !== false;
    
        if (isDevelopment()) {
          logger.info("[MotionSearchService] Starting search", {
            hasQuery: !!params.query,
            hasSamplePattern: !!params.samplePattern,
            hasFilters: !!params.filters,
            limit: params.limit,
            minSimilarity: params.minSimilarity,
            includeJsAnimations,
            hasJsAnimationFilters: !!params.js_animation_filters,
          });
        }
    
        try {
          // クエリテキストを準備
          // NOTE: generateEmbedding() が内部で E5 prefix ("query: " / "passage: ") を
          // 自動付与するため、ここではプレフィックスなしのテキストを渡す。
          let queryText: string;
          if (params.query) {
            queryText = params.query;
          } else if (params.samplePattern) {
            queryText = samplePatternToText(params.samplePattern);
          } else {
            throw new Error("query or samplePattern is required");
          }
    
          // Embedding生成を試みる
          let queryEmbedding: number[] | null = null;
          try {
            const embeddingService = this.getEmbeddingService();
            queryEmbedding = await embeddingService.generateEmbedding(queryText, "query");
    
            // Embedding ベクトルの検証(Phase6-SEC-2対応)
            // 検索はEmbeddingなしでは不可能なため、検証失敗時はエラーをスロー
            const validationResult = validateEmbeddingVector(queryEmbedding);
            if (!validationResult.isValid) {
              const error = validationResult.error;
              const errorMessage =
                error?.index !== undefined
                  ? `${error.message} at index ${error.index}`
                  : (error?.message ?? "Unknown validation error");
              throw new EmbeddingValidationError(
                error?.code ?? "INVALID_VECTOR",
                errorMessage,
                error?.index
              );
            }
          } catch (embeddingError) {
            // EmbeddingValidationError は再スロー(検索不可能)
            if (embeddingError instanceof EmbeddingValidationError) {
              throw embeddingError;
            }
            if (isDevelopment()) {
              logger.warn(
                "[MotionSearchService] Embedding generation failed, falling back to text search",
                {
                  error: embeddingError instanceof Error ? embeddingError.message : "Unknown error",
                }
              );
            }
            // Embedding生成に失敗した場合は空の結果を返す
            // (テキスト検索フォールバックは将来実装)
          }
    
          // PrismaClient取得を試みる
          let prisma: IPrismaClient;
          try {
            prisma = this.getPrismaClient();
          } catch {
            // PrismaClientが利用できない場合は空の結果を返す
            if (isDevelopment()) {
              logger.warn("[MotionSearchService] PrismaClient not available, returning empty results");
            }
            return {
              results: [],
              total: 0,
              query: {
                text: params.query || samplePatternToText(params.samplePattern!),
              },
            };
          }
    
          // ベクトル検索を実行
          let results: MotionSearchResultItem[] = [];
    
          if (queryEmbedding) {
            const { clause, params: whereParams } = buildWhereClause(params.filters);
            const vectorString = `[${queryEmbedding.join(",")}]`;
            const { sql: query, params: queryParams } = this.buildCSSVectorSearchQuery(
              clause,
              whereParams,
              vectorString,
              params.minSimilarity,
              params.limit
            );
    
            try {
              const searchResults = await prisma.$queryRawUnsafe<VectorSearchResult[]>(
                query,
                ...queryParams
              );
    
              results = searchResults.map((r) => ({
                pattern: recordToMotionPattern(r),
                similarity: r.similarity,
                source: r.web_page_id
                  ? { pageId: r.web_page_id, url: r.source_url || undefined }
                  : undefined,
              }));
            } catch (dbError) {
              if (isDevelopment()) {
                logger.warn("[MotionSearchService] Vector search failed, returning empty results", {
                  error: dbError instanceof Error ? dbError.message : "Unknown error",
                });
              }
              // データベースエラー時は空の結果を返す
            }
          }
    
          // v0.1.0: JSAnimation検索を実行
          let jsAnimationResults: JSAnimationSearchResultItem[] = [];
          if (includeJsAnimations && queryEmbedding) {
            const jsSearchService = this.getJSAnimationSearchService();
            if (jsSearchService) {
              try {
                // フィルターパラメータを構築
                const jsSearchParams: JSAnimationSearchParams = {
                  queryEmbedding,
                  minSimilarity: params.minSimilarity,
                  limit: params.limit,
                  libraryType: params.js_animation_filters?.libraryType,
                  animationType: params.js_animation_filters?.animationType,
                };
                const jsSearchResult = await jsSearchService.search(jsSearchParams);
                jsAnimationResults = jsSearchResult.results;
    
                if (isDevelopment()) {
                  logger.info("[MotionSearchService] JSAnimation search completed", {
                    resultsCount: jsSearchResult.results.length,
                    total: jsSearchResult.total,
                  });
                }
              } catch (jsError) {
                if (isDevelopment()) {
                  logger.warn(
                    "[MotionSearchService] JSAnimation search failed, continuing without JS results",
                    {
                      error: jsError instanceof Error ? jsError.message : "Unknown error",
                    }
                  );
                }
                // JSAnimation検索失敗時は空の結果で継続(Graceful Degradation)
              }
            } else if (isDevelopment()) {
              logger.debug(
                "[MotionSearchService] JSAnimationSearchService not available, skipping JS search"
              );
            }
          }
    
          // v0.1.0: 結果をマージして類似度順でソート
          const mergedResults = this.mergeAndSortResults(results, jsAnimationResults, params.limit);
    
          const processingTimeMs = Date.now() - startTime;
    
          if (isDevelopment()) {
            logger.info("[MotionSearchService] Search completed", {
              cssResultsCount: results.length,
              jsResultsCount: jsAnimationResults.length,
              mergedResultsCount: mergedResults.length,
              processingTimeMs,
            });
          }
    
          const queryInfo: MotionSearchQueryInfo = {
            text: params.query || samplePatternToText(params.samplePattern!),
          };
    
          return {
            results: mergedResults,
            total: mergedResults.length,
            query: queryInfo,
          };
        } catch (error) {
          if (isDevelopment()) {
            logger.error("[MotionSearchService] Search error", {
              error: error instanceof Error ? error.message : "Unknown error",
            });
          }
          throw error;
        }
      }
    
      /**
       * テキストからEmbeddingを取得
       */
      async getEmbedding(text: string): Promise<number[]> {
        const embeddingService = this.getEmbeddingService();
        // NOTE: generateEmbedding() が内部で E5 prefix を自動付与するため、
        // プレフィックスなしのテキストを渡す。
        const embedding = await embeddingService.generateEmbedding(text, "query");
    
        // Embedding ベクトルの検証(Phase6-SEC-2対応)
        const validationResult = validateEmbeddingVector(embedding);
        if (!validationResult.isValid) {
          const error = validationResult.error;
          const errorMessage =
            error?.index !== undefined
              ? `${error.message} at index ${error.index}`
              : (error?.message ?? "Unknown validation error");
          throw new EmbeddingValidationError(
            error?.code ?? "INVALID_VECTOR",
            errorMessage,
            error?.index
          );
        }
    
        return embedding;
      }
    
      /**
       * CSSモーションパターンとJSアニメーション結果をマージしてソート(v0.1.0)
       *
       * @param cssResults CSSモーションパターン検索結果
       * @param jsResults JSアニメーション検索結果
       * @param limit 結果制限数
       * @returns マージ・ソート済みの検索結果
       */
      private mergeAndSortResults(
        cssResults: MotionSearchResultItem[],
        jsResults: JSAnimationSearchResultItem[],
        limit: number
      ): MotionSearchResultItem[] {
        // JSアニメーション結果をMotionSearchResultItem形式に変換
        const convertedJsResults: MotionSearchResultItem[] = jsResults.map((jsItem) => ({
          pattern: this.jsAnimationToMotionPattern(jsItem),
          similarity: jsItem.similarity,
          source: jsItem.webPageId ? { pageId: jsItem.webPageId } : undefined,
          jsAnimationInfo: {
            libraryType: jsItem.libraryType,
            animationType: jsItem.animationType ?? undefined,
            libraryVersion: jsItem.libraryVersion ?? undefined,
          },
        }));
    
        // 両方の結果をマージ
        const merged = [...cssResults, ...convertedJsResults];
    
        // 類似度で降順ソート
        merged.sort((a, b) => b.similarity - a.similarity);
    
        // limit制限を適用
        return merged.slice(0, limit);
      }
    
      /**
       * JSAnimationSearchResultItemをMotionPatternに変換(v0.1.0)
       */
      private jsAnimationToMotionPattern(jsItem: JSAnimationSearchResultItem): MotionPattern {
        // JSアニメーションタイプをCSSタイプにマッピング
        const typeMapping: Record<string, MotionPattern["type"]> = {
          tween: "css_animation",
          timeline: "css_animation",
          spring: "css_transition",
          physics: "css_animation",
          keyframe: "keyframes",
          morphing: "css_animation",
          path: "css_animation",
          scroll_driven: "css_animation",
          gesture: "css_transition",
        };
    
        // ライブラリタイプをカテゴリにマッピング
        const categoryMapping: Record<string, MotionPattern["category"]> = {
          gsap: "micro_interaction",
          framer_motion: "page_transition",
          anime_js: "micro_interaction",
          three_js: "scroll_trigger",
          lottie: "loading_state",
          web_animations_api: "micro_interaction",
          unknown: "unknown",
        };
    
        const animationType = jsItem.animationType ?? "tween";
        const durationMs = jsItem.durationMs ?? undefined;
        const easing = jsItem.easing ?? undefined;
    
        // keyframesからpropertiesを抽出
        const properties: Array<{
          property: string;
          from?: string | number;
          to?: string | number;
        }> = [];
    
        if (jsItem.keyframes && Array.isArray(jsItem.keyframes)) {
          const keyframes = jsItem.keyframes as Array<Record<string, unknown>>;
          if (keyframes.length >= 2) {
            const firstKf = keyframes[0];
            const lastKf = keyframes[keyframes.length - 1];
    
            // 最初と最後のキーフレームからプロパティを抽出
            const allKeys = new Set([...Object.keys(firstKf ?? {}), ...Object.keys(lastKf ?? {})]);
    
            for (const key of allKeys) {
              if (["offset", "easing", "composite", "computedOffset"].includes(key)) {
                continue;
              }
              const fromValue = firstKf?.[key];
              const toValue = lastKf?.[key];
              const propEntry: { property: string; from?: string | number; to?: string | number } = {
                property: key,
              };
              if (typeof fromValue === "string" || typeof fromValue === "number") {
                propEntry.from = fromValue;
              }
              if (typeof toValue === "string" || typeof toValue === "number") {
                propEntry.to = toValue;
              }
              properties.push(propEntry);
            }
          }
        }
    
        // propertiesフィールドからも抽出
        if (jsItem.properties && Array.isArray(jsItem.properties)) {
          for (const prop of jsItem.properties as Array<Record<string, unknown>>) {
            if (typeof prop === "object" && prop !== null && "property" in prop) {
              const propEntry: { property: string; from?: string | number; to?: string | number } = {
                property: String(prop.property),
              };
              const fromValue = prop.from;
              const toValue = prop.to;
              if (typeof fromValue === "string" || typeof fromValue === "number") {
                propEntry.from = fromValue;
              }
              if (typeof toValue === "string" || typeof toValue === "number") {
                propEntry.to = toValue;
              }
              properties.push(propEntry);
            }
          }
        }
    
        // selectorフィールドを設定(v0.1.0)
        // 優先順位: targetSelector > nameから生成
        const selector = jsItem.targetSelector || generateSelectorFromName(jsItem.name);
    
        return {
          id: jsItem.id,
          type: typeMapping[animationType] || "css_animation",
          category: categoryMapping[jsItem.libraryType] || "unknown",
          name: jsItem.name,
          selector, // JSアニメーションのターゲットセレクタ
          trigger: "load", // JSアニメーションはデフォルトでload
          animation: {
            duration: durationMs,
            easing: easing ? { type: mapEasingType(easing) } : undefined,
          },
          properties: properties.length > 0 ? properties : [],
        };
      }
    
      /**
       * ハイブリッド検索(ベクトル検索 + 全文検索 → RRF マージ)
       *
       * 既存の search() と同じインターフェースで、内部的に全文検索を追加し
       * Reciprocal Rank Fusion で結果をマージする。
       * 全文検索が失敗した場合はベクトル検索のみにフォールバック。
       */
      async searchHybrid(params: MotionSearchParams): Promise<MotionSearchResult> {
        const startTime = Date.now();
        const includeJsAnimations = params.include_js_animations !== false;
    
        if (isDevelopment()) {
          logger.info("[MotionSearchService] Starting hybrid search", {
            hasQuery: !!params.query,
            hasSamplePattern: !!params.samplePattern,
          });
        }
    
        try {
          // クエリテキストを準備
          // NOTE: generateEmbedding() が内部で E5 prefix を自動付与するため、
          // プレフィックスなしのテキストを渡す。
          let queryText: string;
          if (params.query) {
            queryText = params.query;
          } else if (params.samplePattern) {
            queryText = samplePatternToText(params.samplePattern);
          } else {
            throw new Error("query or samplePattern is required");
          }
    
          // Embedding 生成
          let queryEmbedding: number[] | null = null;
          try {
            const embeddingService = this.getEmbeddingService();
            queryEmbedding = await embeddingService.generateEmbedding(queryText, "query");
    
            const validationResult = validateEmbeddingVector(queryEmbedding);
            if (!validationResult.isValid) {
              const error = validationResult.error;
              const errorMessage =
                error?.index !== undefined
                  ? `${error.message} at index ${error.index}`
                  : (error?.message ?? "Unknown validation error");
              throw new EmbeddingValidationError(
                error?.code ?? "INVALID_VECTOR",
                errorMessage,
                error?.index
              );
            }
          } catch (embeddingError) {
            if (embeddingError instanceof EmbeddingValidationError) {
              throw embeddingError;
            }
            if (isDevelopment()) {
              logger.warn("[MotionSearchService] Embedding generation failed in hybrid search", {
                error: embeddingError instanceof Error ? embeddingError.message : "Unknown error",
              });
            }
          }
    
          let prisma: IPrismaClient;
          try {
            prisma = this.getPrismaClient();
          } catch {
            if (isDevelopment()) {
              logger.warn("[MotionSearchService] PrismaClient not available");
            }
            return {
              results: [],
              total: 0,
              query: { text: queryText },
            };
          }
    
          // CSS モーション検索: ハイブリッド(ベクトル + 全文)
          let results: MotionSearchResultItem[] = [];
    
          if (queryEmbedding) {
            const { clause, params: whereParams } = buildWhereClause(params.filters);
            const vectorString = `[${queryEmbedding.join(",")}]`;
            const fetchLimit = Math.min(params.limit * 3, 150);
    
            // ベクトル検索関数(共通メソッドで SQL 構築)
            const vectorSearchFn = async (): Promise<RankedItem[]> => {
              const { sql, params: queryParams } = this.buildCSSVectorSearchQuery(
                clause,
                whereParams,
                vectorString,
                params.minSimilarity,
                fetchLimit
              );
              const rows = await prisma.$queryRawUnsafe<VectorSearchResult[]>(sql, ...queryParams);
              return toRankedItems(rows);
            };
    
            // 全文検索関数(共通メソッドで SQL 構築)
            const fulltextSearchFn = async (): Promise<RankedItem[]> => {
              try {
                const { sql, params: queryParams } = this.buildCSSFulltextSearchQuery(
                  clause,
                  whereParams,
                  queryText,
                  fetchLimit
                );
                const rows = await prisma.$queryRawUnsafe<VectorSearchResult[]>(sql, ...queryParams);
                return toRankedItems(rows);
              } catch (ftError) {
                if (isDevelopment()) {
                  logger.warn("[MotionSearchService] Full-text search failed, using vector only", {
                    error: ftError instanceof Error ? ftError.message : "Unknown error",
                  });
                }
                return [];
              }
            };
    
            try {
              const hybridResults = await executeHybridSearch(vectorSearchFn, fulltextSearchFn);
    
              results = hybridResults.slice(0, params.limit).map((hr) => {
                const data = hr.data as unknown as VectorSearchResult;
                data.id = hr.id; // mergeWithRRFがdataからidを除去するため復元 / restore id stripped by mergeWithRRF
                return {
                  pattern: recordToMotionPattern(data),
                  similarity: hr.similarity,
                  source: data.web_page_id
                    ? { pageId: data.web_page_id, url: data.source_url || undefined }
                    : undefined,
                };
              });
            } catch (dbError) {
              if (isDevelopment()) {
                logger.warn("[MotionSearchService] Hybrid search failed, returning empty results", {
                  error: dbError instanceof Error ? dbError.message : "Unknown error",
                });
              }
            }
          }
    
          // JSAnimation 検索: ハイブリッドモードで実行(tsvector search_vector 使用)
          let jsAnimationResults: JSAnimationSearchResultItem[] = [];
          if (includeJsAnimations && queryEmbedding) {
            const jsSearchService = this.getJSAnimationSearchService();
            if (jsSearchService) {
              try {
                const jsSearchParams: JSAnimationSearchParams = {
                  queryEmbedding,
                  queryText: queryText, // searchHybrid で全文検索に使用
                  minSimilarity: params.minSimilarity,
                  limit: params.limit,
                  libraryType: params.js_animation_filters?.libraryType,
                  animationType: params.js_animation_filters?.animationType,
                };
                const jsSearchResult = await jsSearchService.searchHybrid(jsSearchParams);
                jsAnimationResults = jsSearchResult.results;
              } catch (jsError) {
                if (isDevelopment()) {
                  logger.warn("[MotionSearchService] JSAnimation hybrid search failed", {
                    error: jsError instanceof Error ? jsError.message : "Unknown error",
                  });
                }
              }
            }
          }
    
          const mergedResults = this.mergeAndSortResults(results, jsAnimationResults, params.limit);
          const processingTimeMs = Date.now() - startTime;
    
          if (isDevelopment()) {
            logger.info("[MotionSearchService] Hybrid search completed", {
              cssResultsCount: results.length,
              jsResultsCount: jsAnimationResults.length,
              mergedResultsCount: mergedResults.length,
              processingTimeMs,
            });
          }
    
          return {
            results: mergedResults,
            total: mergedResults.length,
            query: { text: queryText },
          };
        } catch (error) {
          if (isDevelopment()) {
            logger.error("[MotionSearchService] Hybrid search error, falling back to standard search", {
              error: error instanceof Error ? error.message : "Unknown error",
            });
          }
          // フォールバック: 標準検索
          return this.search(params);
        }
      }
    }
    
    // =====================================================
    // シングルトンインスタンス
    // =====================================================
    
    let motionSearchServiceInstance: MotionSearchService | null = null;
    
    /**
     * MotionSearchServiceインスタンスを取得
     */
    export function getMotionSearchService(): MotionSearchService {
      if (!motionSearchServiceInstance) {
        motionSearchServiceInstance = new MotionSearchService();
      }
      return motionSearchServiceInstance;
    }
    
    /**
     * MotionSearchServiceインスタンスをリセット
     */
    export function resetMotionSearchService(): void {
      motionSearchServiceInstance = null;
    }
    
    /**
     * MotionSearchServiceファクトリを作成
     */
    export function createMotionSearchServiceFactory(): () => IMotionSearchService {
      return () => getMotionSearchService();
    }
    
    export default MotionSearchService;

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/TKMD/reftrix-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server