feat: tag/cat mapping tools
This commit is contained in:
@@ -38,12 +38,14 @@ export interface AnalyzedCategory {
|
|||||||
name: string;
|
name: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
existsInProject: boolean;
|
existsInProject: boolean;
|
||||||
|
mappedTo?: string; // When set, indicates this item should be mapped to the given name on import
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AnalyzedTag {
|
export interface AnalyzedTag {
|
||||||
name: string;
|
name: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
existsInProject: boolean;
|
existsInProject: boolean;
|
||||||
|
mappedTo?: string; // When set, indicates this item should be mapped to the given name on import
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ImportAnalysisReport {
|
export interface ImportAnalysisReport {
|
||||||
|
|||||||
@@ -1147,6 +1147,149 @@ export class OpenCodeManager {
|
|||||||
return errorMsg;
|
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(
|
private httpRequest(
|
||||||
urlStr: string,
|
urlStr: string,
|
||||||
options: {
|
options: {
|
||||||
|
|||||||
@@ -317,6 +317,19 @@ export function registerChatHandlers(): void {
|
|||||||
return { success: false, error: (error as Error).message };
|
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),
|
clearMessages: (conversationId: string) => ipcRenderer.invoke('chat:clearMessages', conversationId),
|
||||||
setConversationModel: (conversationId: string, modelId: string) => ipcRenderer.invoke('chat:setConversationModel', conversationId, modelId),
|
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
|
// Event listeners for streaming/progress
|
||||||
onStreamDelta: (callback: (data: { conversationId: string; delta: string }) => void) => {
|
onStreamDelta: (callback: (data: { conversationId: string; delta: string }) => void) => {
|
||||||
const subscription = (_event: Electron.IpcRendererEvent, data: { conversationId: string; delta: string }) => callback(data);
|
const subscription = (_event: Electron.IpcRendererEvent, data: { conversationId: string; delta: string }) => callback(data);
|
||||||
|
|||||||
@@ -382,6 +382,213 @@
|
|||||||
color: #75beff;
|
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 */
|
/* Empty state */
|
||||||
.import-empty-state {
|
.import-empty-state {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
||||||
|
import type { ChatModel } from '../../types/electron';
|
||||||
import './ImportAnalysisView.css';
|
import './ImportAnalysisView.css';
|
||||||
|
|
||||||
interface AnalysisReport {
|
interface AnalysisReport {
|
||||||
@@ -50,6 +51,7 @@ interface TaxonomyItem {
|
|||||||
name: string;
|
name: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
existsInProject: boolean;
|
existsInProject: boolean;
|
||||||
|
mappedTo?: string; // When set, indicates this item should be mapped to the given name on import
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportAnalysisViewProps {
|
interface ImportAnalysisViewProps {
|
||||||
@@ -66,6 +68,62 @@ export const ImportAnalysisView: React.FC<ImportAnalysisViewProps> = ({ definiti
|
|||||||
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({});
|
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({});
|
||||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
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
|
// Load definition on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
@@ -247,6 +305,8 @@ export const ImportAnalysisView: React.FC<ImportAnalysisViewProps> = ({ definiti
|
|||||||
tags={report.tags}
|
tags={report.tags}
|
||||||
expanded={expandedSections['taxonomy'] ?? false}
|
expanded={expandedSections['taxonomy'] ?? false}
|
||||||
onToggle={() => toggleSection('taxonomy')}
|
onToggle={() => toggleSection('taxonomy')}
|
||||||
|
onMappingsAnalyzed={handleTaxonomyMappingsUpdated}
|
||||||
|
onMappingUpdated={handleSingleMappingUpdated}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -453,24 +513,171 @@ const TaxonomySection: React.FC<{
|
|||||||
tags: TaxonomyItem[];
|
tags: TaxonomyItem[];
|
||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
}> = ({ categories, tags, expanded, onToggle }) => (
|
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">
|
<div className="import-detail-section">
|
||||||
<h3 onClick={onToggle}>
|
<h3 onClick={onToggle}>
|
||||||
<span className={`toggle-icon ${expanded ? 'open' : ''}`}>▶</span>
|
<span className={`toggle-icon ${expanded ? 'open' : ''}`}>▶</span>
|
||||||
Categories & Tags
|
Categories & Tags
|
||||||
|
{(mappedCategoriesCount > 0 || mappedTagsCount > 0) && (
|
||||||
|
<span className="taxonomy-mapped-count">
|
||||||
|
{mappedCategoriesCount + mappedTagsCount} mapped
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</h3>
|
</h3>
|
||||||
{expanded && (
|
{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>
|
||||||
|
<span className="taxonomy-analyze-hint">
|
||||||
|
AI will suggest mappings to consolidate similar tags and categories
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{categories.length > 0 && (
|
{categories.length > 0 && (
|
||||||
<div style={{ marginBottom: 12 }}>
|
<div style={{ marginBottom: 12, marginTop: 16 }}>
|
||||||
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5, color: 'var(--vscode-descriptionForeground)', marginBottom: 6 }}>
|
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: 0.5, color: 'var(--vscode-descriptionForeground)', marginBottom: 6 }}>
|
||||||
Categories
|
Categories
|
||||||
</div>
|
</div>
|
||||||
<div className="import-taxonomy-list">
|
<div className="import-taxonomy-list">
|
||||||
{categories.map((cat, idx) => (
|
{categories.map((cat, idx) => (
|
||||||
<span key={idx} className={`import-taxonomy-pill ${cat.existsInProject ? 'exists' : 'new-tax'}`}>
|
<TaxonomyPill
|
||||||
{cat.name}
|
key={idx}
|
||||||
</span>
|
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>
|
||||||
</div>
|
</div>
|
||||||
@@ -482,9 +689,19 @@ const TaxonomySection: React.FC<{
|
|||||||
</div>
|
</div>
|
||||||
<div className="import-taxonomy-list">
|
<div className="import-taxonomy-list">
|
||||||
{tags.map((tag, idx) => (
|
{tags.map((tag, idx) => (
|
||||||
<span key={idx} className={`import-taxonomy-pill ${tag.existsInProject ? 'exists' : 'new-tax'}`}>
|
<TaxonomyPill
|
||||||
{tag.name}
|
key={idx}
|
||||||
</span>
|
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>
|
</div>
|
||||||
@@ -492,4 +709,147 @@ const TaxonomySection: React.FC<{
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</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>
|
||||||
|
</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>;
|
clearMessages: (conversationId: string) => Promise<void>;
|
||||||
setConversationModel: (conversationId: string, modelId: 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
|
// Event listeners for streaming/progress
|
||||||
onStreamDelta: (callback: (data: ChatStreamDelta) => void) => () => void;
|
onStreamDelta: (callback: (data: ChatStreamDelta) => void) => () => void;
|
||||||
onToolCall: (callback: (data: ChatToolCall) => void) => () => void;
|
onToolCall: (callback: (data: ChatToolCall) => void) => () => void;
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ vi.mock('fs/promises', () => ({
|
|||||||
unlink: vi.fn(async () => {}),
|
unlink: vi.fn(async () => {}),
|
||||||
mkdir: vi.fn(async () => {}),
|
mkdir: vi.fn(async () => {}),
|
||||||
readdir: vi.fn(async () => []),
|
readdir: vi.fn(async () => []),
|
||||||
|
rm: vi.fn(async () => {}),
|
||||||
stat: vi.fn(async () => ({
|
stat: vi.fn(async () => ({
|
||||||
isFile: () => false,
|
isFile: () => false,
|
||||||
isDirectory: () => true,
|
isDirectory: () => true,
|
||||||
|
|||||||
Reference in New Issue
Block a user