Fix queued bubbles, queue persistence, duplicate confirm UX, semantic check
- Queued message bubbles now match user bubble style (primary colour, right-aligned, opacity 0.45) in both ChatView and WorkspaceView - Queue persisted to localStorage (fa_conv_queue_<id>); survives page refresh, restored on fetchConversation, cleared on delete - ToolCallCard confirm/deny: buttons now inline in header row; "Create anyway" calls POST /api/notes or /api/tasks directly (no LLM round-trip); shows "✓ Created" / "Skipped" state; removed chatStore dependency - POST /api/notes and POST /api/tasks now accept project (name string) and resolve to project_id server-side, matching tools.py behaviour - Remove semantic similarity duplicate check from create_note and create_task — 0.87 threshold was too aggressive for topically-related notes; title-based exact/fuzzy checks are sufficient Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,16 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from "vue";
|
import { computed, ref, watch } from "vue";
|
||||||
import { apiPost } from "@/api/client";
|
import { apiPost } from "@/api/client";
|
||||||
import { useChatStore } from "@/stores/chat";
|
|
||||||
import type { ToolCallRecord } from "@/types/chat";
|
import type { ToolCallRecord } from "@/types/chat";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
toolCall: ToolCallRecord;
|
toolCall: ToolCallRecord;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const chatStore = useChatStore();
|
|
||||||
const appliedTags = ref<Set<string>>(new Set());
|
const appliedTags = ref<Set<string>>(new Set());
|
||||||
const applyingTag = ref<string | null>(null);
|
const applyingTag = ref<string | null>(null);
|
||||||
|
const confirmState = ref<"idle" | "creating" | "created" | "denied">("idle");
|
||||||
|
|
||||||
const label = computed(() => {
|
const label = computed(() => {
|
||||||
if (props.toolCall.status === "declined") return "Declined";
|
if (props.toolCall.status === "declined") return "Declined";
|
||||||
@@ -213,12 +212,31 @@ function toggle() {
|
|||||||
if (hasDetail.value) collapsed.value = !collapsed.value;
|
if (hasDetail.value) collapsed.value = !collapsed.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmDuplicate() {
|
async function confirmDuplicate() {
|
||||||
chatStore.sendMessage("Yes, please create it.", null, undefined, true);
|
if (confirmState.value !== "idle") return;
|
||||||
|
confirmState.value = "creating";
|
||||||
|
const args = props.toolCall.arguments as Record<string, unknown>;
|
||||||
|
const isTask = props.toolCall.function === "create_task";
|
||||||
|
const endpoint = isTask ? "/api/tasks" : "/api/notes";
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
title: args.title,
|
||||||
|
body: args.body ?? "",
|
||||||
|
tags: args.tags ?? [],
|
||||||
|
...(args.due_date ? { due_date: args.due_date } : {}),
|
||||||
|
...(args.priority ? { priority: args.priority } : {}),
|
||||||
|
...(args.project ? { project: args.project } : {}),
|
||||||
|
...(isTask ? { status: args.status ?? "todo" } : {}),
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await apiPost(endpoint, payload);
|
||||||
|
confirmState.value = "created";
|
||||||
|
} catch {
|
||||||
|
confirmState.value = "idle";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function denyDuplicate() {
|
function denyDuplicate() {
|
||||||
chatStore.sendMessage("No, skip that creation.", null, undefined, true);
|
confirmState.value = "denied";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applyTag(tag: string) {
|
async function applyTag(tag: string) {
|
||||||
@@ -264,6 +282,19 @@ async function applyTag(tag: string) {
|
|||||||
@click.stop
|
@click.stop
|
||||||
>{{ toolCall.result.similar_note.title }}</router-link>
|
>{{ toolCall.result.similar_note.title }}</router-link>
|
||||||
<span v-else class="tool-error">{{ toolCall.result.error }}</span>
|
<span v-else class="tool-error">{{ toolCall.result.error }}</span>
|
||||||
|
<template v-if="confirmState === 'created'">
|
||||||
|
<span class="confirm-created">✓ Created</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="confirmState === 'denied'">
|
||||||
|
<span class="confirm-denied">Skipped</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="confirmState !== 'creating'">
|
||||||
|
<button class="btn-confirm-duplicate" @click.stop="confirmDuplicate">Create anyway</button>
|
||||||
|
<button class="btn-deny-duplicate" @click.stop="denyDuplicate">Skip</button>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span class="tool-summary-count">Creating…</span>
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="toolCall.status === 'error'">
|
<template v-else-if="toolCall.status === 'error'">
|
||||||
<span class="tool-error">{{ toolCall.result.error }}</span>
|
<span class="tool-error">{{ toolCall.result.error }}</span>
|
||||||
@@ -326,12 +357,6 @@ async function applyTag(tag: string) {
|
|||||||
<span v-if="hasDetail" class="tool-chevron" :class="{ open: !collapsed }">▶</span>
|
<span v-if="hasDetail" class="tool-chevron" :class="{ open: !collapsed }">▶</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Duplicate confirmation actions -->
|
|
||||||
<div v-if="toolCall.result.requires_confirmation" class="duplicate-confirm-actions">
|
|
||||||
<button class="btn-confirm-duplicate" @click="confirmDuplicate">Create Anyway</button>
|
|
||||||
<button class="btn-deny-duplicate" @click="denyDuplicate">Don't Create</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ── Detail section (expandable) ─────────────────────────── -->
|
<!-- ── Detail section (expandable) ─────────────────────────── -->
|
||||||
<div v-if="hasDetail" v-show="!collapsed" class="tool-card-detail">
|
<div v-if="hasDetail" v-show="!collapsed" class="tool-card-detail">
|
||||||
<template v-if="noteContent && noteContent.tags.length">
|
<template v-if="noteContent && noteContent.tags.length">
|
||||||
@@ -609,32 +634,38 @@ async function applyTag(tag: string) {
|
|||||||
.tool-call-card.requires-confirm {
|
.tool-call-card.requires-confirm {
|
||||||
border-color: color-mix(in srgb, var(--color-warning, #f59e0b) 60%, transparent);
|
border-color: color-mix(in srgb, var(--color-warning, #f59e0b) 60%, transparent);
|
||||||
}
|
}
|
||||||
.duplicate-confirm-actions {
|
.confirm-created {
|
||||||
display: flex;
|
color: var(--color-success, #2ecc71);
|
||||||
gap: 0.4rem;
|
font-size: 0.78rem;
|
||||||
padding: 0.4rem 0.6rem;
|
font-weight: 600;
|
||||||
border-top: 1px solid var(--color-border);
|
}
|
||||||
|
.confirm-denied {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-style: italic;
|
||||||
}
|
}
|
||||||
.btn-confirm-duplicate {
|
.btn-confirm-duplicate {
|
||||||
padding: 0.25rem 0.7rem;
|
padding: 0.15rem 0.5rem;
|
||||||
background: var(--color-primary);
|
background: var(--color-primary);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--radius-sm, 6px);
|
border-radius: var(--radius-sm, 6px);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.78rem;
|
font-size: 0.72rem;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.btn-confirm-duplicate:hover { opacity: 0.9; }
|
.btn-confirm-duplicate:hover { opacity: 0.9; }
|
||||||
.btn-deny-duplicate {
|
.btn-deny-duplicate {
|
||||||
padding: 0.25rem 0.7rem;
|
padding: 0.15rem 0.5rem;
|
||||||
background: none;
|
background: none;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--radius-sm, 6px);
|
border-radius: var(--radius-sm, 6px);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.78rem;
|
font-size: 0.72rem;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.btn-deny-duplicate:hover {
|
.btn-deny-duplicate:hover {
|
||||||
color: var(--color-danger, #e74c3c);
|
color: var(--color-danger, #e74c3c);
|
||||||
|
|||||||
@@ -89,9 +89,32 @@ export const useChatStore = defineStore("chat", () => {
|
|||||||
return id ? (convQueues.value[id] ?? []) : [];
|
return id ? (convQueues.value[id] ?? []) : [];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function _saveQueue(convId: number) {
|
||||||
|
const q = convQueues.value[convId] ?? [];
|
||||||
|
if (q.length) {
|
||||||
|
localStorage.setItem(`fa_conv_queue_${convId}`, JSON.stringify(q));
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(`fa_conv_queue_${convId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _loadQueue(convId: number) {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(`fa_conv_queue_${convId}`);
|
||||||
|
if (raw) {
|
||||||
|
convQueues.value[convId] = JSON.parse(raw) as QueuedMessage[];
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore corrupt data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function clearQueue() {
|
function clearQueue() {
|
||||||
const id = currentConversation.value?.id;
|
const id = currentConversation.value?.id;
|
||||||
if (id) convQueues.value[id] = [];
|
if (id) {
|
||||||
|
convQueues.value[id] = [];
|
||||||
|
_saveQueue(id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Chat is usable when Ollama is up and model is at least installed (cold starts still work)
|
// Chat is usable when Ollama is up and model is at least installed (cold starts still work)
|
||||||
@@ -134,6 +157,7 @@ export const useChatStore = defineStore("chat", () => {
|
|||||||
currentConversation.value = await apiGet<ConversationDetail>(
|
currentConversation.value = await apiGet<ConversationDetail>(
|
||||||
`/api/chat/conversations/${id}`
|
`/api/chat/conversations/${id}`
|
||||||
);
|
);
|
||||||
|
_loadQueue(id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
useToastStore().show("Failed to load conversation", "error");
|
useToastStore().show("Failed to load conversation", "error");
|
||||||
throw e;
|
throw e;
|
||||||
@@ -153,6 +177,7 @@ export const useChatStore = defineStore("chat", () => {
|
|||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
delete convStreams.value[id];
|
delete convStreams.value[id];
|
||||||
delete convQueues.value[id];
|
delete convQueues.value[id];
|
||||||
|
localStorage.removeItem(`fa_conv_queue_${id}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,6 +190,7 @@ export const useChatStore = defineStore("chat", () => {
|
|||||||
}
|
}
|
||||||
delete convStreams.value[id];
|
delete convStreams.value[id];
|
||||||
delete convQueues.value[id];
|
delete convQueues.value[id];
|
||||||
|
localStorage.removeItem(`fa_conv_queue_${id}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
useToastStore().show("Failed to delete conversation", "error");
|
useToastStore().show("Failed to delete conversation", "error");
|
||||||
throw e;
|
throw e;
|
||||||
@@ -212,6 +238,7 @@ export const useChatStore = defineStore("chat", () => {
|
|||||||
content, contextNoteId, includeNoteIds, think, contextNoteTitle,
|
content, contextNoteId, includeNoteIds, think, contextNoteTitle,
|
||||||
excludeNoteIds, ragProjectId, workspaceProjectId,
|
excludeNoteIds, ragProjectId, workspaceProjectId,
|
||||||
});
|
});
|
||||||
|
_saveQueue(convId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -409,6 +436,7 @@ export const useChatStore = defineStore("chat", () => {
|
|||||||
const queue = convQueues.value[convId];
|
const queue = convQueues.value[convId];
|
||||||
if (queue?.length && currentConversation.value?.id === convId) {
|
if (queue?.length && currentConversation.value?.id === convId) {
|
||||||
const next = queue.shift()!;
|
const next = queue.shift()!;
|
||||||
|
_saveQueue(convId);
|
||||||
setTimeout(() => sendMessage(
|
setTimeout(() => sendMessage(
|
||||||
next.content,
|
next.content,
|
||||||
next.contextNoteId,
|
next.contextNoteId,
|
||||||
|
|||||||
@@ -1623,17 +1623,21 @@ details[open] .thinking-summary::before {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.queued-bubble {
|
.queued-bubble {
|
||||||
background: var(--color-bg-secondary) !important;
|
background: var(--color-primary) !important;
|
||||||
border: 1px dashed var(--color-border) !important;
|
color: #fff !important;
|
||||||
opacity: 0.7;
|
border-bottom-right-radius: 4px !important;
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
.queued-bubble .message-content {
|
||||||
|
color: #fff;
|
||||||
}
|
}
|
||||||
.queued-badge {
|
.queued-badge {
|
||||||
font-size: 0.7rem;
|
font-size: 0.65rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
color: var(--color-text-muted);
|
color: rgba(255, 255, 255, 0.75);
|
||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.2rem;
|
||||||
}
|
}
|
||||||
.queued-clear-row {
|
.queued-clear-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -644,17 +644,21 @@ details[open] .thinking-summary::before {
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
.queued-bubble {
|
.queued-bubble {
|
||||||
background: var(--color-bg-secondary) !important;
|
background: var(--color-primary) !important;
|
||||||
border: 1px dashed var(--color-border) !important;
|
color: #fff !important;
|
||||||
opacity: 0.7;
|
border-bottom-right-radius: 4px !important;
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
.queued-bubble .message-content {
|
||||||
|
color: #fff;
|
||||||
}
|
}
|
||||||
.queued-badge {
|
.queued-badge {
|
||||||
font-size: 0.7rem;
|
font-size: 0.65rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
color: var(--color-text-muted);
|
color: rgba(255, 255, 255, 0.75);
|
||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.2rem;
|
||||||
}
|
}
|
||||||
.queued-clear-row {
|
.queued-clear-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -92,13 +92,20 @@ async def create_note_route():
|
|||||||
if isinstance(due_date, tuple):
|
if isinstance(due_date, tuple):
|
||||||
return due_date
|
return due_date
|
||||||
|
|
||||||
|
project_id = data.get("project_id")
|
||||||
|
if project_id is None and data.get("project"):
|
||||||
|
from fabledassistant.services.projects import get_project_by_title as _gpbt
|
||||||
|
proj = await _gpbt(uid, data["project"])
|
||||||
|
if proj:
|
||||||
|
project_id = proj.id
|
||||||
|
|
||||||
note = await create_note(
|
note = await create_note(
|
||||||
uid,
|
uid,
|
||||||
title=data.get("title", ""),
|
title=data.get("title", ""),
|
||||||
body=body,
|
body=body,
|
||||||
tags=tags,
|
tags=tags,
|
||||||
parent_id=data.get("parent_id"),
|
parent_id=data.get("parent_id"),
|
||||||
project_id=data.get("project_id"),
|
project_id=project_id,
|
||||||
milestone_id=data.get("milestone_id"),
|
milestone_id=data.get("milestone_id"),
|
||||||
status=status,
|
status=status,
|
||||||
priority=priority,
|
priority=priority,
|
||||||
|
|||||||
@@ -68,6 +68,13 @@ async def create_task_route():
|
|||||||
TaskPriority(data["priority"]).value if "priority" in data else TaskPriority.none.value
|
TaskPriority(data["priority"]).value if "priority" in data else TaskPriority.none.value
|
||||||
)
|
)
|
||||||
|
|
||||||
|
project_id = data.get("project_id")
|
||||||
|
if project_id is None and data.get("project"):
|
||||||
|
from fabledassistant.services.projects import get_project_by_title as _gpbt
|
||||||
|
proj = await _gpbt(uid, data["project"])
|
||||||
|
if proj:
|
||||||
|
project_id = proj.id
|
||||||
|
|
||||||
task = await create_note(
|
task = await create_note(
|
||||||
uid,
|
uid,
|
||||||
title=data.get("title", ""),
|
title=data.get("title", ""),
|
||||||
@@ -76,7 +83,7 @@ async def create_task_route():
|
|||||||
priority=priority,
|
priority=priority,
|
||||||
due_date=due_date,
|
due_date=due_date,
|
||||||
tags=tags,
|
tags=tags,
|
||||||
project_id=data.get("project_id"),
|
project_id=project_id,
|
||||||
milestone_id=data.get("milestone_id"),
|
milestone_id=data.get("milestone_id"),
|
||||||
parent_id=data.get("parent_id"),
|
parent_id=data.get("parent_id"),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -825,15 +825,6 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
|
|||||||
item_type = "task" if near.status is not None else "note"
|
item_type = "task" if near.status is not None else "note"
|
||||||
return {"success": False, "requires_confirmation": True, "similar_note": {"id": near.id, "title": near.title}, "error": f"A {item_type} with a very similar title '{near.title}' already exists (similarity: {ratio:.0%}). Ask the user to confirm before creating a separate entry."}
|
return {"success": False, "requires_confirmation": True, "similar_note": {"id": near.id, "title": near.title}, "error": f"A {item_type} with a very similar title '{near.title}' already exists (similarity: {ratio:.0%}). Ask the user to confirm before creating a separate entry."}
|
||||||
|
|
||||||
if not arguments.get("confirmed") and len(task_body.strip()) >= 80:
|
|
||||||
from fabledassistant.services.embeddings import semantic_search_notes as _ssn
|
|
||||||
sem_query = f"{task_title}\n{task_body}".strip()
|
|
||||||
sem_hits = await _ssn(user_id, sem_query, limit=3, threshold=0.87)
|
|
||||||
if sem_hits:
|
|
||||||
best_score, best_note = sem_hits[0]
|
|
||||||
item_type = "task" if best_note.status is not None else "note"
|
|
||||||
return {"success": False, "requires_confirmation": True, "similar_note": {"id": best_note.id, "title": best_note.title}, "error": f"A {item_type} with very similar content exists: '{best_note.title}' (semantic similarity: {best_score:.0%}). Ask the user to confirm before creating a separate entry."}
|
|
||||||
|
|
||||||
note = await create_note(
|
note = await create_note(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
title=task_title,
|
title=task_title,
|
||||||
@@ -903,15 +894,6 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
|
|||||||
item_type = "task" if near.status is not None else "note"
|
item_type = "task" if near.status is not None else "note"
|
||||||
return {"success": False, "requires_confirmation": True, "similar_note": {"id": near.id, "title": near.title}, "error": f"A {item_type} with a very similar title '{near.title}' already exists (similarity: {ratio:.0%}). Ask the user to confirm before creating a separate entry."}
|
return {"success": False, "requires_confirmation": True, "similar_note": {"id": near.id, "title": near.title}, "error": f"A {item_type} with a very similar title '{near.title}' already exists (similarity: {ratio:.0%}). Ask the user to confirm before creating a separate entry."}
|
||||||
|
|
||||||
if not arguments.get("confirmed") and len(note_body.strip()) >= 80:
|
|
||||||
from fabledassistant.services.embeddings import semantic_search_notes as _ssn
|
|
||||||
sem_query = f"{note_title}\n{note_body}".strip()
|
|
||||||
sem_hits = await _ssn(user_id, sem_query, limit=3, threshold=0.87)
|
|
||||||
if sem_hits:
|
|
||||||
best_score, best_note = sem_hits[0]
|
|
||||||
item_type = "task" if best_note.status is not None else "note"
|
|
||||||
return {"success": False, "requires_confirmation": True, "similar_note": {"id": best_note.id, "title": best_note.title}, "error": f"A {item_type} with very similar content exists: '{best_note.title}' (semantic similarity: {best_score:.0%}). Ask the user to confirm before creating a separate entry."}
|
|
||||||
|
|
||||||
note = await create_note(
|
note = await create_note(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
title=note_title,
|
title=note_title,
|
||||||
|
|||||||
Reference in New Issue
Block a user