fix(ui): preserve snowflake ID precision through form round-trips (#133)

This commit is contained in:
matevip 2026-05-15 15:51:33 +08:00
parent 38b1af11cd
commit 0800c03cd3
9 changed files with 86 additions and 39 deletions

View File

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

View File

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

View File

@ -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,
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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