mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-14 03:33:43 +08:00
214 lines
11 KiB
Vue
214 lines
11 KiB
Vue
<script setup lang="ts">
|
|
import { onBeforeUnmount, ref, watch } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { executionEvidenceApi, type ExecutionEvidence, type ArtifactJsonCheck } from '@/api/executionEvidence'
|
|
|
|
const props = defineProps<{ conversationId: string; goalId?: string; teamTaskId?: string }>()
|
|
const { t } = useI18n()
|
|
const expanded = ref(false)
|
|
const loaded = ref(false)
|
|
const loading = ref(false)
|
|
const errorKey = ref('')
|
|
const items = ref<ExecutionEvidence[]>([])
|
|
const nextCursor = ref<string | null>(null)
|
|
const detailLoading = ref<Record<string, boolean>>({})
|
|
const detailErrors = ref<Record<string, string>>({})
|
|
const checkFields = ref<Record<string, string>>({})
|
|
const checkLoading = ref<Record<string, boolean>>({})
|
|
const checkErrors = ref<Record<string, string>>({})
|
|
const checkResults = ref<Record<string, ArtifactJsonCheck>>({})
|
|
function clearChecks() {
|
|
checkFields.value = {}
|
|
checkLoading.value = {}
|
|
checkErrors.value = {}
|
|
checkResults.value = {}
|
|
}
|
|
let generation = 0
|
|
|
|
async function load(more = false) {
|
|
if (loading.value || !props.conversationId) return
|
|
const request = ++generation
|
|
detailLoading.value = {}
|
|
detailErrors.value = {}
|
|
clearChecks()
|
|
loading.value = true
|
|
errorKey.value = ''
|
|
try {
|
|
const { data } = await executionEvidenceApi.list({
|
|
conversationId: props.conversationId, goalId: props.goalId, teamTaskId: props.teamTaskId,
|
|
cursor: more ? nextCursor.value ?? undefined : undefined, limit: 20,
|
|
})
|
|
if (request !== generation) return
|
|
const merged = new Map((more ? items.value : []).map(item => [item.id, item]))
|
|
data.items.forEach(item => merged.set(item.id, item))
|
|
items.value = [...merged.values()]
|
|
nextCursor.value = data.nextCursor
|
|
loaded.value = true
|
|
} catch (error) {
|
|
if (request !== generation) return
|
|
const failure = error as { code?: number; response?: { status?: number } }
|
|
const code = failure.response?.status ?? failure.code
|
|
errorKey.value = code === 401 || code === 403 ? 'executionEvidence.accessError' : 'executionEvidence.loadError'
|
|
} finally {
|
|
if (request === generation) loading.value = false
|
|
}
|
|
}
|
|
async function loadDetail(id: string, event: Event) {
|
|
if (!(event.target as HTMLDetailsElement).open || detailLoading.value[id]) return
|
|
const request = generation
|
|
detailLoading.value[id] = true
|
|
delete detailErrors.value[id]
|
|
try {
|
|
const { data } = await executionEvidenceApi.get(id)
|
|
if (request !== generation) return
|
|
items.value = items.value.map(item => item.id === id ? data : item)
|
|
} catch (error) {
|
|
if (request !== generation) return
|
|
const failure = error as { code?: number; response?: { status?: number } }
|
|
const code = failure.response?.status ?? failure.code
|
|
if (code === 401 || code === 403 || code === 404) {
|
|
items.value = items.value.filter(item => item.id !== id)
|
|
errorKey.value = 'executionEvidence.accessError'
|
|
} else {
|
|
detailErrors.value[id] = 'executionEvidence.loadError'
|
|
}
|
|
} finally {
|
|
if (request === generation) delete detailLoading.value[id]
|
|
}
|
|
}
|
|
async function checkJson(id: string) {
|
|
if (checkLoading.value[id]) return
|
|
const fields = (checkFields.value[id] ?? '').split(/\r?\n/).filter(field => field.length > 0)
|
|
delete checkResults.value[id]
|
|
delete checkErrors.value[id]
|
|
if (!fields.length || fields.length > 16 || new Set(fields).size !== fields.length
|
|
|| fields.some(field => !field.trim() || field.length > 128 || /[\x00-\x1f\x7f]/.test(field))) {
|
|
checkErrors.value[id] = 'executionEvidence.jsonCheck.inputError'
|
|
return
|
|
}
|
|
const request = generation
|
|
checkLoading.value[id] = true
|
|
try {
|
|
const { data } = await executionEvidenceApi.checkJson(id, fields)
|
|
if (request !== generation || !items.value.some(item => item.id === id)) return
|
|
checkResults.value[id] = data
|
|
if (data.status === 'UNAVAILABLE') {
|
|
items.value = items.value.map(item => item.id === id
|
|
? { ...item, artifactRef: null, artifactDigest: null, summary: null, validity: 'UNAVAILABLE' } : item)
|
|
}
|
|
} catch (error) {
|
|
if (request !== generation) return
|
|
const failure = error as { code?: number; response?: { status?: number } }
|
|
const code = failure.response?.status ?? failure.code
|
|
if (code === 401 || code === 403 || code === 404) {
|
|
items.value = items.value.filter(item => item.id !== id)
|
|
delete checkFields.value[id]
|
|
errorKey.value = 'executionEvidence.accessError'
|
|
} else {
|
|
checkErrors.value[id] = code === 400 ? 'executionEvidence.jsonCheck.inputError' : 'executionEvidence.loadError'
|
|
}
|
|
} finally {
|
|
if (request === generation) delete checkLoading.value[id]
|
|
}
|
|
}
|
|
function toggle() {
|
|
expanded.value = !expanded.value
|
|
if (expanded.value && !loaded.value) void load()
|
|
}
|
|
watch(() => [props.conversationId, props.goalId, props.teamTaskId], () => {
|
|
generation++
|
|
items.value = []
|
|
detailLoading.value = {}
|
|
detailErrors.value = {}
|
|
clearChecks()
|
|
nextCursor.value = null
|
|
errorKey.value = ''
|
|
loaded.value = false
|
|
loading.value = false
|
|
if (expanded.value) void load()
|
|
})
|
|
onBeforeUnmount(() => { generation++ })
|
|
</script>
|
|
|
|
<template>
|
|
<section class="execution-evidence">
|
|
<button type="button" data-evidence-toggle :aria-expanded="expanded" @click="toggle">
|
|
{{ expanded ? '▾' : '▸' }} {{ t('executionEvidence.title') }}
|
|
</button>
|
|
<div v-if="expanded" class="execution-evidence__body" :aria-busy="loading">
|
|
<p class="execution-evidence__notice">{{ t('executionEvidence.observationOnly') }}</p>
|
|
<button type="button" :disabled="loading" @click="load()">{{ t('executionEvidence.refresh') }}</button>
|
|
<p v-if="errorKey" role="alert">{{ t(errorKey) }}</p>
|
|
<p v-if="loading" role="status">{{ t('common.loading') }}</p>
|
|
<p v-else-if="!errorKey && loaded && !items.length">{{ t('executionEvidence.empty') }}</p>
|
|
<ol class="execution-evidence__list">
|
|
<li v-for="item in items" :key="item.id" :data-evidence-item="item.id">
|
|
<strong>{{ item.toolName }}</strong>
|
|
<span class="execution-evidence__kind">{{ t(`executionEvidence.kind.${item.kind}`) }}</span>
|
|
<p v-if="item.summary" class="execution-evidence__summary">{{ item.summary }}</p>
|
|
<dl>
|
|
<div><dt>{{ t('executionEvidence.stateLabel') }}</dt><dd>{{ t(`executionEvidence.state.${item.state}`) }}</dd></div>
|
|
<div><dt>{{ t('executionEvidence.resultLabel') }}</dt><dd>{{ t(`executionEvidence.result.${item.result}`) }}</dd></div>
|
|
<div><dt>{{ t('executionEvidence.effectLabel') }}</dt><dd>{{ t(`executionEvidence.effect.${item.effectOutcome}`) }}</dd></div>
|
|
<div><dt>{{ t('executionEvidence.validityLabel') }}</dt><dd>{{ t(`executionEvidence.validity.${item.validity}`) }}</dd></div>
|
|
<div v-if="item.checkScope"><dt>{{ t('executionEvidence.scope') }}</dt><dd>{{ item.checkScope }}</dd></div>
|
|
<div><dt>{{ t('executionEvidence.source') }}</dt><dd>{{ item.sourceLevel }}</dd></div>
|
|
<div><dt>{{ t('executionEvidence.observedAt') }}</dt><dd><time :datetime="item.observedAt">{{ item.observedAt }}</time></dd></div>
|
|
<div v-if="item.expiresAt"><dt>{{ t('executionEvidence.expiresAt') }}</dt><dd><time :datetime="item.expiresAt">{{ item.expiresAt }}</time></dd></div>
|
|
</dl>
|
|
<details @toggle="loadDetail(item.id, $event)" :aria-busy="!!detailLoading[item.id]">
|
|
<summary>{{ t('executionEvidence.details') }}</summary>
|
|
<p v-if="detailLoading[item.id]" role="status">{{ t('common.loading') }}</p>
|
|
<p v-if="detailErrors[item.id]" role="alert">{{ t(detailErrors[item.id]) }}</p>
|
|
<dl>
|
|
<div><dt>{{ t('executionEvidence.id') }}</dt><dd>{{ item.id }}</dd></div>
|
|
<div><dt>{{ t('executionEvidence.attemptId') }}</dt><dd>{{ item.attemptId }}</dd></div>
|
|
<div v-if="item.artifactRef"><dt>{{ t('executionEvidence.artifactRef') }}</dt><dd>{{ item.artifactRef }}</dd></div>
|
|
<div v-if="item.artifactDigest"><dt>{{ t('executionEvidence.digest') }}</dt><dd>{{ item.artifactDigest }}</dd></div>
|
|
</dl>
|
|
<form v-if="item.kind === 'ARTIFACT_SNAPSHOT' && item.artifactRef" class="json-check" @submit.prevent="checkJson(item.id)">
|
|
<label :for="`json-fields-${item.id}`">{{ t('executionEvidence.jsonCheck.label') }}</label>
|
|
<textarea :id="`json-fields-${item.id}`" v-model="checkFields[item.id]" data-json-fields rows="3" maxlength="2064"
|
|
:disabled="!!checkLoading[item.id]" :placeholder="t('executionEvidence.jsonCheck.placeholder')"
|
|
@input="delete checkResults[item.id]" />
|
|
<p>{{ t('executionEvidence.jsonCheck.scope') }}</p>
|
|
<button type="submit" data-json-check :disabled="!!checkLoading[item.id]">{{ t(checkLoading[item.id] ? 'common.loading' : 'executionEvidence.jsonCheck.run') }}</button>
|
|
</form>
|
|
<p v-if="checkErrors[item.id]" role="alert">{{ t(checkErrors[item.id]) }}</p>
|
|
<div v-if="checkResults[item.id]" data-json-result role="status">
|
|
<p>{{ t(`executionEvidence.jsonCheck.status.${checkResults[item.id]!.status}`) }}</p>
|
|
<p v-if="checkResults[item.id]!.missingFields.length">{{ checkResults[item.id]!.missingFields.join(', ') }}</p>
|
|
<p>{{ t('executionEvidence.jsonCheck.limitation') }}</p>
|
|
<time :datetime="checkResults[item.id]!.checkedAt">{{ checkResults[item.id]!.checkedAt }}</time>
|
|
</div>
|
|
</details>
|
|
</li>
|
|
</ol>
|
|
<button v-if="nextCursor" type="button" data-evidence-more :disabled="loading" @click="load(true)">{{ t('executionEvidence.loadMore') }}</button>
|
|
</div>
|
|
</section>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.execution-evidence { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--mc-border-light); font-size: 12px; color: var(--mc-text-secondary); }
|
|
button { background: transparent; color: var(--mc-text-primary); border: 1px solid var(--mc-border-light); border-radius: 6px; padding: 6px 9px; cursor: pointer; font: inherit; }
|
|
button:disabled { opacity: .5; cursor: wait; }
|
|
button:focus-visible, summary:focus-visible { outline: 2px solid var(--mc-primary); outline-offset: 2px; }
|
|
.execution-evidence__notice { color: var(--mc-text-tertiary); line-height: 1.6; }
|
|
.execution-evidence__list { list-style: none; padding: 0; margin: 10px 0; display: grid; gap: 10px; }
|
|
li { padding: 10px; border: 1px solid var(--mc-border-light); border-radius: 8px; min-width: 0; overflow-wrap: anywhere; }
|
|
.execution-evidence__kind { display: block; color: var(--mc-text-tertiary); margin-top: 4px; }
|
|
.execution-evidence__summary { white-space: pre-wrap; }
|
|
dl { margin: 8px 0; display: grid; gap: 5px; }
|
|
dl > div { display: grid; grid-template-columns: minmax(75px, 1fr) minmax(0, 2fr); gap: 8px; }
|
|
dt { color: var(--mc-text-tertiary); }
|
|
dd { margin: 0; overflow-wrap: anywhere; }
|
|
summary { cursor: pointer; }
|
|
.json-check { display: grid; gap: 6px; margin-top: 12px; }
|
|
.json-check p { margin: 0; line-height: 1.5; }
|
|
.json-check textarea { width: 100%; box-sizing: border-box; resize: vertical; font: inherit; color: var(--mc-text-primary); background: transparent; border: 1px solid var(--mc-border-light); border-radius: 6px; padding: 6px; }
|
|
.json-check textarea:focus-visible { outline: 2px solid var(--mc-primary); }
|
|
.json-check button { justify-self: start; }
|
|
[role="alert"] { color: var(--mc-danger, #b53535); }
|
|
</style>
|