mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
fix(ui): preserve snowflake ID precision through form round-trips (#133)
This commit is contained in:
parent
38b1af11cd
commit
0800c03cd3
@ -6,9 +6,10 @@
|
||||
"description": "MateClaw - Personal AI Assistant Web Console",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "node --max-old-space-size=6144 ./node_modules/vue-tsc/bin/vue-tsc.js --noEmit && node --max-old-space-size=6144 ./node_modules/vite/bin/vite.js build",
|
||||
"build": "bash ../scripts/check-snowflake-precision.sh && node --max-old-space-size=6144 ./node_modules/vue-tsc/bin/vue-tsc.js --noEmit && node --max-old-space-size=6144 ./node_modules/vite/bin/vite.js build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint src --ext .ts,.vue --fix"
|
||||
"lint": "eslint src --ext .ts,.vue --fix && bash ../scripts/check-snowflake-precision.sh",
|
||||
"lint:precision": "bash ../scripts/check-snowflake-precision.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
|
||||
@ -559,7 +559,10 @@ export const settingsApi = {
|
||||
// unrelated settings pages can't clobber them via partial payloads. This
|
||||
// endpoint is the only path that writes those fields unconditionally —
|
||||
// pass {defaultVisionModelId: null} here to explicitly clear a sidecar.
|
||||
updateSidecar: (data: { defaultVisionModelId: number | null; defaultVideoModelId: number | null }) =>
|
||||
// Model IDs are accepted as either JSON numbers or strings — Jackson
|
||||
// coerces both into Long. The string form is preferred from the UI to
|
||||
// sidestep JS Number precision loss on 19-digit Snowflake IDs.
|
||||
updateSidecar: (data: { defaultVisionModelId: number | string | null; defaultVideoModelId: number | string | null }) =>
|
||||
http.put('/settings/sidecar', data),
|
||||
}
|
||||
|
||||
@ -1042,7 +1045,10 @@ export interface TriggerSummary {
|
||||
patternType: string
|
||||
patternJson: string
|
||||
targetType: string
|
||||
targetId: number
|
||||
// Snowflake ID — backend serializes Long as string (ToStringSerializer).
|
||||
// Keep the union so v-model can hold the string form without TS errors
|
||||
// and JS Number() coercion stays out of the round-trip.
|
||||
targetId: number | string
|
||||
payloadTemplate?: string
|
||||
rateLimitPerMin: number
|
||||
dedupWindowSecs: number
|
||||
|
||||
@ -406,10 +406,11 @@ function onAgentSelect(e: Event) {
|
||||
patch({ agentId: undefined, agentName: undefined })
|
||||
return
|
||||
}
|
||||
const id = Number(v)
|
||||
// Keep the id as the raw string from the option value — Snowflake IDs
|
||||
// exceed Number.MAX_SAFE_INTEGER, so Number(v) would silently truncate.
|
||||
const selected = props.availableAgents.find((a) => String(a.id) === v)
|
||||
patch({
|
||||
agentId: Number.isFinite(id) ? id : undefined,
|
||||
agentId: v,
|
||||
// Kept as a denormalized label for the canvas node; runtime resolves by agentId.
|
||||
agentName: selected?.name,
|
||||
})
|
||||
|
||||
@ -86,10 +86,14 @@
|
||||
<template v-else-if="patternType === 'agent_lifecycle'">
|
||||
<div class="pf-field">
|
||||
<span class="pf-label">{{ t('triggers.pattern.agentId') }}</span>
|
||||
<!-- type=text + inputmode=numeric so 19-digit Snowflake IDs aren't
|
||||
coerced through JS Number (input[type=number] silently truncates
|
||||
values past 2^53-1 even when v-model has no .number modifier). -->
|
||||
<input
|
||||
v-model.number="form.agentId"
|
||||
type="number"
|
||||
min="0"
|
||||
v-model.trim="form.agentId"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
pattern="\d*"
|
||||
class="pf-input"
|
||||
:placeholder="t('triggers.pattern.agentIdPlaceholder')"
|
||||
@input="emitFromForm"
|
||||
@ -113,7 +117,7 @@
|
||||
<template v-else-if="patternType === 'workflow_completion'">
|
||||
<div class="pf-field">
|
||||
<span class="pf-label">{{ t('triggers.pattern.sourceWorkflowId') }}</span>
|
||||
<select v-model.number="form.sourceWorkflowId" class="pf-input" @change="emitFromForm">
|
||||
<select v-model="form.sourceWorkflowId" class="pf-input" @change="emitFromForm">
|
||||
<option :value="undefined">{{ t('triggers.pattern.sourceWorkflowAny') }}</option>
|
||||
<option v-for="wf in availableWorkflows" :key="wf.id" :value="wf.id">
|
||||
#{{ wf.id }} — {{ wf.name || '(unnamed)' }}
|
||||
@ -190,9 +194,12 @@ interface FormState {
|
||||
senderEquals?: string
|
||||
contentContains?: string
|
||||
substring?: string
|
||||
agentId?: number | null
|
||||
// IDs are kept as strings to survive 19-digit Snowflake values through
|
||||
// v-model without precision loss; matcher's longOrNull accepts both
|
||||
// numeric and string forms in pattern_json.
|
||||
agentId?: string | null
|
||||
phase?: string
|
||||
sourceWorkflowId?: number
|
||||
sourceWorkflowId?: string | number
|
||||
stateFilter?: string
|
||||
}
|
||||
|
||||
@ -220,9 +227,15 @@ function loadFromJson(json: string) {
|
||||
if (typeof parsed.senderEquals === 'string') form.senderEquals = parsed.senderEquals
|
||||
if (typeof parsed.contentContains === 'string') form.contentContains = parsed.contentContains
|
||||
if (typeof parsed.substring === 'string') form.substring = parsed.substring
|
||||
if (typeof parsed.agentId === 'number') form.agentId = parsed.agentId
|
||||
// Accept both number and string — pattern_json may store IDs either way
|
||||
// depending on who wrote it (matcher's longOrNull accepts both too).
|
||||
if (typeof parsed.agentId === 'number' || typeof parsed.agentId === 'string') { // snowflake-precision-ok: explicit dual-form handling, then String()
|
||||
form.agentId = String(parsed.agentId)
|
||||
}
|
||||
if (typeof parsed.phase === 'string') form.phase = parsed.phase
|
||||
if (typeof parsed.sourceWorkflowId === 'number') form.sourceWorkflowId = parsed.sourceWorkflowId
|
||||
if (typeof parsed.sourceWorkflowId === 'number' || typeof parsed.sourceWorkflowId === 'string') { // snowflake-precision-ok: explicit dual-form handling, then String()
|
||||
form.sourceWorkflowId = String(parsed.sourceWorkflowId)
|
||||
}
|
||||
if (typeof parsed.stateFilter === 'string') form.stateFilter = parsed.stateFilter
|
||||
} catch (e) {
|
||||
rawError.value = (e as Error).message
|
||||
@ -247,14 +260,17 @@ function buildJsonFromForm(): string {
|
||||
if (form.substring) out.substring = form.substring
|
||||
break
|
||||
case 'agent_lifecycle':
|
||||
if (typeof form.agentId === 'number' && !Number.isNaN(form.agentId)) {
|
||||
out.agentId = form.agentId
|
||||
// Emit IDs as JSON strings to preserve Snowflake precision. The
|
||||
// matcher's longOrNull parses either form, so this stays compatible
|
||||
// with any pattern_json written previously as a JSON number.
|
||||
if (form.agentId != null && String(form.agentId).trim() !== '') {
|
||||
out.agentId = String(form.agentId).trim()
|
||||
}
|
||||
if (form.phase) out.phase = form.phase
|
||||
break
|
||||
case 'workflow_completion':
|
||||
if (typeof form.sourceWorkflowId === 'number') {
|
||||
out.sourceWorkflowId = form.sourceWorkflowId
|
||||
if (form.sourceWorkflowId != null && String(form.sourceWorkflowId) !== '') {
|
||||
out.sourceWorkflowId = String(form.sourceWorkflowId)
|
||||
}
|
||||
if (form.stateFilter) out.stateFilter = form.stateFilter
|
||||
break
|
||||
|
||||
@ -227,11 +227,12 @@ export function useStream(options: UseStreamOptions): UseStreamReturn {
|
||||
return
|
||||
}
|
||||
seenEventIds.add(event.id)
|
||||
// Track highest id for reconnect Last-Event-ID echo. String compare is
|
||||
// fine because ids are zero-padded server-side... actually they're plain
|
||||
// numbers, so coerce to BigInt-safe numeric compare.
|
||||
const incoming = Number(event.id)
|
||||
const current = lastEventId.value === null ? -1 : Number(lastEventId.value)
|
||||
// Track highest id for reconnect Last-Event-ID echo. SSE event ids are
|
||||
// per-conversation sequential counters issued by ChatStreamTracker — not
|
||||
// Snowflake — so coercing through Number is safe within JS's 2^53 ceiling
|
||||
// (a single conversation would need 9 quadrillion events to overflow).
|
||||
const incoming = Number(event.id) // snowflake-precision-ok: SSE sequence counter
|
||||
const current = lastEventId.value === null ? -1 : Number(lastEventId.value) // snowflake-precision-ok: SSE sequence counter
|
||||
if (!Number.isNaN(incoming) && incoming > current) {
|
||||
lastEventId.value = event.id
|
||||
}
|
||||
@ -305,7 +306,7 @@ export function useStream(options: UseStreamOptions): UseStreamReturn {
|
||||
// switch. We only inject when the dedup state is still relevant
|
||||
// (sameConv) AND we actually have an id to echo.
|
||||
if (sameConv && lastEventId.value !== null && body && body.reconnect) {
|
||||
const numericId = Number(lastEventId.value)
|
||||
const numericId = Number(lastEventId.value) // snowflake-precision-ok: SSE sequence counter
|
||||
if (!Number.isNaN(numericId)) {
|
||||
body = { ...body, lastEventId: numericId }
|
||||
}
|
||||
|
||||
@ -28,7 +28,10 @@ import dagre from 'dagre'
|
||||
|
||||
export interface RawStep {
|
||||
name?: string
|
||||
agentId?: number
|
||||
// Snowflake IDs lose precision when round-tripped through JS Number, so
|
||||
// we treat agentId as opaque (string preferred, number tolerated for
|
||||
// legacy JSON written before the string-id convention).
|
||||
agentId?: string | number
|
||||
agentName?: string
|
||||
mode?: { type?: string; expression?: string; [k: string]: unknown }
|
||||
promptTemplate?: string
|
||||
@ -45,7 +48,7 @@ export interface StepNodeData {
|
||||
index: number
|
||||
name: string
|
||||
modeType: string
|
||||
agentId?: number
|
||||
agentId?: string | number
|
||||
agentName?: string
|
||||
promptTemplate?: string
|
||||
expression?: string
|
||||
@ -102,7 +105,9 @@ export function buildGraph(json: string): { nodes: Node<StepNodeData>[]; edges:
|
||||
index: idx,
|
||||
name: step?.name?.trim() || fallbackName(idx),
|
||||
modeType,
|
||||
agentId: typeof step?.agentId === 'number' ? step.agentId : undefined,
|
||||
// Accept both string (post-fix, Snowflake-safe) and number (legacy)
|
||||
// forms; the canvas only uses this for display, so we don't normalize.
|
||||
agentId: step?.agentId != null && step.agentId !== '' ? step.agentId : undefined,
|
||||
agentName: step?.agentName,
|
||||
promptTemplate: step?.promptTemplate,
|
||||
expression: typeof step?.mode?.expression === 'string' ? step.mode!.expression : undefined,
|
||||
|
||||
@ -143,7 +143,7 @@ async function loadAll() {
|
||||
}
|
||||
}
|
||||
|
||||
async function persistSettings(payload: { defaultVisionModelId: number | null; defaultVideoModelId: number | null }) {
|
||||
async function persistSettings(payload: { defaultVisionModelId: number | string | null; defaultVideoModelId: number | string | null }) {
|
||||
// Use the dedicated sidecar endpoint so the bulk /settings PUT can keep
|
||||
// guarding vision/video keys with non-null checks (preventing unrelated
|
||||
// settings pages from clobbering this configuration via partial payloads).
|
||||
@ -159,9 +159,12 @@ async function onSaveVision() {
|
||||
// Send vision's pending value plus the *initial* (last-saved) video value
|
||||
// so saving vision never accidentally clears a video selection the user
|
||||
// may have edited but not yet committed in that card.
|
||||
// Send IDs as the raw string from the model picker. Number() would
|
||||
// truncate 19-digit Snowflake IDs past Number.MAX_SAFE_INTEGER; Jackson
|
||||
// on the backend coerces the string back to Long with full precision.
|
||||
await persistSettings({
|
||||
defaultVisionModelId: visionModelId.value ? Number(visionModelId.value) : null,
|
||||
defaultVideoModelId: initialVideo.value ? Number(initialVideo.value) : null,
|
||||
defaultVisionModelId: visionModelId.value ? String(visionModelId.value) : null,
|
||||
defaultVideoModelId: initialVideo.value ? String(initialVideo.value) : null,
|
||||
})
|
||||
initialVision.value = visionModelId.value
|
||||
savedTip.value = 'vision'
|
||||
@ -178,8 +181,8 @@ async function onSaveVideo() {
|
||||
savedTip.value = null
|
||||
try {
|
||||
await persistSettings({
|
||||
defaultVisionModelId: initialVision.value ? Number(initialVision.value) : null,
|
||||
defaultVideoModelId: videoModelId.value ? Number(videoModelId.value) : null,
|
||||
defaultVisionModelId: initialVision.value ? String(initialVision.value) : null,
|
||||
defaultVideoModelId: videoModelId.value ? String(videoModelId.value) : null,
|
||||
})
|
||||
initialVideo.value = videoModelId.value
|
||||
savedTip.value = 'video'
|
||||
|
||||
@ -118,7 +118,10 @@
|
||||
</select>
|
||||
</label>
|
||||
<label>{{ t('triggers.fields.targetId') }}
|
||||
<select v-model.number="formState.targetId">
|
||||
<!-- Keep targetId as a string so 19-digit Snowflake IDs survive
|
||||
the v-model round trip; .number would truncate to JS's
|
||||
53-bit safe-integer ceiling. -->
|
||||
<select v-model="formState.targetId">
|
||||
<option v-if="!availableWorkflows.length" :value="0">
|
||||
{{ t('triggers.targetWorkflowEmpty') }}
|
||||
</option>
|
||||
@ -208,7 +211,11 @@ interface FormState {
|
||||
patternType: string
|
||||
patternJson: string
|
||||
targetType: string
|
||||
targetId: number
|
||||
// Snowflake IDs arrive from the backend as strings (ToStringSerializer).
|
||||
// Keep that string form here so v-model on the workflow select can't
|
||||
// accidentally lose precision. The 0 sentinel covers the "no workflow
|
||||
// available" empty state.
|
||||
targetId: number | string
|
||||
rateLimitPerMin: number
|
||||
dedupWindowSecs: number
|
||||
botSelfFilter: boolean
|
||||
|
||||
@ -196,7 +196,10 @@ async function saveEmbeddingBinding() {
|
||||
embeddingModelId: embeddingModelId.value === '' ? null : embeddingModelId.value,
|
||||
})
|
||||
const kb: any = store.currentKB
|
||||
kb.embeddingModelId = embeddingModelId.value === '' ? null : Number(embeddingModelId.value)
|
||||
// Mirror the persisted value to the in-memory KB without going through
|
||||
// Number() — Snowflake model IDs would otherwise lose their last digits
|
||||
// (Number.MAX_SAFE_INTEGER = 2^53-1, IDs are 19 digits).
|
||||
kb.embeddingModelId = embeddingModelId.value === '' ? null : embeddingModelId.value
|
||||
} catch (e) {
|
||||
console.error('[WikiConfig] Failed to save embedding binding', e)
|
||||
} finally {
|
||||
@ -262,9 +265,13 @@ async function saveStepModelsAndClose() {
|
||||
if (!store.currentKB) return
|
||||
savingStepModels.value = true
|
||||
try {
|
||||
const stepMap: Record<string, number> = {}
|
||||
// Keep model IDs as strings end-to-end. Snowflake IDs exceed
|
||||
// Number.MAX_SAFE_INTEGER, so Number() / .map(Number) would silently
|
||||
// corrupt the last few digits before serializing to configContent JSON.
|
||||
// Backend parses configContent leniently — string-typed IDs are fine.
|
||||
const stepMap: Record<string, string> = {}
|
||||
for (const key of stepKeys) {
|
||||
if (stepModels[key]) stepMap[`heavy_ingest.${key}`] = Number(stepModels[key])
|
||||
if (stepModels[key]) stepMap[`heavy_ingest.${key}`] = stepModels[key]
|
||||
}
|
||||
let existingConfig: any = {}
|
||||
try {
|
||||
@ -272,9 +279,9 @@ async function saveStepModelsAndClose() {
|
||||
} catch { /* not JSON */ }
|
||||
existingConfig.stepModels = Object.keys(stepMap).length > 0 ? stepMap : undefined
|
||||
existingConfig.fallbackModelIds = fallbackModelIds.value.length > 0
|
||||
? fallbackModelIds.value.map(Number) : undefined
|
||||
? [...fallbackModelIds.value] : undefined
|
||||
existingConfig.wikiDefaultModelId = wikiGlobalModelId.value
|
||||
? Number(wikiGlobalModelId.value) : undefined
|
||||
? wikiGlobalModelId.value : undefined
|
||||
await wikiApi.updateConfig(store.currentKB.id, JSON.stringify(existingConfig, null, 2))
|
||||
modelsOpen.value = false
|
||||
} catch (e) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user