Add category management tools to AI chat (list, create, delete)
Three new tools: list_categories shows all categories with type and parent, create_category adds a new one with optional parent nesting, delete_category removes non-system categories by name. Frontend renders category tables, success alerts for creation, and warning alerts for deletion. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -120,6 +120,44 @@
|
||||
from "{{ data.oldCategory }}" to "{{ data.newCategory }}"
|
||||
</v-alert>
|
||||
</div>
|
||||
|
||||
<!-- Categories List -->
|
||||
<div v-else-if="type === 'categories' && Array.isArray(data)">
|
||||
<v-table density="compact" class="text-caption rounded-lg mb-2">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Parent</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="cat in data" :key="cat.id">
|
||||
<td>{{ cat.name }}</td>
|
||||
<td>
|
||||
<v-chip size="x-small" :color="cat.type === 'Income' ? 'success' : cat.type === 'Expense' ? 'error' : 'info'" variant="tonal">
|
||||
{{ cat.type }}
|
||||
</v-chip>
|
||||
</td>
|
||||
<td class="text-medium-emphasis">{{ cat.parentName || '-' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</div>
|
||||
|
||||
<!-- Category Created -->
|
||||
<div v-else-if="type === 'category_created'">
|
||||
<v-alert type="success" variant="tonal" density="compact" class="mb-2 text-caption">
|
||||
Created {{ data.type }} category "{{ data.name }}"{{ data.parentName ? ` under "${data.parentName}"` : '' }}
|
||||
</v-alert>
|
||||
</div>
|
||||
|
||||
<!-- Category Deleted -->
|
||||
<div v-else-if="type === 'category_deleted'">
|
||||
<v-alert type="warning" variant="tonal" density="compact" class="mb-2 text-caption">
|
||||
Deleted {{ data.type }} category "{{ data.name }}"
|
||||
</v-alert>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
@@ -33,7 +33,7 @@ public class ChatService : IChatService
|
||||
private const string SystemPromptTemplate =
|
||||
"""
|
||||
You are a helpful personal finance assistant for Purrse.
|
||||
You can search transactions, view spending reports, check account balances, and update transaction categories.
|
||||
You can search transactions, view spending reports, check account balances, update transaction categories, and manage categories (list, create, delete).
|
||||
When the user asks about their finances, use the available tools to look up real data.
|
||||
Always present financial amounts formatted as currency.
|
||||
When showing transactions, include the date, payee, amount, and category.
|
||||
@@ -424,6 +424,9 @@ public class ChatService : IChatService
|
||||
"get_account_balances" => await ExecuteGetAccountBalancesAsync(userId, args),
|
||||
"get_dashboard_summary" => await ExecuteGetDashboardSummaryAsync(userId),
|
||||
"update_transaction_category" => await ExecuteUpdateTransactionCategoryAsync(userId, args),
|
||||
"list_categories" => await ExecuteListCategoriesAsync(userId),
|
||||
"create_category" => await ExecuteCreateCategoryAsync(userId, args),
|
||||
"delete_category" => await ExecuteDeleteCategoryAsync(userId, args),
|
||||
_ => ("error", new { error = $"Unknown tool: {name}" }, $"Error: Unknown tool '{name}'")
|
||||
};
|
||||
}
|
||||
@@ -586,6 +589,74 @@ public class ChatService : IChatService
|
||||
$"Updated transaction '{updated.PayeeName ?? "Unknown"}' ({updated.Date:yyyy-MM-dd}, {updated.Amount:C}) category from '{existing.CategoryName ?? "Uncategorized"}' to '{category.Name}'.");
|
||||
}
|
||||
|
||||
private async Task<(string, object, string)> ExecuteListCategoriesAsync(Guid userId)
|
||||
{
|
||||
var categories = await _categoryService.GetAllAsync(userId);
|
||||
var active = categories.Where(c => c.IsActive).ToList();
|
||||
|
||||
var summary = new StringBuilder();
|
||||
summary.AppendLine($"You have {active.Count} active categories:");
|
||||
|
||||
var grouped = active.GroupBy(c => c.Type).OrderBy(g => g.Key);
|
||||
foreach (var group in grouped)
|
||||
{
|
||||
summary.AppendLine($"\n{group.Key}:");
|
||||
foreach (var cat in group.OrderBy(c => c.SortOrder))
|
||||
{
|
||||
var parent = cat.ParentName != null ? $" (under {cat.ParentName})" : "";
|
||||
summary.AppendLine($"- {cat.Name}{parent} (ID: {cat.Id})");
|
||||
}
|
||||
}
|
||||
|
||||
return ("categories", active, summary.ToString());
|
||||
}
|
||||
|
||||
private async Task<(string, object, string)> ExecuteCreateCategoryAsync(Guid userId, Dictionary<string, object> args)
|
||||
{
|
||||
var name = ParseStringArg(args, "name")
|
||||
?? throw new ArgumentException("name is required");
|
||||
|
||||
var typeStr = ParseStringArg(args, "type") ?? "Expense";
|
||||
if (!Enum.TryParse<Purrse.Core.Enums.CategoryType>(typeStr, ignoreCase: true, out var categoryType))
|
||||
categoryType = Purrse.Core.Enums.CategoryType.Expense;
|
||||
|
||||
// Resolve optional parent category by name
|
||||
Guid? parentId = null;
|
||||
var parentName = ParseStringArg(args, "parent_name");
|
||||
if (parentName != null)
|
||||
{
|
||||
var categories = await _categoryService.GetAllAsync(userId);
|
||||
var parent = categories.FirstOrDefault(c => c.Name.Equals(parentName, StringComparison.OrdinalIgnoreCase));
|
||||
if (parent != null) parentId = parent.Id;
|
||||
}
|
||||
|
||||
var created = await _categoryService.CreateAsync(userId, new CreateCategoryRequest(name, categoryType, parentId));
|
||||
|
||||
var result = new { id = created.Id, name = created.Name, type = created.Type.ToString(), parentName };
|
||||
|
||||
return ("category_created", result,
|
||||
$"Created new {categoryType} category '{created.Name}'" + (parentName != null ? $" under '{parentName}'" : "") + ".");
|
||||
}
|
||||
|
||||
private async Task<(string, object, string)> ExecuteDeleteCategoryAsync(Guid userId, Dictionary<string, object> args)
|
||||
{
|
||||
var categoryName = ParseStringArg(args, "name")
|
||||
?? throw new ArgumentException("name is required");
|
||||
|
||||
var categories = await _categoryService.GetAllAsync(userId);
|
||||
var category = categories.FirstOrDefault(c => c.Name.Equals(categoryName, StringComparison.OrdinalIgnoreCase))
|
||||
?? throw new ArgumentException($"Category '{categoryName}' not found");
|
||||
|
||||
if (category.IsSystem)
|
||||
throw new InvalidOperationException($"Cannot delete system category '{category.Name}'");
|
||||
|
||||
await _categoryService.DeleteAsync(userId, category.Id);
|
||||
|
||||
var result = new { id = category.Id, name = category.Name, type = category.Type.ToString() };
|
||||
|
||||
return ("category_deleted", result, $"Deleted category '{category.Name}'.");
|
||||
}
|
||||
|
||||
private static List<OllamaTool> BuildToolDefinitions()
|
||||
{
|
||||
return new List<OllamaTool>
|
||||
@@ -698,6 +769,58 @@ public class ChatService : IChatService
|
||||
Required = new List<string> { "transaction_id", "category_name" }
|
||||
}
|
||||
}
|
||||
},
|
||||
new()
|
||||
{
|
||||
Type = "function",
|
||||
Function = new OllamaFunction
|
||||
{
|
||||
Name = "list_categories",
|
||||
Description = "List all available categories. Shows name, type (Income/Expense/Transfer), and parent category if any.",
|
||||
Parameters = new OllamaFunctionParameters
|
||||
{
|
||||
Type = "object",
|
||||
Properties = new Dictionary<string, OllamaPropertySchema>()
|
||||
}
|
||||
}
|
||||
},
|
||||
new()
|
||||
{
|
||||
Type = "function",
|
||||
Function = new OllamaFunction
|
||||
{
|
||||
Name = "create_category",
|
||||
Description = "Create a new category. Specify the name, type (Income, Expense, or Transfer), and optionally a parent category name to nest it under.",
|
||||
Parameters = new OllamaFunctionParameters
|
||||
{
|
||||
Type = "object",
|
||||
Properties = new Dictionary<string, OllamaPropertySchema>
|
||||
{
|
||||
["name"] = new() { Type = "string", Description = "The name for the new category" },
|
||||
["type"] = new() { Type = "string", Description = "Category type", Enum = new List<string> { "Income", "Expense", "Transfer" } },
|
||||
["parent_name"] = new() { Type = "string", Description = "Optional parent category name to nest under" }
|
||||
},
|
||||
Required = new List<string> { "name", "type" }
|
||||
}
|
||||
}
|
||||
},
|
||||
new()
|
||||
{
|
||||
Type = "function",
|
||||
Function = new OllamaFunction
|
||||
{
|
||||
Name = "delete_category",
|
||||
Description = "Delete a category by name. System categories cannot be deleted. Transactions using this category will become uncategorized.",
|
||||
Parameters = new OllamaFunctionParameters
|
||||
{
|
||||
Type = "object",
|
||||
Properties = new Dictionary<string, OllamaPropertySchema>
|
||||
{
|
||||
["name"] = new() { Type = "string", Description = "The exact name of the category to delete" }
|
||||
},
|
||||
Required = new List<string> { "name" }
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user