feat(#242): DocAskTab — Reasoning intégré au workspace (doc structure + question + trace cliquable)

This commit is contained in:
Pier-Jean Malandrino 2026-04-30 10:59:58 +02:00
parent 0062890830
commit 2718d86262
3 changed files with 542 additions and 12 deletions

View 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>

View file

@ -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>

View file

@ -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',