feat(#242): DocAskTab — Reasoning intégré au workspace (doc structure + question + trace cliquable)
This commit is contained in:
parent
0062890830
commit
2718d86262
3 changed files with 542 additions and 12 deletions
359
frontend/src/features/reasoning/ui/AskRunner.vue
Normal file
359
frontend/src/features/reasoning/ui/AskRunner.vue
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
<template>
|
||||
<div class="ask-runner" data-e2e="ask-runner">
|
||||
<!-- Form -->
|
||||
<form class="ask-form" @submit.prevent="run">
|
||||
<label class="ask-label">{{ t('ask.questionLabel') }}</label>
|
||||
<textarea
|
||||
v-model="query"
|
||||
class="ask-textarea"
|
||||
:placeholder="t('ask.questionPlaceholder')"
|
||||
:disabled="running"
|
||||
rows="3"
|
||||
/>
|
||||
<details class="ask-model-details">
|
||||
<summary class="ask-model-summary">{{ t('ask.modelConfig') }}</summary>
|
||||
<input
|
||||
v-model="modelId"
|
||||
class="ask-model-input"
|
||||
:placeholder="t('ask.modelPlaceholder')"
|
||||
:disabled="running"
|
||||
/>
|
||||
<p class="ask-model-hint">{{ t('ask.modelHint') }}</p>
|
||||
</details>
|
||||
<button class="ask-submit" type="submit" :disabled="!query.trim() || running">
|
||||
<span v-if="running" class="ask-spinner" />
|
||||
<span>{{ running ? t('ask.running') : t('ask.run') }}</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-if="error" class="ask-error" data-e2e="ask-error">{{ error }}</div>
|
||||
|
||||
<!-- Result -->
|
||||
<div v-if="result" class="ask-result" data-e2e="ask-result">
|
||||
<!-- Answer -->
|
||||
<div class="ask-answer">
|
||||
<div class="ask-answer-header">
|
||||
<span class="ask-answer-label">{{ t('ask.answerLabel') }}</span>
|
||||
<span class="ask-converged" :class="{ yes: result.converged, no: !result.converged }">
|
||||
{{ result.converged ? t('reasoning.converged') : t('reasoning.notConverged') }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -- sanitized by DOMPurify -->
|
||||
<div class="ask-answer-body" v-html="renderedAnswer" />
|
||||
</div>
|
||||
|
||||
<!-- Iterations -->
|
||||
<div class="ask-iterations">
|
||||
<h4 class="ask-section-title">{{ t('reasoning.iterationsTitle') }}</h4>
|
||||
<IterationCard
|
||||
v-for="it in resolvedIterations"
|
||||
:key="it.iteration"
|
||||
:iteration="it"
|
||||
:active="activeIteration === it.iteration"
|
||||
@focus="onIterationFocus"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import DOMPurify from 'dompurify'
|
||||
import { marked } from 'marked'
|
||||
import { runReasoning } from '../api'
|
||||
import type { ReasoningResult, ReasoningIteration, ResolvedIteration } from '../types'
|
||||
import IterationCard from './IterationCard.vue'
|
||||
import { useI18n } from '../../../shared/i18n'
|
||||
|
||||
const props = defineProps<{ docId: string }>()
|
||||
|
||||
const emit = defineEmits<{ sectionFocus: [sectionRef: string] }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const query = ref('')
|
||||
const modelId = ref('')
|
||||
const running = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const result = ref<ReasoningResult | null>(null)
|
||||
const activeIteration = ref<number | null>(null)
|
||||
|
||||
const renderedAnswer = computed(() => {
|
||||
const raw = result.value?.answer ?? ''
|
||||
if (!raw.trim()) return ''
|
||||
return DOMPurify.sanitize(marked.parse(raw, { async: false }) as string)
|
||||
})
|
||||
|
||||
function toResolved(it: ReasoningIteration): ResolvedIteration {
|
||||
return {
|
||||
iteration: it.iteration,
|
||||
sectionRef: it.section_ref,
|
||||
nodeId: it.section_ref,
|
||||
present: true,
|
||||
reason: it.reason,
|
||||
canAnswer: it.can_answer,
|
||||
response: it.response,
|
||||
sectionTextLength: it.section_text_length,
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedIterations = computed<ResolvedIteration[]>(() =>
|
||||
(result.value?.iterations ?? []).map(toResolved),
|
||||
)
|
||||
|
||||
async function run(): Promise<void> {
|
||||
if (!query.value.trim()) return
|
||||
running.value = true
|
||||
error.value = null
|
||||
result.value = null
|
||||
activeIteration.value = null
|
||||
try {
|
||||
result.value = await runReasoning(props.docId, query.value, modelId.value || undefined)
|
||||
} catch (e) {
|
||||
error.value = (e as Error).message || t('reasoning.runErrUnknown')
|
||||
} finally {
|
||||
running.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onIterationFocus(n: number): void {
|
||||
activeIteration.value = n
|
||||
const it = resolvedIterations.value.find((r) => r.iteration === n)
|
||||
if (it?.sectionRef) emit('sectionFocus', it.sectionRef)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ask-runner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.ask-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ask-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.ask-textarea {
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 12px;
|
||||
resize: vertical;
|
||||
transition: border-color var(--transition);
|
||||
}
|
||||
|
||||
.ask-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.ask-textarea:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.ask-model-details {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ask-model-summary {
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
padding: 2px 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.ask-model-input {
|
||||
width: 100%;
|
||||
margin-top: 6px;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 6px 10px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.ask-model-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.ask-model-hint {
|
||||
margin: 4px 0 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.ask-submit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: opacity var(--transition);
|
||||
}
|
||||
|
||||
.ask-submit:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.ask-submit:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.ask-spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.4);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.ask-error {
|
||||
padding: 10px 12px;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid var(--error);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--error);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ask-result {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.ask-answer {
|
||||
background: var(--bg);
|
||||
border: 1px solid #ea580c;
|
||||
border-radius: var(--radius);
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
box-shadow: 0 1px 3px rgba(234, 88, 12, 0.08);
|
||||
}
|
||||
|
||||
.ask-answer-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ask-answer-label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.8px;
|
||||
text-transform: uppercase;
|
||||
color: #ea580c;
|
||||
}
|
||||
|
||||
.ask-converged {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.ask-converged.yes {
|
||||
background: rgba(22, 163, 74, 0.15);
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.ask-converged.no {
|
||||
background: rgba(234, 179, 8, 0.15);
|
||||
color: #a16207;
|
||||
}
|
||||
|
||||
.ask-answer-body {
|
||||
font-size: 13.5px;
|
||||
line-height: 1.6;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.ask-answer-body :deep(p) {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.ask-answer-body :deep(p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.ask-answer-body :deep(ol),
|
||||
.ask-answer-body :deep(ul) {
|
||||
margin: 4px 0 8px;
|
||||
padding-left: 22px;
|
||||
}
|
||||
|
||||
.ask-answer-body :deep(li) {
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
.ask-answer-body :deep(strong) {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ask-answer-body :deep(code) {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 12px;
|
||||
background: var(--border-light);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.ask-section-title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ask-iterations {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,44 +1,191 @@
|
|||
<template>
|
||||
<div class="ask-tab" data-e2e="ask-tab">
|
||||
<div class="coming-soon">
|
||||
<p class="coming-soon-title">{{ t('workspace.askComingSoon') }}</p>
|
||||
<p class="coming-soon-sub">{{ t('workspace.askComingSoonHint') }}</p>
|
||||
<!-- Loading analysis -->
|
||||
<div v-if="loading" class="ask-state">
|
||||
<span class="spinner" />
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-else-if="error" class="ask-state ask-state--error">
|
||||
<p>{{ error }}</p>
|
||||
<button class="retry-btn" @click="loadAnalysis">{{ t('inspect.retry') }}</button>
|
||||
</div>
|
||||
|
||||
<!-- No analysis -->
|
||||
<div v-else-if="pages.length === 0" class="ask-state">
|
||||
<p class="ask-empty-title">{{ t('ask.noAnalysis') }}</p>
|
||||
<p class="ask-empty-sub">{{ t('ask.noAnalysisSub') }}</p>
|
||||
<RouterLink :to="{ name: ROUTES.STUDIO }" class="ask-cta">
|
||||
{{ t('inspect.goToStudio') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<!-- Split layout: document left + ask panel right -->
|
||||
<template v-else>
|
||||
<div class="ask-doc-pane">
|
||||
<StructureViewer
|
||||
ref="structureViewerRef"
|
||||
:pages="pages"
|
||||
:document-id="docId"
|
||||
:visited-by-self-ref="visitedBySelfRef"
|
||||
:focused-self-ref="focusedSelfRef"
|
||||
selectable
|
||||
/>
|
||||
</div>
|
||||
<div class="ask-panel-pane">
|
||||
<AskRunner :doc-id="docId" @section-focus="onSectionFocus" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import type { Analysis, Page } from '../shared/types'
|
||||
import { fetchDocumentAnalyses } from '../features/analysis/api'
|
||||
import StructureViewer from '../features/analysis/ui/StructureViewer.vue'
|
||||
import AskRunner from '../features/reasoning/ui/AskRunner.vue'
|
||||
import { useI18n } from '../shared/i18n'
|
||||
import { ROUTES } from '../shared/routing/names'
|
||||
|
||||
defineProps<{ docId: string }>()
|
||||
const props = defineProps<{ docId: string }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const analysis = ref<Analysis | null>(null)
|
||||
const focusedSelfRef = ref<string | null>(null)
|
||||
const structureViewerRef = ref<InstanceType<typeof StructureViewer> | null>(null)
|
||||
|
||||
const pages = computed<Page[]>(() => {
|
||||
if (!analysis.value?.pagesJson) return []
|
||||
try {
|
||||
return JSON.parse(analysis.value.pagesJson) as Page[]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
const visitedBySelfRef = computed<Map<string, number>>(() => new Map())
|
||||
|
||||
async function loadAnalysis(): Promise<void> {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const analyses = await fetchDocumentAnalyses(props.docId)
|
||||
analysis.value = analyses.find((a) => a.status === 'COMPLETED') ?? null
|
||||
} catch (e) {
|
||||
error.value = (e as Error).message || 'Failed to load analysis'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onSectionFocus(sectionRef: string): void {
|
||||
if (focusedSelfRef.value === sectionRef) {
|
||||
structureViewerRef.value?.scrollToFocused(sectionRef)
|
||||
} else {
|
||||
focusedSelfRef.value = sectionRef
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadAnalysis)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ask-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.coming-soon {
|
||||
text-align: center;
|
||||
.ask-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
gap: 12px;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.coming-soon-title {
|
||||
.ask-state--error {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.ask-empty-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.coming-soon-sub {
|
||||
.ask-empty-sub {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ask-cta {
|
||||
font-size: 13px;
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
border: 1px solid var(--accent);
|
||||
padding: 6px 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
.ask-cta:hover {
|
||||
background: var(--accent-muted);
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
padding: 6px 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
.retry-btn:hover {
|
||||
border-color: var(--text-secondary);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.ask-doc-pane {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid var(--border);
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.ask-panel-pane {
|
||||
width: 360px;
|
||||
flex-shrink: 0;
|
||||
overflow-y: auto;
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 2px solid var(--border-light);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -372,6 +372,18 @@ const messages: Messages = {
|
|||
'inspect.noElements':
|
||||
'Aucun \u00e9l\u00e9ment \u2014 lancez une analyse pour g\u00e9n\u00e9rer la structure.',
|
||||
|
||||
// Ask tab (#242)
|
||||
'ask.questionLabel': 'Question',
|
||||
'ask.questionPlaceholder': 'Ex\u00a0: Quelles sont les obligations du fournisseur\u00a0?',
|
||||
'ask.modelConfig': 'Mod\u00e8le (optionnel)',
|
||||
'ask.modelPlaceholder': 'gpt-oss:20b',
|
||||
'ask.modelHint': 'Mod\u00e8le Ollama. Laisser vide pour le d\u00e9faut serveur.',
|
||||
'ask.run': 'Lancer',
|
||||
'ask.running': 'Reasoning en cours\u2026',
|
||||
'ask.answerLabel': 'R\u00e9ponse',
|
||||
'ask.noAnalysis': 'Aucune analyse disponible',
|
||||
'ask.noAnalysisSub': 'Analysez ce document dans le Studio avant de poser une question.',
|
||||
|
||||
// Doc workspace (#216, #218)
|
||||
'workspace.tabs.ask': 'Ask',
|
||||
'workspace.tabs.inspect': 'Inspect',
|
||||
|
|
@ -779,6 +791,18 @@ const messages: Messages = {
|
|||
'inspect.retry': 'Retry',
|
||||
'inspect.noElements': 'No elements — run an analysis to generate structure.',
|
||||
|
||||
// Ask tab (#242)
|
||||
'ask.questionLabel': 'Question',
|
||||
'ask.questionPlaceholder': 'e.g. What are the supplier obligations?',
|
||||
'ask.modelConfig': 'Model (optional)',
|
||||
'ask.modelPlaceholder': 'gpt-oss:20b',
|
||||
'ask.modelHint': 'Ollama model name. Leave empty to use the server default.',
|
||||
'ask.run': 'Run',
|
||||
'ask.running': 'Reasoning…',
|
||||
'ask.answerLabel': 'Answer',
|
||||
'ask.noAnalysis': 'No analysis available',
|
||||
'ask.noAnalysisSub': 'Analyze this document in Studio before asking a question.',
|
||||
|
||||
// Doc workspace (#216, #218)
|
||||
'workspace.tabs.ask': 'Ask',
|
||||
'workspace.tabs.inspect': 'Inspect',
|
||||
|
|
|
|||
Loading…
Reference in a new issue