feat: tag/cat mapping tools
This commit is contained in:
@@ -38,12 +38,14 @@ export interface AnalyzedCategory {
|
||||
name: string;
|
||||
slug: string;
|
||||
existsInProject: boolean;
|
||||
mappedTo?: string; // When set, indicates this item should be mapped to the given name on import
|
||||
}
|
||||
|
||||
export interface AnalyzedTag {
|
||||
name: string;
|
||||
slug: string;
|
||||
existsInProject: boolean;
|
||||
mappedTo?: string; // When set, indicates this item should be mapped to the given name on import
|
||||
}
|
||||
|
||||
export interface ImportAnalysisReport {
|
||||
|
||||
@@ -1147,6 +1147,149 @@ export class OpenCodeManager {
|
||||
return errorMsg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze taxonomy items (tags and categories) and suggest mappings
|
||||
* This is a one-shot request without conversation context
|
||||
*/
|
||||
async analyzeTaxonomy(
|
||||
categories: Array<{ name: string; slug: string; existsInProject: boolean }>,
|
||||
tags: Array<{ name: string; slug: string; existsInProject: boolean }>,
|
||||
modelId: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
categoryMappings?: Record<string, string>;
|
||||
tagMappings?: Record<string, string>;
|
||||
error?: string;
|
||||
}> {
|
||||
if (!this.apiKey) {
|
||||
return { success: false, error: 'API key not set' };
|
||||
}
|
||||
|
||||
const provider = this.detectProvider(modelId);
|
||||
|
||||
// Build the prompt for taxonomy analysis
|
||||
const existingCategories = categories.filter(c => c.existsInProject).map(c => c.name);
|
||||
const newCategories = categories.filter(c => !c.existsInProject).map(c => c.name);
|
||||
const existingTags = tags.filter(t => t.existsInProject).map(t => t.name);
|
||||
const newTags = tags.filter(t => !t.existsInProject).map(t => t.name);
|
||||
|
||||
const systemPrompt = `You are an expert at analyzing and consolidating taxonomy terms (tags and categories) for a blog import system.
|
||||
|
||||
Your task is to analyze imported categories and tags and suggest mappings to reduce duplicates and inconsistencies.
|
||||
|
||||
RULES:
|
||||
1. Only suggest mappings for items that are genuinely similar or duplicates
|
||||
2. Consider variations like: singular/plural, different casing, synonyms, abbreviations, hyphenation
|
||||
3. Map to existing items when there's a clear match
|
||||
4. Map multiple similar new items to a single canonical form
|
||||
5. Only suggest mappings where it makes sense - not every item needs a mapping
|
||||
|
||||
RESPONSE FORMAT:
|
||||
You MUST respond with valid JSON only, no other text. Use this exact structure:
|
||||
{
|
||||
"categoryMappings": { "Source Category": "Target Category", ... },
|
||||
"tagMappings": { "Source Tag": "Target Tag", ... }
|
||||
}
|
||||
|
||||
If there are no sensible mappings to suggest, return empty objects.`;
|
||||
|
||||
const userPrompt = `Analyze these taxonomy items from a WordPress import and suggest mappings:
|
||||
|
||||
EXISTING CATEGORIES IN PROJECT:
|
||||
${existingCategories.length > 0 ? existingCategories.join(', ') : '(none)'}
|
||||
|
||||
NEW CATEGORIES FROM IMPORT:
|
||||
${newCategories.length > 0 ? newCategories.join(', ') : '(none)'}
|
||||
|
||||
EXISTING TAGS IN PROJECT:
|
||||
${existingTags.length > 0 ? existingTags.join(', ') : '(none)'}
|
||||
|
||||
NEW TAGS FROM IMPORT:
|
||||
${newTags.length > 0 ? newTags.join(', ') : '(none)'}
|
||||
|
||||
Suggest mappings to consolidate similar items. Response must be valid JSON only.`;
|
||||
|
||||
try {
|
||||
let responseText = '';
|
||||
|
||||
if (provider === 'anthropic') {
|
||||
const body = {
|
||||
model: modelId,
|
||||
max_tokens: 4096,
|
||||
system: systemPrompt,
|
||||
messages: [{ role: 'user', content: userPrompt }],
|
||||
};
|
||||
|
||||
const response = await this.httpRequest(ZEN_ANTHROPIC_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': this.apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (response.statusCode !== 200) {
|
||||
console.error('[OpenCodeManager] Taxonomy analysis failed:', response.body);
|
||||
return { success: false, error: `API request failed: ${response.statusCode}` };
|
||||
}
|
||||
|
||||
const data = JSON.parse(response.body);
|
||||
// Extract text from Anthropic response
|
||||
for (const block of data.content || []) {
|
||||
if (block.type === 'text') {
|
||||
responseText += block.text;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// OpenAI-compatible
|
||||
const body = {
|
||||
model: modelId,
|
||||
max_tokens: 4096,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userPrompt },
|
||||
],
|
||||
};
|
||||
|
||||
const response = await this.httpRequest(ZEN_OPENAI_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (response.statusCode !== 200) {
|
||||
console.error('[OpenCodeManager] Taxonomy analysis failed:', response.body);
|
||||
return { success: false, error: `API request failed: ${response.statusCode}` };
|
||||
}
|
||||
|
||||
const data = JSON.parse(response.body);
|
||||
responseText = data.choices?.[0]?.message?.content || '';
|
||||
}
|
||||
|
||||
// Parse the JSON response
|
||||
const jsonMatch = responseText.match(/\{[\s\S]*\}/);
|
||||
if (!jsonMatch) {
|
||||
console.error('[OpenCodeManager] No JSON found in response:', responseText);
|
||||
return { success: false, error: 'Invalid response format from AI' };
|
||||
}
|
||||
|
||||
const result = JSON.parse(jsonMatch[0]);
|
||||
return {
|
||||
success: true,
|
||||
categoryMappings: result.categoryMappings || {},
|
||||
tagMappings: result.tagMappings || {},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[OpenCodeManager] Error analyzing taxonomy:', error);
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
private httpRequest(
|
||||
urlStr: string,
|
||||
options: {
|
||||
|
||||
@@ -317,6 +317,19 @@ export function registerChatHandlers(): void {
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
// ============ Taxonomy Analysis ============
|
||||
|
||||
// Analyze taxonomy items (tags/categories) and suggest mappings
|
||||
ipcMain.handle('chat:analyzeTaxonomy', async (_, categories: Array<{ name: string; slug: string; existsInProject: boolean }>, tags: Array<{ name: string; slug: string; existsInProject: boolean }>, modelId: string) => {
|
||||
try {
|
||||
const manager = getOpenCodeManager();
|
||||
return await manager.analyzeTaxonomy(categories, tags, modelId);
|
||||
} catch (error) {
|
||||
console.error('[Chat IPC] Error analyzing taxonomy:', error);
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -199,6 +199,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
clearMessages: (conversationId: string) => ipcRenderer.invoke('chat:clearMessages', conversationId),
|
||||
setConversationModel: (conversationId: string, modelId: string) => ipcRenderer.invoke('chat:setConversationModel', conversationId, modelId),
|
||||
|
||||
// Taxonomy Analysis
|
||||
analyzeTaxonomy: (categories: Array<{ name: string; slug: string; existsInProject: boolean }>, tags: Array<{ name: string; slug: string; existsInProject: boolean }>, modelId: string) => ipcRenderer.invoke('chat:analyzeTaxonomy', categories, tags, modelId),
|
||||
|
||||
// Event listeners for streaming/progress
|
||||
onStreamDelta: (callback: (data: { conversationId: string; delta: string }) => void) => {
|
||||
const subscription = (_event: Electron.IpcRendererEvent, data: { conversationId: string; delta: string }) => callback(data);
|
||||
|
||||
@@ -382,6 +382,213 @@
|
||||
color: #75beff;
|
||||
}
|
||||
|
||||
/* Mapped taxonomy items - green background */
|
||||
.import-taxonomy-pill.mapped {
|
||||
background: rgba(115, 201, 145, 0.25);
|
||||
color: #73c991;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.import-taxonomy-pill .pill-arrow {
|
||||
opacity: 0.6;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.import-taxonomy-pill .pill-mapped-to {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.import-taxonomy-pill .pill-clear-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
opacity: 0.5;
|
||||
cursor: pointer;
|
||||
padding: 0 2px;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.import-taxonomy-pill .pill-clear-btn:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Editable taxonomy pills */
|
||||
.import-taxonomy-pill:not(.editing):not(.exists):not(.mapped) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.import-taxonomy-pill:not(.editing):hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.import-taxonomy-pill.editing {
|
||||
background: var(--vscode-input-background);
|
||||
padding: 0 4px 0 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.import-taxonomy-pill.editing .pill-name {
|
||||
color: var(--vscode-foreground);
|
||||
}
|
||||
|
||||
.import-taxonomy-pill .pill-edit-input {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #73c991;
|
||||
font-size: 11px;
|
||||
padding: 2px 4px;
|
||||
width: 100px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.import-taxonomy-pill .pill-edit-input::placeholder {
|
||||
color: var(--vscode-input-placeholderForeground);
|
||||
}
|
||||
|
||||
/* Pill edit container for autocomplete */
|
||||
.pill-edit-container {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.pill-suggestions-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
margin-top: 4px;
|
||||
min-width: 150px;
|
||||
max-width: 250px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
background: var(--vscode-dropdown-background, var(--vscode-sideBar-background));
|
||||
border: 1px solid var(--vscode-dropdown-border, var(--vscode-panel-border));
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.pill-suggestion-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
font-size: 11px;
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--vscode-foreground);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.pill-suggestion-item:hover,
|
||||
.pill-suggestion-item.selected {
|
||||
background: var(--vscode-list-hoverBackground);
|
||||
}
|
||||
|
||||
.pill-suggestion-item.selected {
|
||||
background: var(--vscode-list-activeSelectionBackground);
|
||||
color: var(--vscode-list-activeSelectionForeground);
|
||||
}
|
||||
|
||||
/* Taxonomy analyze row */
|
||||
.taxonomy-analyze-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--vscode-panel-border, rgba(255,255,255,0.06));
|
||||
}
|
||||
|
||||
.taxonomy-analyze-dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.taxonomy-analyze-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
background: var(--vscode-button-background);
|
||||
color: var(--vscode-button-foreground);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.taxonomy-analyze-btn:hover:not(:disabled) {
|
||||
background: var(--vscode-button-hoverBackground);
|
||||
}
|
||||
|
||||
.taxonomy-analyze-btn:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.taxonomy-analyze-btn .import-spinner.small {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-width: 1.5px;
|
||||
}
|
||||
|
||||
.taxonomy-model-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
margin-top: 4px;
|
||||
background: var(--vscode-dropdown-background, var(--vscode-sideBar-background));
|
||||
border: 1px solid var(--vscode-dropdown-border, var(--vscode-panel-border));
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
z-index: 100;
|
||||
min-width: 180px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.taxonomy-model-option {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--vscode-foreground);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.taxonomy-model-option:hover {
|
||||
background: var(--vscode-list-hoverBackground);
|
||||
}
|
||||
|
||||
.taxonomy-analyze-hint {
|
||||
font-size: 11px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.taxonomy-mapped-count {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
background: rgba(115, 201, 145, 0.2);
|
||||
color: #73c991;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.import-empty-state {
|
||||
display: flex;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import type { ChatModel } from '../../types/electron';
|
||||
import './ImportAnalysisView.css';
|
||||
|
||||
interface AnalysisReport {
|
||||
@@ -50,6 +51,7 @@ interface TaxonomyItem {
|
||||
name: string;
|
||||
slug: string;
|
||||
existsInProject: boolean;
|
||||
mappedTo?: string; // When set, indicates this item should be mapped to the given name on import
|
||||
}
|
||||
|
||||
interface ImportAnalysisViewProps {
|
||||
@@ -66,6 +68,62 @@ export const ImportAnalysisView: React.FC<ImportAnalysisViewProps> = ({ definiti
|
||||
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({});
|
||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Save the current report to the definition
|
||||
const persistReport = useCallback(async (updatedReport: AnalysisReport) => {
|
||||
await window.electronAPI?.importDefinitions.update(definitionId, {
|
||||
lastAnalysisResult: JSON.stringify(updatedReport),
|
||||
});
|
||||
}, [definitionId]);
|
||||
|
||||
// Handler for updating taxonomy mappings
|
||||
const handleTaxonomyMappingsUpdated = useCallback(async (
|
||||
categoryMappings: Record<string, string>,
|
||||
tagMappings: Record<string, string>
|
||||
) => {
|
||||
if (!report) return;
|
||||
|
||||
const updatedReport: AnalysisReport = {
|
||||
...report,
|
||||
categories: report.categories.map(cat => ({
|
||||
...cat,
|
||||
mappedTo: categoryMappings[cat.name] || cat.mappedTo,
|
||||
})),
|
||||
tags: report.tags.map(tag => ({
|
||||
...tag,
|
||||
mappedTo: tagMappings[tag.name] || tag.mappedTo,
|
||||
})),
|
||||
};
|
||||
|
||||
setReport(updatedReport);
|
||||
await persistReport(updatedReport);
|
||||
}, [report, persistReport]);
|
||||
|
||||
// Handler for updating a single item mapping
|
||||
const handleSingleMappingUpdated = useCallback(async (
|
||||
type: 'category' | 'tag',
|
||||
itemName: string,
|
||||
mappedTo: string | undefined
|
||||
) => {
|
||||
if (!report) return;
|
||||
|
||||
const updatedReport: AnalysisReport = {
|
||||
...report,
|
||||
categories: type === 'category'
|
||||
? report.categories.map(cat =>
|
||||
cat.name === itemName ? { ...cat, mappedTo } : cat
|
||||
)
|
||||
: report.categories,
|
||||
tags: type === 'tag'
|
||||
? report.tags.map(tag =>
|
||||
tag.name === itemName ? { ...tag, mappedTo } : tag
|
||||
)
|
||||
: report.tags,
|
||||
};
|
||||
|
||||
setReport(updatedReport);
|
||||
await persistReport(updatedReport);
|
||||
}, [report, persistReport]);
|
||||
|
||||
// Load definition on mount
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
@@ -247,6 +305,8 @@ export const ImportAnalysisView: React.FC<ImportAnalysisViewProps> = ({ definiti
|
||||
tags={report.tags}
|
||||
expanded={expandedSections['taxonomy'] ?? false}
|
||||
onToggle={() => toggleSection('taxonomy')}
|
||||
onMappingsAnalyzed={handleTaxonomyMappingsUpdated}
|
||||
onMappingUpdated={handleSingleMappingUpdated}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@@ -453,43 +513,343 @@ const TaxonomySection: React.FC<{
|
||||
tags: TaxonomyItem[];
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
}> = ({ categories, tags, expanded, onToggle }) => (
|
||||
<div className="import-detail-section">
|
||||
<h3 onClick={onToggle}>
|
||||
<span className={`toggle-icon ${expanded ? 'open' : ''}`}>▶</span>
|
||||
Categories & Tags
|
||||
</h3>
|
||||
{expanded && (
|
||||
<>
|
||||
{categories.length > 0 && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5, color: 'var(--vscode-descriptionForeground)', marginBottom: 6 }}>
|
||||
Categories
|
||||
onMappingsAnalyzed: (categoryMappings: Record<string, string>, tagMappings: Record<string, string>) => void;
|
||||
onMappingUpdated: (type: 'category' | 'tag', itemName: string, mappedTo: string | undefined) => void;
|
||||
}> = ({ categories, tags, expanded, onToggle, onMappingsAnalyzed, onMappingUpdated }) => {
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||
const [showModelSelector, setShowModelSelector] = useState(false);
|
||||
const [availableModels, setAvailableModels] = useState<ChatModel[]>([]);
|
||||
const [editingItem, setEditingItem] = useState<{ type: 'category' | 'tag'; name: string } | null>(null);
|
||||
const [editValue, setEditValue] = useState('');
|
||||
const [existingTags, setExistingTags] = useState<string[]>([]);
|
||||
const [existingCategories, setExistingCategories] = useState<string[]>([]);
|
||||
const modelSelectorRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Load available models and existing taxonomy on mount
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
const [modelsResult, tagsResult, categoriesResult] = await Promise.all([
|
||||
window.electronAPI?.chat.getAvailableModels(),
|
||||
window.electronAPI?.tags.getAll(),
|
||||
window.electronAPI?.meta.getCategories(),
|
||||
]);
|
||||
if (modelsResult?.models) {
|
||||
setAvailableModels(modelsResult.models);
|
||||
}
|
||||
if (tagsResult) {
|
||||
setExistingTags(tagsResult.map(t => t.name));
|
||||
}
|
||||
if (categoriesResult) {
|
||||
setExistingCategories(categoriesResult);
|
||||
}
|
||||
};
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
// Close model selector when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (modelSelectorRef.current && !modelSelectorRef.current.contains(e.target as Node)) {
|
||||
setShowModelSelector(false);
|
||||
}
|
||||
};
|
||||
if (showModelSelector) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
}, [showModelSelector]);
|
||||
|
||||
const handleAnalyze = async (modelId: string) => {
|
||||
setShowModelSelector(false);
|
||||
setIsAnalyzing(true);
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI?.chat.analyzeTaxonomy(categories, tags, modelId);
|
||||
if (result?.success) {
|
||||
onMappingsAnalyzed(result.categoryMappings || {}, result.tagMappings || {});
|
||||
} else {
|
||||
console.error('Taxonomy analysis failed:', result?.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Taxonomy analysis error:', error);
|
||||
} finally {
|
||||
setIsAnalyzing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartEdit = (type: 'category' | 'tag', item: TaxonomyItem) => {
|
||||
setEditingItem({ type, name: item.name });
|
||||
setEditValue(item.mappedTo || '');
|
||||
};
|
||||
|
||||
const handleSaveEdit = (directValue?: string) => {
|
||||
if (editingItem) {
|
||||
const valueToSave = directValue !== undefined ? directValue : editValue;
|
||||
onMappingUpdated(editingItem.type, editingItem.name, valueToSave.trim() || undefined);
|
||||
setEditingItem(null);
|
||||
setEditValue('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearMapping = (type: 'category' | 'tag', name: string) => {
|
||||
onMappingUpdated(type, name, undefined);
|
||||
};
|
||||
|
||||
// Build suggestions list: existing project items + import items (deduplicated)
|
||||
const categorySuggestions = [...new Set([...existingCategories, ...categories.map(c => c.name)])].sort();
|
||||
const tagSuggestions = [...new Set([...existingTags, ...tags.map(t => t.name)])].sort();
|
||||
|
||||
const mappedCategoriesCount = categories.filter(c => c.mappedTo).length;
|
||||
const mappedTagsCount = tags.filter(t => t.mappedTo).length;
|
||||
|
||||
return (
|
||||
<div className="import-detail-section">
|
||||
<h3 onClick={onToggle}>
|
||||
<span className={`toggle-icon ${expanded ? 'open' : ''}`}>▶</span>
|
||||
Categories & Tags
|
||||
{(mappedCategoriesCount > 0 || mappedTagsCount > 0) && (
|
||||
<span className="taxonomy-mapped-count">
|
||||
{mappedCategoriesCount + mappedTagsCount} mapped
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
{expanded && (
|
||||
<>
|
||||
<div className="taxonomy-analyze-row">
|
||||
<div className="taxonomy-analyze-dropdown" ref={modelSelectorRef}>
|
||||
<button
|
||||
className="taxonomy-analyze-btn"
|
||||
onClick={() => setShowModelSelector(!showModelSelector)}
|
||||
disabled={isAnalyzing}
|
||||
>
|
||||
{isAnalyzing ? (
|
||||
<>
|
||||
<span className="import-spinner small" />
|
||||
Analyzing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
|
||||
</svg>
|
||||
Analyze with...
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" style={{ marginLeft: 4 }}>
|
||||
<path d="M7 10l5 5 5-5z"/>
|
||||
</svg>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{showModelSelector && (
|
||||
<div className="taxonomy-model-dropdown">
|
||||
{availableModels.map(model => (
|
||||
<button
|
||||
key={model.id}
|
||||
className="taxonomy-model-option"
|
||||
onClick={() => handleAnalyze(model.id)}
|
||||
>
|
||||
{model.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="import-taxonomy-list">
|
||||
{categories.map((cat, idx) => (
|
||||
<span key={idx} className={`import-taxonomy-pill ${cat.existsInProject ? 'exists' : 'new-tax'}`}>
|
||||
{cat.name}
|
||||
</span>
|
||||
<span className="taxonomy-analyze-hint">
|
||||
AI will suggest mappings to consolidate similar tags and categories
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{categories.length > 0 && (
|
||||
<div style={{ marginBottom: 12, marginTop: 16 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5, color: 'var(--vscode-descriptionForeground)', marginBottom: 6 }}>
|
||||
Categories
|
||||
</div>
|
||||
<div className="import-taxonomy-list">
|
||||
{categories.map((cat, idx) => (
|
||||
<TaxonomyPill
|
||||
key={idx}
|
||||
item={cat}
|
||||
type="category"
|
||||
isEditing={editingItem?.type === 'category' && editingItem?.name === cat.name}
|
||||
editValue={editValue}
|
||||
suggestions={categorySuggestions}
|
||||
onEditValueChange={setEditValue}
|
||||
onStartEdit={() => handleStartEdit('category', cat)}
|
||||
onSaveEdit={handleSaveEdit}
|
||||
onCancelEdit={() => setEditingItem(null)}
|
||||
onClearMapping={() => handleClearMapping('category', cat.name)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{tags.length > 0 && (
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5, color: 'var(--vscode-descriptionForeground)', marginBottom: 6 }}>
|
||||
Tags
|
||||
</div>
|
||||
<div className="import-taxonomy-list">
|
||||
{tags.map((tag, idx) => (
|
||||
<TaxonomyPill
|
||||
key={idx}
|
||||
item={tag}
|
||||
type="tag"
|
||||
isEditing={editingItem?.type === 'tag' && editingItem?.name === tag.name}
|
||||
editValue={editValue}
|
||||
suggestions={tagSuggestions}
|
||||
onEditValueChange={setEditValue}
|
||||
onStartEdit={() => handleStartEdit('tag', tag)}
|
||||
onSaveEdit={handleSaveEdit}
|
||||
onCancelEdit={() => setEditingItem(null)}
|
||||
onClearMapping={() => handleClearMapping('tag', tag.name)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TaxonomyPill: React.FC<{
|
||||
item: TaxonomyItem;
|
||||
type: 'category' | 'tag';
|
||||
isEditing: boolean;
|
||||
editValue: string;
|
||||
suggestions: string[];
|
||||
onEditValueChange: (value: string) => void;
|
||||
onStartEdit: () => void;
|
||||
onSaveEdit: (directValue?: string) => void;
|
||||
onCancelEdit: () => void;
|
||||
onClearMapping: () => void;
|
||||
}> = ({ item, type: _type, isEditing, editValue, suggestions, onEditValueChange, onStartEdit, onSaveEdit, onCancelEdit, onClearMapping }) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
|
||||
// Filter suggestions based on input, exclude current item name
|
||||
const filteredSuggestions = suggestions.filter(s =>
|
||||
s.toLowerCase() !== item.name.toLowerCase() &&
|
||||
s.toLowerCase().includes(editValue.toLowerCase())
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
setShowDropdown(true);
|
||||
setSelectedIndex(-1);
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
// Reset selection when filtered results change
|
||||
useEffect(() => {
|
||||
setSelectedIndex(-1);
|
||||
}, [editValue]);
|
||||
|
||||
const handleSelectSuggestion = (suggestion: string) => {
|
||||
setShowDropdown(false);
|
||||
onSaveEdit(suggestion);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev =>
|
||||
prev < filteredSuggestions.length - 1 ? prev + 1 : prev
|
||||
);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => prev > 0 ? prev - 1 : -1);
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (selectedIndex >= 0 && selectedIndex < filteredSuggestions.length) {
|
||||
handleSelectSuggestion(filteredSuggestions[selectedIndex]);
|
||||
} else {
|
||||
onSaveEdit();
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
onCancelEdit();
|
||||
} else if (e.key === 'Tab') {
|
||||
// Allow tab to close dropdown and save
|
||||
setShowDropdown(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = (e: React.FocusEvent) => {
|
||||
// Check if focus is moving to the dropdown
|
||||
if (dropdownRef.current?.contains(e.relatedTarget as Node)) {
|
||||
return;
|
||||
}
|
||||
// Small delay to allow click on dropdown item
|
||||
setTimeout(() => {
|
||||
setShowDropdown(false);
|
||||
onSaveEdit();
|
||||
}, 150);
|
||||
};
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<span className="import-taxonomy-pill editing">
|
||||
<span className="pill-name">{item.name}</span>
|
||||
<span className="pill-arrow">→</span>
|
||||
<div className="pill-edit-container">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="pill-edit-input"
|
||||
value={editValue}
|
||||
onChange={(e) => {
|
||||
onEditValueChange(e.target.value);
|
||||
setShowDropdown(true);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleBlur}
|
||||
onFocus={() => setShowDropdown(true)}
|
||||
placeholder="Map to..."
|
||||
/>
|
||||
{showDropdown && filteredSuggestions.length > 0 && (
|
||||
<div className="pill-suggestions-dropdown" ref={dropdownRef}>
|
||||
{filteredSuggestions.slice(0, 10).map((suggestion, idx) => (
|
||||
<button
|
||||
key={suggestion}
|
||||
className={`pill-suggestion-item ${idx === selectedIndex ? 'selected' : ''}`}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
handleSelectSuggestion(suggestion);
|
||||
}}
|
||||
onMouseEnter={() => setSelectedIndex(idx)}
|
||||
>
|
||||
{suggestion}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{tags.length > 0 && (
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5, color: 'var(--vscode-descriptionForeground)', marginBottom: 6 }}>
|
||||
Tags
|
||||
</div>
|
||||
<div className="import-taxonomy-list">
|
||||
{tags.map((tag, idx) => (
|
||||
<span key={idx} className={`import-taxonomy-pill ${tag.existsInProject ? 'exists' : 'new-tax'}`}>
|
||||
{tag.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
)}
|
||||
</div>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const hasMaping = !!item.mappedTo;
|
||||
const className = `import-taxonomy-pill ${item.existsInProject ? 'exists' : 'new-tax'} ${hasMaping ? 'mapped' : ''}`;
|
||||
|
||||
return (
|
||||
<span className={className} onClick={onStartEdit} title={`Click to ${hasMaping ? 'edit' : 'add'} mapping`}>
|
||||
{item.name}
|
||||
{hasMaping && (
|
||||
<>
|
||||
<span className="pill-arrow">→</span>
|
||||
<span className="pill-mapped-to">{item.mappedTo}</span>
|
||||
<button
|
||||
className="pill-clear-btn"
|
||||
onClick={(e) => { e.stopPropagation(); onClearMapping(); }}
|
||||
title="Clear mapping"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
3
src/renderer/types/electron.d.ts
vendored
3
src/renderer/types/electron.d.ts
vendored
@@ -432,6 +432,9 @@ export interface ElectronAPI {
|
||||
clearMessages: (conversationId: string) => Promise<void>;
|
||||
setConversationModel: (conversationId: string, modelId: string) => Promise<void>;
|
||||
|
||||
// Taxonomy Analysis
|
||||
analyzeTaxonomy: (categories: Array<{ name: string; slug: string; existsInProject: boolean }>, tags: Array<{ name: string; slug: string; existsInProject: boolean }>, modelId: string) => Promise<{ success: boolean; categoryMappings?: Record<string, string>; tagMappings?: Record<string, string>; error?: string }>;
|
||||
|
||||
// Event listeners for streaming/progress
|
||||
onStreamDelta: (callback: (data: ChatStreamDelta) => void) => () => void;
|
||||
onToolCall: (callback: (data: ChatToolCall) => void) => () => void;
|
||||
|
||||
Reference in New Issue
Block a user