Files
ttstd-infinite-canvas/backend/internal/app/cloud_agent_canvas_state.go

724 lines
24 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"encoding/json"
"fmt"
"strings"
"infinite-canvas/backend/internal/canvas/capability"
"infinite-canvas/backend/internal/repository"
)
type cloudAgentStructuredProjector func(value any, offset int, precise bool) (any, error)
var cloudAgentStructuredProjectors = map[string]cloudAgentStructuredProjector{
"storyboard": func(value any, offset int, precise bool) (any, error) {
storyboard, ok := value.(map[string]any)
if !ok {
return nil, nil
}
return cloudAgentStoryboardState(storyboard, offset, precise), nil
},
"batch_table": func(value any, offset int, precise bool) (any, error) {
table, ok := value.(map[string]any)
if !ok {
return nil, nil
}
return cloudAgentBatchTableState(table, offset, precise), nil
},
}
// cloudAgentNodeHash 是单个节点内容的版本号。分镜/批量表的读写只作用在一个节点上,
// 用它做并发校验:用户删改其它节点不会让 Agent 对这个节点的修改失效;
// 这个节点本身被改过(或被删掉)时才拒绝写入。
func cloudAgentNodeHash(doc map[string]any, nodeID string) string {
for _, node := range creationMaps(doc["nodes"]) {
if stringValue(node["id"]) == nodeID {
return creationHash(node)
}
}
return ""
}
// cloudAgentNodeSnapshotMatches 接受两种版本号:旧的整画布哈希(画布完全未变)或
// 目标节点哈希(只有目标节点未变)。前者兼容已经发出、带整画布哈希的调用。
func cloudAgentNodeSnapshotMatches(doc map[string]any, nodeID, snapshotHash string) bool {
if snapshotHash == "" {
return false
}
if snapshotHash == cloudAgentCanvasHash(doc) {
return true
}
node := cloudAgentNodeHash(doc, nodeID)
return node != "" && node == snapshotHash
}
// Viewport autosaves must not invalidate approved content; node edits still do.
func cloudAgentCanvasHash(doc map[string]any) string {
content := make(map[string]any, len(doc))
for key, value := range doc {
if key != "viewport" && key != "updatedAt" {
content[key] = value
}
}
return creationHash(content)
}
// Generation uses the graph, not canvas presentation or autosave bookkeeping.
// Keep all node business fields (including unknown metadata) fail-closed, and
// keep the full canvas hash for mutations/undo and the database CAS.
func cloudAgentMediaContentHash(doc map[string]any) string {
content := map[string]any{"connections": doc["connections"]}
nodes := creationMaps(doc["nodes"])
projected := make([]map[string]any, 0, len(nodes))
for _, node := range nodes {
item := make(map[string]any, len(node))
for key, value := range node {
switch key {
case "position", "width", "height", "createdAt", "updatedAt":
default:
item[key] = value
}
}
projected = append(projected, item)
}
content["nodes"] = projected
return cloudAgentCanvasHash(content)
}
const cloudAgentReadPageBytes = 64 << 10
// A full related-component read is useful for answering questions such as
// "what feeds this shot and what does it feed?", but it must remain bounded so
// a densely connected canvas cannot turn one tool call into an unbounded
// context dump.
const cloudAgentRelatedNodeLimit = 256
func cloudAgentCanvasState(repo *repository.Repository, userID, canvasID string, doc map[string]any, offset int, ids []string, storyboardOffset int, connectionOffsets ...int) (any, error) {
return cloudAgentCanvasStateSelected(repo, userID, canvasID, doc, offset, ids, nil, 0, false, storyboardOffset, connectionOffsets...)
}
// cloudAgentCanvasStateWithFocus returns a bounded graph neighborhood around
// the requested nodes. It is deliberately separate from the legacy paginated
// reader so existing callers keep their exact pagination semantics.
func cloudAgentCanvasStateWithFocus(repo *repository.Repository, userID, canvasID string, doc map[string]any, offset int, focusNodeIDs []string, depth, storyboardOffset int, connectionOffsets ...int) (any, error) {
return cloudAgentCanvasStateSelected(repo, userID, canvasID, doc, offset, nil, focusNodeIDs, depth, false, storyboardOffset, connectionOffsets...)
}
func cloudAgentCanvasStateWithRelated(repo *repository.Repository, userID, canvasID string, doc map[string]any, offset int, focusNodeIDs []string, storyboardOffset int, connectionOffsets ...int) (any, error) {
return cloudAgentCanvasStateSelected(repo, userID, canvasID, doc, offset, nil, focusNodeIDs, 0, true, storyboardOffset, connectionOffsets...)
}
func cloudAgentCanvasStateSelected(repo *repository.Repository, userID, canvasID string, doc map[string]any, offset int, ids, focusNodeIDs []string, depth int, includeRelated bool, storyboardOffset int, connectionOffsets ...int) (any, error) {
if offset < 0 || storyboardOffset < 0 || len(ids) > 8 || len(focusNodeIDs) > 8 || (len(ids) > 0 && len(focusNodeIDs) > 0) {
return nil, BadAuthRequest("画布读取分页参数无效")
}
if len(focusNodeIDs) > 0 && !includeRelated && (depth < 0 || depth > 3) {
return nil, BadAuthRequest("关联子图 depth 必须在 0 到 3 之间")
}
if includeRelated && len(focusNodeIDs) == 0 {
return nil, BadAuthRequest("includeRelated 只能与 focusNodeIds 一起使用")
}
all := creationMaps(doc["nodes"])
connectionOffset := 0
if len(connectionOffsets) > 0 {
connectionOffset = connectionOffsets[0]
}
if connectionOffset < 0 {
return nil, BadAuthRequest("连线分页参数无效")
}
wanted := map[string]bool{}
for _, id := range ids {
wanted[id] = true
}
focus := map[string]bool{}
for _, id := range focusNodeIDs {
if id == "" || focus[id] {
return nil, BadAuthRequest("关联子图 focusNodeIds 不能包含空值或重复节点")
}
focus[id] = true
}
nodeByID := make(map[string]map[string]any, len(all))
for _, node := range all {
if id := stringValue(node["id"]); id != "" {
nodeByID[id] = node
}
}
for id := range wanted {
if nodeByID[id] == nil {
return nil, BadAuthRequest("指定节点不在当前画布")
}
}
for id := range focus {
if nodeByID[id] == nil {
return nil, BadAuthRequest("关联子图 focusNodeIds 中存在不在当前画布的节点")
}
}
selected := map[string]bool{}
selectionTruncated := false
if len(focus) > 0 {
adjacency := make(map[string][]string, len(all))
for _, edge := range creationMaps(doc["connections"]) {
from, to := stringValue(edge["fromNodeId"]), stringValue(edge["toNodeId"])
if nodeByID[from] == nil || nodeByID[to] == nil {
continue
}
adjacency[from] = append(adjacency[from], to)
adjacency[to] = append(adjacency[to], from)
}
frontier := make([]string, 0, len(focus))
// Preserve the caller's focus order. This makes bounded results stable
// and keeps the selected node itself ahead of its neighbours.
for _, id := range focusNodeIDs {
selected[id] = true
frontier = append(frontier, id)
}
levels := depth
if includeRelated {
levels = len(all) + 1
}
for level := 0; level < levels && len(frontier) > 0; level++ {
next := make([]string, 0)
for _, id := range frontier {
for _, neighbor := range adjacency[id] {
if selected[neighbor] {
continue
}
if includeRelated && len(selected) >= cloudAgentRelatedNodeLimit {
selectionTruncated = true
continue
}
selected[neighbor] = true
next = append(next, neighbor)
}
}
frontier = next
}
}
selectedMode := len(focus) > 0
candidateNodes := make([]map[string]any, 0, len(all))
for _, node := range all {
id := stringValue(node["id"])
switch {
case selectedMode && selected[id]:
candidateNodes = append(candidateNodes, node)
case !selectedMode && len(ids) > 0 && wanted[id]:
candidateNodes = append(candidateNodes, node)
case !selectedMode && len(ids) == 0:
candidateNodes = append(candidateNodes, node)
}
}
limit := 320
if len(ids) > 0 || len(focus) > 0 {
limit = 16000
}
nodes := []any{}
included := map[string]bool{}
next := 0
pageBytes := 1024
for candidateIndex, node := range candidateNodes {
if candidateIndex < offset {
continue
}
id := stringValue(node["id"])
if len(ids) == 0 && len(focus) == 0 && len(nodes) == 40 {
next = candidateIndex
break
}
precise := len(ids) > 0 || focus[id]
meta, _ := node["metadata"].(map[string]any)
item := map[string]any{"id": id, "type": stringValue(node["type"])}
if title, ok := node["title"].(string); ok {
item["title"] = truncateRunes(title, 300)
}
if position, ok := node["position"].(map[string]any); ok {
safePosition := map[string]any{}
for _, axis := range []string{"x", "y"} {
if value, ok := cloudAgentSafeNumber(position[axis]); ok {
safePosition[axis] = value
}
}
item["position"] = safePosition
}
for _, dimension := range []string{"width", "height"} {
if value, ok := cloudAgentSafeNumber(node[dimension]); ok {
item[dimension] = value
}
}
if status, ok := meta["status"].(string); ok {
item["status"] = truncateRunes(status, 40)
}
capability, known := cloudAgentNodeCapabilityForNode(node)
if !known {
// Read visibility is not permission to mutate or use a node as a media reference.
item["agentSupported"] = false
item["agentUnsupportedReason"] = "仅展示基础信息;当前 Agent 不支持操作此类型节点"
body, _ := json.Marshal(item)
if pageBytes+len(body) > cloudAgentReadPageBytes-(8<<10) {
next = candidateIndex
break
}
pageBytes += len(body)
nodes = append(nodes, item)
included[id] = true
continue
}
if capability.Variant != nil {
// 变体节点(如角色卡)底层 type 仍是 text,用 kind 标明真实能力,避免当普通文本处理。
item["kind"] = capability.Type
}
fields := capability.SummaryFields
if precise {
fields = capability.DetailFields
}
projected, err := cloudAgentProjectNodeFields(node, meta, capability, fields, limit, precise, storyboardOffset)
if err != nil {
return nil, err
}
for key, value := range projected {
item[key] = value
}
if cloudAgentCharacterNode(node) {
if repo == nil {
item["character"] = map[string]any{"available": false, "issue": "角色资产读取服务不可用"}
} else {
canvas, canvasErr := repo.CanvasProjectForUser(userID, canvasID)
if canvasErr != nil {
return nil, canvasErr
}
character, characterErr := cloudAgentResolveCharacter(repo, userID, canvas.ProjectID, node)
if characterErr != nil {
item["character"] = map[string]any{"available": false, "issue": cloudAgentSafeToolError(characterErr)}
} else {
characterView := character.read(precise)
if precise {
_, referenceErr := cloudAgentCharacterImageReference(repo, userID, id, character)
characterView["imageReference"] = map[string]any{"ready": referenceErr == nil}
if referenceErr != nil {
characterView["imageReference"].(map[string]any)["issue"] = cloudAgentSafeToolError(referenceErr)
}
_, audioErr := cloudAgentCharacterAudioReference(repo, userID, id, character)
characterView["audioReference"] = map[string]any{"ready": audioErr == nil}
if audioErr != nil {
characterView["audioReference"].(map[string]any)["issue"] = cloudAgentSafeToolError(audioErr)
}
}
item["character"] = characterView
}
}
}
if capability.GenerationMode != "" {
generation := map[string]any{"taskStatus": "not_submitted"}
if reason, issue := cloudAgentMediaTargetIssue(node, capability.Type); reason != "" {
generation["submitBlockedReason"], generation["submitBlockedIssue"] = reason, issue
}
taskID := stringValue(meta["taskId"])
if taskID == "" {
taskID = stringValue(meta["generationTaskId"])
}
if taskID != "" {
// Do not infer success/failure from stale canvas metadata.
generation["taskStatus"] = "unavailable"
task, err := repo.TaskForUser(userID, taskID)
if err == nil && task.ProjectID == canvasID {
for key, value := range cloudAgentTaskDiagnostic(repo, task) {
generation[key] = value
}
}
}
item["generation"] = generation
}
if draftRunID := stringValue(meta["agentDraftRunId"]); draftRunID != "" && stringValue(meta["taskId"]) == "" && stringValue(meta["generationTaskId"]) == "" {
draft := map[string]any{"submitted": false, "requiresApproval": true, "ownerStatus": "unknown"}
owner, err := repo.CloudAgent(userID, draftRunID)
if err == nil && owner.CanvasID == canvasID {
draft["ownerStatus"] = owner.Status
draft["cleanupPending"] = owner.CleanupPending
}
item["generationDraft"] = draft
}
// 角色卡的引用可用性在 character.imageReference/audioReference 中给出。
if capability.Connection.CanReference && !cloudAgentCharacterNode(node) {
ref, _, err := cloudAgentReference(repo, userID, node)
outputReference := map[string]any{"ready": err == nil}
item["outputReference"] = outputReference
if err != nil {
outputReference["issue"] = cloudAgentSafeToolError(err)
} else {
// Provider references contain a storage key for task submission.
// The model only needs the verified public characteristics; never
// forward the provider payload or storage locator into the read tool.
item["asset"] = map[string]any{
"mimeType": ref["mimeType"], "bytes": ref["bytes"],
"width": ref["width"], "height": ref["height"],
"durationMs": ref["durationMs"], "inputKind": ref["inputKind"],
}
}
}
body, err := json.Marshal(item)
if err != nil {
return nil, err
}
if pageBytes+len(body) > cloudAgentReadPageBytes-(8<<10) {
if len(nodes) == 0 {
return nil, BadAuthRequest("节点详情超过单页读取预算,请使用节点对应的结构化分页工具")
}
next = candidateIndex
break
}
pageBytes += len(body)
nodes = append(nodes, item)
included[id] = true
}
edges := []any{}
nextConnection := 0
for index, edge := range creationMaps(doc["connections"]) {
if index < connectionOffset {
continue
}
fromIncluded := included[stringValue(edge["fromNodeId"])]
toIncluded := included[stringValue(edge["toNodeId"])]
if (selectedMode && fromIncluded && toIncluded) || (!selectedMode && (fromIncluded || toIncluded)) {
item := map[string]any{"id": edge["id"], "fromNodeId": edge["fromNodeId"], "toNodeId": edge["toNodeId"]}
body, _ := json.Marshal(item)
if pageBytes+len(body) > cloudAgentReadPageBytes {
nextConnection = index
break
}
pageBytes += len(body)
edges = append(edges, item)
}
}
result := map[string]any{"snapshotHash": cloudAgentCanvasHash(doc), "mediaSnapshotHash": cloudAgentMediaContentHash(doc), "nodes": nodes, "connections": edges, "totalNodes": len(all), "nextOffset": next, "hasMore": next > 0, "nextConnectionOffset": nextConnection, "hasMoreConnections": nextConnection > 0, "pageByteBudget": cloudAgentReadPageBytes}
if selectedMode {
result["totalSelectedNodes"] = len(candidateNodes)
selection := map[string]any{"mode": "subgraph", "focusNodeIds": focusNodeIDs, "selectedNodes": len(candidateNodes)}
if includeRelated {
selection["relationMode"] = "all"
selection["truncated"] = selectionTruncated
selection["nodeLimit"] = cloudAgentRelatedNodeLimit
} else {
selection["depth"] = depth
}
result["selection"] = selection
}
return result, nil
}
func cloudAgentSafeNumber(value any) (any, bool) {
switch number := value.(type) {
case float64, float32, int, int64:
return number, true
default:
return nil, false
}
}
// cloudAgentProjectNodeFields is the single projection path for both the
// initial run summary and canvas_get_state. Capability descriptors decide which
// fields exist; this function decides how those fields are safely represented.
// It deliberately never returns arbitrary metadata, URLs, storage keys or
// media payloads.
func cloudAgentProjectNodeFields(node, meta map[string]any, descriptor capability.Descriptor, fields []string, textLimit int, precise bool, structuredOffset int) (map[string]any, error) {
projected := map[string]any{}
for _, key := range fields {
if descriptor.ProjectionKind != "" && key == descriptor.ProjectionField {
projector, registered := cloudAgentStructuredProjectors[descriptor.ProjectionKind]
if !registered {
return nil, BadAuthRequest(fmt.Sprintf("节点 %s 的结构化读取能力未注册", descriptor.Label))
}
value, ok := cloudAgentProjectionValue(node, meta, descriptor.ProjectionField)
if !ok {
continue
}
structured, err := projector(value, structuredOffset, precise)
if err != nil {
return nil, BadAuthRequest(fmt.Sprintf("节点 %s 的结构化数据无法读取", descriptor.Label))
}
if structured != nil {
projected[key] = structured
}
continue
}
var value any
var ok bool
if key == "prompt" && descriptor.GenerationMode != "" {
value, ok = cloudAgentMediaPrompt(meta)
} else {
value, ok = node[key]
if !ok {
value, ok = meta[key]
}
}
if !ok || (key == "content" && descriptor.GenerationMode != "") {
continue
}
if safe, truncated := cloudAgentSafeProjection(value, textLimit); safe != nil {
projected[key] = safe
if truncated {
projected[key+"Truncated"] = true
}
}
}
return projected, nil
}
func cloudAgentMediaPrompt(meta map[string]any) (string, bool) {
if generationSpec, ok := meta["generationSpec"].(map[string]any); ok {
if prompt, ok := generationSpec["prompt"].(string); ok {
return prompt, true
}
}
if prompt, ok := meta["composerContent"].(string); ok {
return prompt, true
}
if prompt, ok := meta["prompt"].(string); ok {
return prompt, true
}
return "", false
}
func cloudAgentProjectionValue(node, meta map[string]any, path string) (any, bool) {
parts := strings.Split(path, ".")
for _, root := range []map[string]any{node, meta} {
var current any = root
found := true
for _, part := range parts {
object, ok := current.(map[string]any)
if !ok {
found = false
break
}
current, ok = object[part]
if !ok {
found = false
break
}
}
if found {
return current, true
}
}
return nil, false
}
func cloudAgentSafeProjection(value any, textLimit int) (any, bool) {
switch typed := value.(type) {
case string:
text := truncateRunes(typed, textLimit)
return text, len([]rune(typed)) > textLimit
case float64, float32, int, int64, bool:
return typed, false
case []string:
items := make([]string, 0, len(typed))
for _, item := range typed {
items = append(items, truncateRunes(item, min(textLimit, 200)))
}
return items, false
case []any:
items := make([]any, 0, min(len(typed), 32))
for _, item := range typed[:min(len(typed), 32)] {
safe, _ := cloudAgentSafeProjection(item, min(textLimit, 200))
if safe != nil {
items = append(items, safe)
}
}
return items, len(typed) > len(items)
default:
return nil, false
}
}
// Summaries locate a shot; a precise node read returns one full row at a time.
// Only narrative fields and canvas IDs are exposed, never arbitrary metadata.
func cloudAgentStoryboardState(storyboard map[string]any, offset int, precise bool) map[string]any {
all := creationMaps(storyboard["rows"])
count, textLimit := 20, 200
if precise {
count, textLimit = 1, 16000
}
rows := []any{}
next := 0
for i, row := range all {
if i < offset {
continue
}
if len(rows) == count {
next = i
break
}
item := map[string]any{}
fields := []string{"id", "shotNumber", "durationSeconds", "plotDescription", "imageNodeId", "videoNodeId"}
if precise {
fields = append(fields, "videoMotionPrompt", "imageGenerationPrompt", "dialogue", "narrativeIntent", "viewerPOV", "performanceBlocking", "shotSize", "emotion", "lightingAndAtmosphere", "audioEffects", "camera", "motion", "timeBeats", "mustHave", "optionalDetails", "continuityOut", "negativePrompt")
}
budget := 24000
for _, key := range fields {
switch value := row[key].(type) {
case string:
text := truncateRunes(value, min(textLimit, budget))
budget -= len([]rune(text))
item[key] = text
if text != value {
item[key+"Truncated"] = true
}
case float64:
item[key] = value
}
}
for collection, keys := range map[string][]string{
"assetBindings": {"nodeId", "role", "priority"},
"characters": {"characterName", "characterAssetId", "characterVersionId", "characterImageNodeId"},
} {
values := []any{}
entries := creationMaps(row[collection])
for _, entry := range entries[:min(len(entries), 16)] {
value := map[string]any{}
for _, key := range keys {
if text, ok := entry[key].(string); ok {
value[key] = truncateRunes(text, 200)
} else if number, ok := entry[key].(float64); ok {
value[key] = number
}
}
values = append(values, value)
}
item[collection] = values
item[collection+"Truncated"] = len(entries) > 16
}
rows = append(rows, item)
}
return map[string]any{"rows": rows, "totalRows": len(all), "nextOffset": next, "hasMore": next > 0}
}
// Batch-table projection exposes only the fields rendered by the component.
// Result URLs, task IDs, storage keys and arbitrary metadata remain private.
func cloudAgentBatchTableState(table map[string]any, offset int, precise bool) map[string]any {
operation := stringValue(table["operation"])
if operation != "creative" {
operation = "try_on"
}
concurrency := 10
if value, ok := cloudAgentInteger(table["concurrency"]); ok && (value == 1 || value == 5 || value == 10) {
concurrency = value
}
columns := []any{}
for index, column := range creationMaps(table["referenceColumns"])[:min(len(creationMaps(table["referenceColumns"])), 6)] {
id, label := truncateRunes(stringValue(column["id"]), 120), truncateRunes(stringValue(column["label"]), 120)
if id != "" && label != "" {
columns = append(columns, map[string]any{"id": id, "label": label, "mentionToken": fmt.Sprintf("@参考图%d", index+1)})
}
}
if len(columns) == 0 {
columns = defaultCloudAgentBatchReferenceColumns()
}
globalPrompt := strings.TrimSpace(stringValue(table["globalPrompt"]))
all := creationMaps(table["rows"])
count, textLimit := 20, 240
if precise {
count, textLimit = 20, 16000
}
rows := []any{}
next := 0
ready, enabled, missingPrompt, missingReferences, outputLinked := 0, 0, 0, 0, 0
for _, row := range all {
rowEnabled, _ := row["enabled"].(bool)
prompt := strings.TrimSpace(stringValue(row["prompt"]))
effectivePrompt := globalPrompt
if effectivePrompt == "" {
effectivePrompt = prompt
}
inputs := cloudAgentBatchInputIDs(row["inputNodeIds"], len(columns))
if rowEnabled {
enabled++
if effectivePrompt == "" {
missingPrompt++
}
minimumInputs := 1
if operation == "try_on" {
minimumInputs = 2
}
if len(inputs) < minimumInputs {
missingReferences++
}
if effectivePrompt != "" && len(inputs) >= minimumInputs {
ready++
}
}
if stringValue(row["outputNodeId"]) != "" {
outputLinked++
}
}
for index, row := range all {
if index < offset {
continue
}
if len(rows) == count {
next = index
break
}
item := map[string]any{
"id": truncateRunes(stringValue(row["id"]), 120),
"enabled": row["enabled"] == true,
"inputNodeIds": cloudAgentBatchInputIDs(row["inputNodeIds"], len(columns)),
}
prompt := stringValue(row["prompt"])
item["prompt"] = truncateRunes(prompt, textLimit)
if len([]rune(prompt)) > textLimit {
item["promptTruncated"] = true
}
if outputNodeID := truncateRunes(stringValue(row["outputNodeId"]), 120); outputNodeID != "" {
item["outputNodeId"] = outputNodeID
}
rows = append(rows, item)
}
projected := map[string]any{
"operation": operation, "concurrency": concurrency, "referenceColumns": columns,
"rows": rows, "totalRows": len(all), "nextOffset": next, "hasMore": next > 0,
"generationPreview": map[string]any{
"enabledRows": enabled, "readyRows": ready, "missingPromptRows": missingPrompt,
"missingReferenceRows": missingReferences, "outputLinkedRows": outputLinked,
},
}
if globalPrompt != "" {
projected["globalPrompt"] = truncateRunes(globalPrompt, textLimit)
if len([]rune(globalPrompt)) > textLimit {
projected["globalPromptTruncated"] = true
}
}
return projected
}
func cloudAgentBatchInputIDs(value any, limit int) []any {
if limit <= 0 || limit > 6 {
limit = 6
}
items, _ := value.([]any)
out := make([]any, 0, min(len(items), limit))
seen := map[string]bool{}
for _, item := range items {
id := truncateRunes(stringValue(item), 120)
if id == "" || seen[id] || len(out) == limit {
continue
}
seen[id] = true
out = append(out, id)
}
return out
}
func cloudAgentInteger(value any) (int, bool) {
switch number := value.(type) {
case int:
return number, true
case int64:
return int(number), int64(int(number)) == number
case float64:
integer := int(number)
return integer, float64(integer) == number
default:
return 0, false
}
}