mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 20:08:18 +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",
|
"description": "MateClaw - Personal AI Assistant Web Console",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"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",
|
"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": {
|
"dependencies": {
|
||||||
"@element-plus/icons-vue": "^2.3.1",
|
"@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
|
// unrelated settings pages can't clobber them via partial payloads. This
|
||||||
// endpoint is the only path that writes those fields unconditionally —
|
// endpoint is the only path that writes those fields unconditionally —
|
||||||
// pass {defaultVisionModelId: null} here to explicitly clear a sidecar.
|
// 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),
|
http.put('/settings/sidecar', data),
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1042,7 +1045,10 @@ export interface TriggerSummary {
|
|||||||
patternType: string
|
patternType: string
|
||||||
patternJson: string
|
patternJson: string
|
||||||
targetType: 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
|
payloadTemplate?: string
|
||||||
rateLimitPerMin: number
|
rateLimitPerMin: number
|
||||||
dedupWindowSecs: number
|
dedupWindowSecs: number
|
||||||
|
|||||||
@ -406,10 +406,11 @@ function onAgentSelect(e: Event) {
|
|||||||
patch({ agentId: undefined, agentName: undefined })
|
patch({ agentId: undefined, agentName: undefined })
|
||||||
return
|
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)
|
const selected = props.availableAgents.find((a) => String(a.id) === v)
|
||||||
patch({
|
patch({
|
||||||
agentId: Number.isFinite(id) ? id : undefined,
|
agentId: v,
|
||||||
// Kept as a denormalized label for the canvas node; runtime resolves by agentId.
|
// Kept as a denormalized label for the canvas node; runtime resolves by agentId.
|
||||||
agentName: selected?.name,
|
agentName: selected?.name,
|
||||||
})
|
})
|
||||||
|
|||||||
@ -86,10 +86,14 @@
|
|||||||
<template v-else-if="patternType === 'agent_lifecycle'">
|
<template v-else-if="patternType === 'agent_lifecycle'">
|
||||||
<div class="pf-field">
|
<div class="pf-field">
|
||||||
<span class="pf-label">{{ t('triggers.pattern.agentId') }}</span>
|
<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
|
<input
|
||||||
v-model.number="form.agentId"
|
v-model.trim="form.agentId"
|
||||||
type="number"
|
type="text"
|
||||||
min="0"
|
inputmode="numeric"
|
||||||
|
pattern="\d*"
|
||||||
class="pf-input"
|
class="pf-input"
|
||||||
:placeholder="t('triggers.pattern.agentIdPlaceholder')"
|
:placeholder="t('triggers.pattern.agentIdPlaceholder')"
|
||||||
@input="emitFromForm"
|
@input="emitFromForm"
|
||||||
@ -113,7 +117,7 @@
|
|||||||
<template v-else-if="patternType === 'workflow_completion'">
|
<template v-else-if="patternType === 'workflow_completion'">
|
||||||
<div class="pf-field">
|
<div class="pf-field">
|
||||||
<span class="pf-label">{{ t('triggers.pattern.sourceWorkflowId') }}</span>
|
<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 :value="undefined">{{ t('triggers.pattern.sourceWorkflowAny') }}</option>
|
||||||
<option v-for="wf in availableWorkflows" :key="wf.id" :value="wf.id">
|
<option v-for="wf in availableWorkflows" :key="wf.id" :value="wf.id">
|
||||||
#{{ wf.id }} — {{ wf.name || '(unnamed)' }}
|
#{{ wf.id }} — {{ wf.name || '(unnamed)' }}
|
||||||
@ -190,9 +194,12 @@ interface FormState {
|
|||||||
senderEquals?: string
|
senderEquals?: string
|
||||||
contentContains?: string
|
contentContains?: string
|
||||||
substring?: 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
|
phase?: string
|
||||||
sourceWorkflowId?: number
|
sourceWorkflowId?: string | number
|
||||||
stateFilter?: string
|
stateFilter?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -220,9 +227,15 @@ function loadFromJson(json: string) {
|
|||||||
if (typeof parsed.senderEquals === 'string') form.senderEquals = parsed.senderEquals
|
if (typeof parsed.senderEquals === 'string') form.senderEquals = parsed.senderEquals
|
||||||
if (typeof parsed.contentContains === 'string') form.contentContains = parsed.contentContains
|
if (typeof parsed.contentContains === 'string') form.contentContains = parsed.contentContains
|
||||||
if (typeof parsed.substring === 'string') form.substring = parsed.substring
|
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.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
|
if (typeof parsed.stateFilter === 'string') form.stateFilter = parsed.stateFilter
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
rawError.value = (e as Error).message
|
rawError.value = (e as Error).message
|
||||||
@ -247,14 +260,17 @@ function buildJsonFromForm(): string {
|
|||||||
if (form.substring) out.substring = form.substring
|
if (form.substring) out.substring = form.substring
|
||||||
break
|
break
|
||||||
case 'agent_lifecycle':
|
case 'agent_lifecycle':
|
||||||
if (typeof form.agentId === 'number' && !Number.isNaN(form.agentId)) {
|
// Emit IDs as JSON strings to preserve Snowflake precision. The
|
||||||
out.agentId = form.agentId
|
// 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
|
if (form.phase) out.phase = form.phase
|
||||||
break
|
break
|
||||||
case 'workflow_completion':
|
case 'workflow_completion':
|
||||||
if (typeof form.sourceWorkflowId === 'number') {
|
if (form.sourceWorkflowId != null && String(form.sourceWorkflowId) !== '') {
|
||||||
out.sourceWorkflowId = form.sourceWorkflowId
|
out.sourceWorkflowId = String(form.sourceWorkflowId)
|
||||||
}
|
}
|
||||||
if (form.stateFilter) out.stateFilter = form.stateFilter
|
if (form.stateFilter) out.stateFilter = form.stateFilter
|
||||||
break
|
break
|
||||||
|
|||||||
@ -227,11 +227,12 @@ export function useStream(options: UseStreamOptions): UseStreamReturn {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
seenEventIds.add(event.id)
|
seenEventIds.add(event.id)
|
||||||
// Track highest id for reconnect Last-Event-ID echo. String compare is
|
// Track highest id for reconnect Last-Event-ID echo. SSE event ids are
|
||||||
// fine because ids are zero-padded server-side... actually they're plain
|
// per-conversation sequential counters issued by ChatStreamTracker — not
|
||||||
// numbers, so coerce to BigInt-safe numeric compare.
|
// Snowflake — so coercing through Number is safe within JS's 2^53 ceiling
|
||||||
const incoming = Number(event.id)
|
// (a single conversation would need 9 quadrillion events to overflow).
|
||||||
const current = lastEventId.value === null ? -1 : Number(lastEventId.value)
|
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) {
|
if (!Number.isNaN(incoming) && incoming > current) {
|
||||||
lastEventId.value = event.id
|
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
|
// switch. We only inject when the dedup state is still relevant
|
||||||
// (sameConv) AND we actually have an id to echo.
|
// (sameConv) AND we actually have an id to echo.
|
||||||
if (sameConv && lastEventId.value !== null && body && body.reconnect) {
|
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)) {
|
if (!Number.isNaN(numericId)) {
|
||||||
body = { ...body, lastEventId: numericId }
|
body = { ...body, lastEventId: numericId }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,7 +28,10 @@ import dagre from 'dagre'
|
|||||||
|
|
||||||
export interface RawStep {
|
export interface RawStep {
|
||||||
name?: string
|
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
|
agentName?: string
|
||||||
mode?: { type?: string; expression?: string; [k: string]: unknown }
|
mode?: { type?: string; expression?: string; [k: string]: unknown }
|
||||||
promptTemplate?: string
|
promptTemplate?: string
|
||||||
@ -45,7 +48,7 @@ export interface StepNodeData {
|
|||||||
index: number
|
index: number
|
||||||
name: string
|
name: string
|
||||||
modeType: string
|
modeType: string
|
||||||
agentId?: number
|
agentId?: string | number
|
||||||
agentName?: string
|
agentName?: string
|
||||||
promptTemplate?: string
|
promptTemplate?: string
|
||||||
expression?: string
|
expression?: string
|
||||||
@ -102,7 +105,9 @@ export function buildGraph(json: string): { nodes: Node<StepNodeData>[]; edges:
|
|||||||
index: idx,
|
index: idx,
|
||||||
name: step?.name?.trim() || fallbackName(idx),
|
name: step?.name?.trim() || fallbackName(idx),
|
||||||
modeType,
|
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,
|
agentName: step?.agentName,
|
||||||
promptTemplate: step?.promptTemplate,
|
promptTemplate: step?.promptTemplate,
|
||||||
expression: typeof step?.mode?.expression === 'string' ? step.mode!.expression : undefined,
|
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
|
// Use the dedicated sidecar endpoint so the bulk /settings PUT can keep
|
||||||
// guarding vision/video keys with non-null checks (preventing unrelated
|
// guarding vision/video keys with non-null checks (preventing unrelated
|
||||||
// settings pages from clobbering this configuration via partial payloads).
|
// 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
|
// Send vision's pending value plus the *initial* (last-saved) video value
|
||||||
// so saving vision never accidentally clears a video selection the user
|
// so saving vision never accidentally clears a video selection the user
|
||||||
// may have edited but not yet committed in that card.
|
// 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({
|
await persistSettings({
|
||||||
defaultVisionModelId: visionModelId.value ? Number(visionModelId.value) : null,
|
defaultVisionModelId: visionModelId.value ? String(visionModelId.value) : null,
|
||||||
defaultVideoModelId: initialVideo.value ? Number(initialVideo.value) : null,
|
defaultVideoModelId: initialVideo.value ? String(initialVideo.value) : null,
|
||||||
})
|
})
|
||||||
initialVision.value = visionModelId.value
|
initialVision.value = visionModelId.value
|
||||||
savedTip.value = 'vision'
|
savedTip.value = 'vision'
|
||||||
@ -178,8 +181,8 @@ async function onSaveVideo() {
|
|||||||
savedTip.value = null
|
savedTip.value = null
|
||||||
try {
|
try {
|
||||||
await persistSettings({
|
await persistSettings({
|
||||||
defaultVisionModelId: initialVision.value ? Number(initialVision.value) : null,
|
defaultVisionModelId: initialVision.value ? String(initialVision.value) : null,
|
||||||
defaultVideoModelId: videoModelId.value ? Number(videoModelId.value) : null,
|
defaultVideoModelId: videoModelId.value ? String(videoModelId.value) : null,
|
||||||
})
|
})
|
||||||
initialVideo.value = videoModelId.value
|
initialVideo.value = videoModelId.value
|
||||||
savedTip.value = 'video'
|
savedTip.value = 'video'
|
||||||
|
|||||||
@ -118,7 +118,10 @@
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>{{ t('triggers.fields.targetId') }}
|
<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">
|
<option v-if="!availableWorkflows.length" :value="0">
|
||||||
{{ t('triggers.targetWorkflowEmpty') }}
|
{{ t('triggers.targetWorkflowEmpty') }}
|
||||||
</option>
|
</option>
|
||||||
@ -208,7 +211,11 @@ interface FormState {
|
|||||||
patternType: string
|
patternType: string
|
||||||
patternJson: string
|
patternJson: string
|
||||||
targetType: 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
|
rateLimitPerMin: number
|
||||||
dedupWindowSecs: number
|
dedupWindowSecs: number
|
||||||
botSelfFilter: boolean
|
botSelfFilter: boolean
|
||||||
|
|||||||
@ -196,7 +196,10 @@ async function saveEmbeddingBinding() {
|
|||||||
embeddingModelId: embeddingModelId.value === '' ? null : embeddingModelId.value,
|
embeddingModelId: embeddingModelId.value === '' ? null : embeddingModelId.value,
|
||||||
})
|
})
|
||||||
const kb: any = store.currentKB
|
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) {
|
} catch (e) {
|
||||||
console.error('[WikiConfig] Failed to save embedding binding', e)
|
console.error('[WikiConfig] Failed to save embedding binding', e)
|
||||||
} finally {
|
} finally {
|
||||||
@ -262,9 +265,13 @@ async function saveStepModelsAndClose() {
|
|||||||
if (!store.currentKB) return
|
if (!store.currentKB) return
|
||||||
savingStepModels.value = true
|
savingStepModels.value = true
|
||||||
try {
|
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) {
|
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 = {}
|
let existingConfig: any = {}
|
||||||
try {
|
try {
|
||||||
@ -272,9 +279,9 @@ async function saveStepModelsAndClose() {
|
|||||||
} catch { /* not JSON */ }
|
} catch { /* not JSON */ }
|
||||||
existingConfig.stepModels = Object.keys(stepMap).length > 0 ? stepMap : undefined
|
existingConfig.stepModels = Object.keys(stepMap).length > 0 ? stepMap : undefined
|
||||||
existingConfig.fallbackModelIds = fallbackModelIds.value.length > 0
|
existingConfig.fallbackModelIds = fallbackModelIds.value.length > 0
|
||||||
? fallbackModelIds.value.map(Number) : undefined
|
? [...fallbackModelIds.value] : undefined
|
||||||
existingConfig.wikiDefaultModelId = wikiGlobalModelId.value
|
existingConfig.wikiDefaultModelId = wikiGlobalModelId.value
|
||||||
? Number(wikiGlobalModelId.value) : undefined
|
? wikiGlobalModelId.value : undefined
|
||||||
await wikiApi.updateConfig(store.currentKB.id, JSON.stringify(existingConfig, null, 2))
|
await wikiApi.updateConfig(store.currentKB.id, JSON.stringify(existingConfig, null, 2))
|
||||||
modelsOpen.value = false
|
modelsOpen.value = false
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user