d7e1fe6aab
- Dashboard chat: allow typing/sending during streaming (queued in store) placeholder updates to "Type to queue…" during generation - ChatView/WorkspaceView: replace queued chip with actual pending message bubbles — light grey, dashed border, "Queued" badge above content; clear button below the stack - ToolCallCard: when tool returns requires_confirmation=True, show "Similar content found" label (not "Error"), link to the similar note, and "Create Anyway" / "Don't Create" buttons that auto-send the reply - tools.py: fuzzy title match now returns requires_confirmation=True with similar_note data instead of a hard error, so numbered-series notes (Lore: X 0, Lore: X 1) can be created with one button click; semantic match responses also include similar_note for the link Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
352 lines
7.9 KiB
Vue
352 lines
7.9 KiB
Vue
<script setup lang="ts">
|
|
import { ref, nextTick } from "vue";
|
|
import { apiGet } from "@/api/client";
|
|
import { useChatStore } from "@/stores/chat";
|
|
import type { Note } from "@/types/note";
|
|
|
|
const emit = defineEmits<{
|
|
submit: [payload: { content: string; contextNoteId?: number }];
|
|
}>();
|
|
|
|
const store = useChatStore();
|
|
|
|
const messageInput = ref("");
|
|
const inputEl = ref<HTMLTextAreaElement | null>(null);
|
|
|
|
// Note picker state
|
|
const attachedNote = ref<{ id: number; title: string } | null>(null);
|
|
const showNotePicker = ref(false);
|
|
const noteSearchQuery = ref("");
|
|
const noteSearchResults = ref<{ id: number; title: string }[]>([]);
|
|
const noteSearchLoading = ref(false);
|
|
let noteSearchTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
function onSubmit() {
|
|
const content = messageInput.value.trim();
|
|
if (!content) return;
|
|
emit("submit", {
|
|
content,
|
|
contextNoteId: attachedNote.value?.id,
|
|
});
|
|
messageInput.value = "";
|
|
attachedNote.value = null;
|
|
resetTextareaHeight();
|
|
}
|
|
|
|
function onInputKeydown(e: KeyboardEvent) {
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
e.preventDefault();
|
|
onSubmit();
|
|
}
|
|
}
|
|
|
|
function autoResize() {
|
|
const el = inputEl.value;
|
|
if (!el) return;
|
|
el.style.height = "auto";
|
|
el.style.height = Math.min(el.scrollHeight, 120) + "px";
|
|
}
|
|
|
|
function resetTextareaHeight() {
|
|
const el = inputEl.value;
|
|
if (!el) return;
|
|
el.style.height = "auto";
|
|
}
|
|
|
|
// Note picker
|
|
function toggleNotePicker() {
|
|
showNotePicker.value = !showNotePicker.value;
|
|
if (showNotePicker.value) {
|
|
noteSearchQuery.value = "";
|
|
noteSearchResults.value = [];
|
|
nextTick(() => {
|
|
const el = document.querySelector(
|
|
".dashboard-chat .note-picker-search"
|
|
) as HTMLInputElement;
|
|
el?.focus();
|
|
});
|
|
}
|
|
}
|
|
|
|
function onNoteSearchInput() {
|
|
if (noteSearchTimer) clearTimeout(noteSearchTimer);
|
|
noteSearchTimer = setTimeout(async () => {
|
|
const q = noteSearchQuery.value.trim();
|
|
if (!q) {
|
|
noteSearchResults.value = [];
|
|
return;
|
|
}
|
|
noteSearchLoading.value = true;
|
|
try {
|
|
const data = await apiGet<{ notes: Note[] }>(
|
|
`/api/notes?q=${encodeURIComponent(q)}&all=true&limit=5`
|
|
);
|
|
noteSearchResults.value = data.notes.map((n) => ({
|
|
id: n.id,
|
|
title: n.title,
|
|
}));
|
|
} catch {
|
|
noteSearchResults.value = [];
|
|
} finally {
|
|
noteSearchLoading.value = false;
|
|
}
|
|
}, 250);
|
|
}
|
|
|
|
function selectNote(note: { id: number; title: string }) {
|
|
attachedNote.value = note;
|
|
showNotePicker.value = false;
|
|
}
|
|
|
|
function removeAttachedNote() {
|
|
attachedNote.value = null;
|
|
}
|
|
|
|
function focus() {
|
|
inputEl.value?.focus();
|
|
}
|
|
|
|
defineExpose({ focus });
|
|
</script>
|
|
|
|
<template>
|
|
<div class="dashboard-chat">
|
|
<!-- Attached note pill -->
|
|
<div v-if="attachedNote" class="attached-note">
|
|
<span class="attached-note-pill">
|
|
{{ attachedNote.title }}
|
|
<button class="attached-note-remove" aria-label="Remove attached note" @click="removeAttachedNote">
|
|
×
|
|
</button>
|
|
</span>
|
|
</div>
|
|
|
|
<div class="chat-input-bar">
|
|
<div class="note-picker-wrapper">
|
|
<button
|
|
class="btn-attach"
|
|
@click="toggleNotePicker"
|
|
:disabled="!store.chatReady"
|
|
title="Attach a note"
|
|
>
|
|
<svg
|
|
width="18"
|
|
height="18"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
>
|
|
<path
|
|
d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
|
|
<div v-if="showNotePicker" class="note-picker-dropdown">
|
|
<input
|
|
class="note-picker-search"
|
|
v-model="noteSearchQuery"
|
|
@input="onNoteSearchInput"
|
|
placeholder="Search notes..."
|
|
/>
|
|
<div class="note-picker-results">
|
|
<div
|
|
v-for="note in noteSearchResults"
|
|
:key="note.id"
|
|
class="note-picker-item"
|
|
@click="selectNote(note)"
|
|
>
|
|
{{ note.title || "Untitled" }}
|
|
</div>
|
|
<div v-if="noteSearchLoading" class="note-picker-empty">
|
|
Searching...
|
|
</div>
|
|
<div
|
|
v-else-if="noteSearchQuery && !noteSearchResults.length"
|
|
class="note-picker-empty"
|
|
>
|
|
No notes found
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<textarea
|
|
ref="inputEl"
|
|
v-model="messageInput"
|
|
@keydown="onInputKeydown"
|
|
@input="autoResize"
|
|
:placeholder="
|
|
!store.chatReady ? 'Chat unavailable'
|
|
: store.streaming ? 'Type to queue… (Enter to queue)'
|
|
: 'Start a new chat… (Enter to send)'
|
|
"
|
|
:disabled="!store.chatReady"
|
|
rows="1"
|
|
></textarea>
|
|
|
|
<button
|
|
class="btn-send"
|
|
@click="onSubmit"
|
|
:disabled="!messageInput.trim() || !store.chatReady"
|
|
>
|
|
↑
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.dashboard-chat {
|
|
margin-top: 0.75rem;
|
|
}
|
|
|
|
.attached-note {
|
|
padding: 0.25rem 0;
|
|
}
|
|
.attached-note-pill {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.25rem;
|
|
background: var(--color-primary);
|
|
color: #fff;
|
|
border-radius: 12px;
|
|
padding: 0.2rem 0.5rem;
|
|
font-size: 0.8rem;
|
|
}
|
|
.attached-note-remove {
|
|
background: none;
|
|
border: none;
|
|
color: rgba(255, 255, 255, 0.7);
|
|
cursor: pointer;
|
|
font-size: 1rem;
|
|
line-height: 1;
|
|
padding: 0 0.15rem;
|
|
}
|
|
.attached-note-remove:hover {
|
|
color: #fff;
|
|
}
|
|
|
|
.chat-input-bar {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 0.5rem 0.5rem 0.5rem 0.75rem;
|
|
background: var(--color-input-bar-bg);
|
|
border-radius: 20px;
|
|
box-shadow: 0 2px 12px var(--color-shadow);
|
|
}
|
|
|
|
.chat-input-bar textarea {
|
|
flex: 1;
|
|
resize: none;
|
|
padding: 0.4rem 0.5rem;
|
|
border: none;
|
|
border-radius: 12px;
|
|
font-family: inherit;
|
|
font-size: 0.95rem;
|
|
background: transparent;
|
|
color: var(--color-input-bar-text);
|
|
outline: none;
|
|
max-height: 120px;
|
|
overflow-y: auto;
|
|
}
|
|
.chat-input-bar textarea::placeholder {
|
|
color: var(--color-input-bar-placeholder);
|
|
}
|
|
.chat-input-bar textarea:disabled {
|
|
opacity: 0.5;
|
|
}
|
|
|
|
/* Note picker */
|
|
.note-picker-wrapper {
|
|
position: relative;
|
|
}
|
|
.btn-attach {
|
|
background: none;
|
|
border: none;
|
|
cursor: pointer;
|
|
color: var(--color-input-bar-text);
|
|
opacity: 0.6;
|
|
padding: 0.25rem;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
.btn-attach:hover {
|
|
opacity: 1;
|
|
}
|
|
.btn-attach:disabled {
|
|
opacity: 0.3;
|
|
cursor: default;
|
|
}
|
|
.note-picker-dropdown {
|
|
position: absolute;
|
|
bottom: calc(100% + 8px);
|
|
left: 0;
|
|
width: 280px;
|
|
background: var(--color-bg-card);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-md);
|
|
box-shadow: 0 4px 16px var(--color-shadow);
|
|
z-index: 10;
|
|
overflow: hidden;
|
|
}
|
|
.note-picker-search {
|
|
width: 100%;
|
|
padding: 0.5rem 0.75rem;
|
|
border: none;
|
|
border-bottom: 1px solid var(--color-border);
|
|
background: transparent;
|
|
color: var(--color-text);
|
|
font-size: 0.9rem;
|
|
outline: none;
|
|
font-family: inherit;
|
|
box-sizing: border-box;
|
|
}
|
|
.note-picker-results {
|
|
max-height: 200px;
|
|
overflow-y: auto;
|
|
}
|
|
.note-picker-item {
|
|
padding: 0.5rem 0.75rem;
|
|
cursor: pointer;
|
|
font-size: 0.9rem;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.note-picker-item:hover {
|
|
background: var(--color-bg-secondary);
|
|
}
|
|
.note-picker-empty {
|
|
padding: 0.5rem 0.75rem;
|
|
color: var(--color-text-muted);
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.btn-send {
|
|
width: 34px;
|
|
min-width: 34px;
|
|
height: 34px;
|
|
padding: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: var(--color-primary);
|
|
color: #fff;
|
|
border: none;
|
|
border-radius: 50%;
|
|
cursor: pointer;
|
|
font-size: 1.1rem;
|
|
flex-shrink: 0;
|
|
}
|
|
.btn-send:disabled {
|
|
opacity: 0.35;
|
|
cursor: default;
|
|
}
|
|
</style>
|