vite-plugin-agentation-vue 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +27 -0
- package/README.md +116 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +23 -0
- package/dist/index.js.map +1 -0
- package/dist/plugin.d.ts +28 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +155 -0
- package/dist/plugin.js.map +1 -0
- package/dist/runtime/bootstrap.d.ts +27 -0
- package/dist/runtime/bootstrap.d.ts.map +1 -0
- package/dist/runtime/bootstrap.js +462 -0
- package/dist/runtime/bootstrap.js.map +1 -0
- package/dist/runtime/entry.d.ts +13 -0
- package/dist/runtime/entry.d.ts.map +1 -0
- package/dist/runtime/entry.js +110 -0
- package/dist/runtime/entry.js.map +1 -0
- package/dist/runtime/resolver/index.d.ts +4 -0
- package/dist/runtime/resolver/index.d.ts.map +1 -0
- package/dist/runtime/resolver/index.js +4 -0
- package/dist/runtime/resolver/index.js.map +1 -0
- package/dist/runtime/resolver/resolve-source.d.ts +21 -0
- package/dist/runtime/resolver/resolve-source.d.ts.map +1 -0
- package/dist/runtime/resolver/resolve-source.js +46 -0
- package/dist/runtime/resolver/resolve-source.js.map +1 -0
- package/dist/runtime/resolver/tracer-adapter.d.ts +9 -0
- package/dist/runtime/resolver/tracer-adapter.d.ts.map +1 -0
- package/dist/runtime/resolver/tracer-adapter.js +84 -0
- package/dist/runtime/resolver/tracer-adapter.js.map +1 -0
- package/dist/runtime/resolver/vue-internal.d.ts +12 -0
- package/dist/runtime/resolver/vue-internal.d.ts.map +1 -0
- package/dist/runtime/resolver/vue-internal.js +95 -0
- package/dist/runtime/resolver/vue-internal.js.map +1 -0
- package/dist/runtime/sync.d.ts +12 -0
- package/dist/runtime/sync.d.ts.map +1 -0
- package/dist/runtime/sync.js +168 -0
- package/dist/runtime/sync.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/dist/types.d.ts +61 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +12 -0
- package/dist/types.js.map +1 -0
- package/dist/virtual.d.ts +37 -0
- package/dist/virtual.d.ts.map +1 -0
- package/dist/virtual.js +96 -0
- package/dist/virtual.js.map +1 -0
- package/package.json +50 -0
- package/src/client.d.ts +4 -0
- package/src/runtime/bootstrap.ts +578 -0
- package/src/runtime/entry.ts +146 -0
- package/src/runtime/resolver/index.ts +3 -0
- package/src/runtime/resolver/resolve-source.ts +51 -0
- package/src/runtime/resolver/tracer-adapter.ts +92 -0
- package/src/runtime/resolver/vue-internal.ts +113 -0
- package/src/runtime/sync.ts +192 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { createApp } from "vue"
|
|
2
|
+
import { findTraceFromElement } from "vite-plugin-vue-tracer/client/record"
|
|
3
|
+
import {
|
|
4
|
+
OverlayRoot,
|
|
5
|
+
createAnnotationsStore,
|
|
6
|
+
createAreaSelectionState,
|
|
7
|
+
createSelectionState,
|
|
8
|
+
createSettingsState,
|
|
9
|
+
} from "@liuovo/agentation-vue-ui"
|
|
10
|
+
import type { RuntimeBridge, UiNotification } from "@liuovo/agentation-vue-ui"
|
|
11
|
+
import type { ResolvedAgentationVueOptions } from "../types.js"
|
|
12
|
+
import { bindTracer } from "./resolver/index.js"
|
|
13
|
+
import { setupInfrastructure, attachListeners } from "./bootstrap.js"
|
|
14
|
+
import { createRuntimeSyncBridge } from "./sync.js"
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Browser console logging (styled with %c)
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
const badgeStyle = [
|
|
21
|
+
"background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%)",
|
|
22
|
+
"color: #34d399",
|
|
23
|
+
"font-weight: 700",
|
|
24
|
+
"padding: 2px 8px",
|
|
25
|
+
"border-radius: 4px",
|
|
26
|
+
"font-size: 11px",
|
|
27
|
+
].join(";")
|
|
28
|
+
|
|
29
|
+
const msgStyle = "color: #22d3ee; font-weight: 600;"
|
|
30
|
+
const detailStyle = "color: #a78bfa;"
|
|
31
|
+
|
|
32
|
+
function logBrowser(emoji: string, message: string, detail = ""): void {
|
|
33
|
+
console.log(
|
|
34
|
+
`%c🛰 agentation%c ${emoji} ${message}%c${detail ? ` ${detail}` : ""}`,
|
|
35
|
+
badgeStyle,
|
|
36
|
+
msgStyle,
|
|
37
|
+
detailStyle,
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Runtime entry point — executed in the browser via the virtual module.
|
|
43
|
+
*
|
|
44
|
+
* 1. Disposes any previous runtime instance (supports HMR re-injection)
|
|
45
|
+
* 2. Binds the vue-tracer lookup function into the resolver
|
|
46
|
+
* 3. Sets up DOM containers and storage bridge
|
|
47
|
+
* 4. Creates store, selection, settings, and area selection state
|
|
48
|
+
* 5. Attaches event listeners that drive Vue state
|
|
49
|
+
* 6. Mounts the Vue overlay app
|
|
50
|
+
*/
|
|
51
|
+
export function runAgentationRuntime(options: ResolvedAgentationVueOptions): void {
|
|
52
|
+
logBrowser("✨", "Runtime starting", `storagePrefix="${options.storagePrefix}" locale="${options.locale}"`)
|
|
53
|
+
|
|
54
|
+
// Dispose previous instance if re-injected (e.g. HMR)
|
|
55
|
+
window.__agentationRuntime?.dispose()
|
|
56
|
+
|
|
57
|
+
// Bind vue-tracer into the resolver
|
|
58
|
+
const tracerAvailable = typeof findTraceFromElement === "function"
|
|
59
|
+
if (tracerAvailable) {
|
|
60
|
+
logBrowser("🔎", "Binding vue-tracer", "precise source mapping enabled")
|
|
61
|
+
bindTracer(findTraceFromElement)
|
|
62
|
+
} else {
|
|
63
|
+
console.warn(
|
|
64
|
+
"%c🛰 agentation%c ⚠️ vue-tracer not found — using Vue internals fallback",
|
|
65
|
+
badgeStyle,
|
|
66
|
+
"color: #f59e0b; font-weight: 600;",
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// 1. Infrastructure: DOM containers + storage + source resolver
|
|
71
|
+
logBrowser("🏗️", "Setting up infrastructure", "DOM containers + storage bridge")
|
|
72
|
+
const infra = setupInfrastructure(options.storagePrefix)
|
|
73
|
+
const notificationListeners = new Set<(notification: UiNotification) => void>()
|
|
74
|
+
|
|
75
|
+
// 2. Build the bridge for the UI layer
|
|
76
|
+
const bridge: RuntimeBridge = {
|
|
77
|
+
appRoot: infra.appRoot,
|
|
78
|
+
overlayRoot: infra.overlayRoot,
|
|
79
|
+
options: { outputDetail: options.outputDetail },
|
|
80
|
+
storage: infra.storage,
|
|
81
|
+
sync: options.sync
|
|
82
|
+
? createRuntimeSyncBridge(options.sync, infra.storage)
|
|
83
|
+
: undefined,
|
|
84
|
+
notify(notification) {
|
|
85
|
+
for (const listener of notificationListeners) {
|
|
86
|
+
listener(notification)
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
subscribeNotifications(listener) {
|
|
90
|
+
notificationListeners.add(listener)
|
|
91
|
+
return () => {
|
|
92
|
+
notificationListeners.delete(listener)
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
resolveSource: infra.resolveSource,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// 3. Create store, selection, settings, and area selection state
|
|
99
|
+
logBrowser("🗂️", "Creating annotations store + selection + settings")
|
|
100
|
+
const store = createAnnotationsStore(bridge)
|
|
101
|
+
const selection = createSelectionState()
|
|
102
|
+
const settings = createSettingsState({
|
|
103
|
+
outputDetail: options.outputDetail,
|
|
104
|
+
locale: options.locale,
|
|
105
|
+
})
|
|
106
|
+
const areaSelection = createAreaSelectionState()
|
|
107
|
+
|
|
108
|
+
// 4. Attach event listeners (pointer/click/drag → store/selection)
|
|
109
|
+
logBrowser("👂", "Attaching event listeners", "pointer + click + drag selection")
|
|
110
|
+
const listeners = attachListeners(
|
|
111
|
+
store,
|
|
112
|
+
selection,
|
|
113
|
+
areaSelection,
|
|
114
|
+
settings,
|
|
115
|
+
infra.resolveSource,
|
|
116
|
+
bridge.notify,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
// 5. Mount the Vue overlay app
|
|
120
|
+
logBrowser("🪄", "Mounting overlay app")
|
|
121
|
+
const app = createApp(OverlayRoot, {
|
|
122
|
+
bridge,
|
|
123
|
+
store,
|
|
124
|
+
selection,
|
|
125
|
+
settings,
|
|
126
|
+
areaSelection,
|
|
127
|
+
})
|
|
128
|
+
app.mount(infra.appRoot)
|
|
129
|
+
|
|
130
|
+
// 6. Expose runtime context for HMR disposal + console debugging
|
|
131
|
+
window.__agentationRuntime = {
|
|
132
|
+
dispose() {
|
|
133
|
+
app.unmount()
|
|
134
|
+
listeners.dispose()
|
|
135
|
+
areaSelection.clear()
|
|
136
|
+
selection.clearHovered()
|
|
137
|
+
selection.clearSelection()
|
|
138
|
+
infra.appRoot.replaceChildren()
|
|
139
|
+
infra.overlayRoot.replaceChildren()
|
|
140
|
+
delete window.__agentationDemo
|
|
141
|
+
},
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Diagnostics
|
|
145
|
+
logBrowser("✅", "Runtime ready!", "Try: __agentationDemo.inspect($0)")
|
|
146
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { SourceLocation } from "@liuovo/agentation-vue-core"
|
|
2
|
+
import type { ElementTraceInfo } from "vite-plugin-vue-tracer/client/record"
|
|
3
|
+
import { mapTraceToSourceLocation } from "./tracer-adapter.js"
|
|
4
|
+
import { resolveFromVueInternals } from "./vue-internal.js"
|
|
5
|
+
|
|
6
|
+
type FindTraceFn = (el?: Element | null) => ElementTraceInfo | undefined
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Holder for the lazily-bound vue-tracer lookup function.
|
|
10
|
+
*
|
|
11
|
+
* At dev runtime, the Vite plugin ensures `vite-plugin-vue-tracer/client/record`
|
|
12
|
+
* is loaded before any user interaction. The runtime bootstrap calls
|
|
13
|
+
* `bindTracer()` to wire up the reference once tracer is ready.
|
|
14
|
+
*/
|
|
15
|
+
let _findTrace: FindTraceFn | null = null
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Bind the vue-tracer `findTraceFromElement` function.
|
|
19
|
+
*
|
|
20
|
+
* Called once by the runtime bootstrap after importing the tracer client module.
|
|
21
|
+
*/
|
|
22
|
+
export function bindTracer(fn: FindTraceFn): void {
|
|
23
|
+
_findTrace = fn
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Unified entry point for resolving an HTML element to its Vue source location.
|
|
28
|
+
*
|
|
29
|
+
* Resolution order:
|
|
30
|
+
* 1. vue-tracer headless API (`findTraceFromElement`) — provides file, line, column
|
|
31
|
+
* 2. Vue internals fallback (`__vnode` / `__vueParentComponent`) — provides file only
|
|
32
|
+
*
|
|
33
|
+
* Returns `null` when neither strategy can identify the source.
|
|
34
|
+
*/
|
|
35
|
+
export function resolveElementSource(el: HTMLElement): SourceLocation | null {
|
|
36
|
+
// Strategy 1: vue-tracer (preferred — includes precise line/column)
|
|
37
|
+
if (_findTrace) {
|
|
38
|
+
try {
|
|
39
|
+
const trace = _findTrace(el)
|
|
40
|
+
if (trace) {
|
|
41
|
+
const result = mapTraceToSourceLocation(trace)
|
|
42
|
+
if (result) return result
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
// tracer threw — fall through to next strategy
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Strategy 2: Vue internals fallback (file-level only, no line/column)
|
|
50
|
+
return resolveFromVueInternals(el)
|
|
51
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { SourceLocation } from "@liuovo/agentation-vue-core"
|
|
2
|
+
import type { ElementTraceInfo } from "vite-plugin-vue-tracer/client/record"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Maps a vue-tracer `ElementTraceInfo` to the internal `SourceLocation` type.
|
|
6
|
+
*
|
|
7
|
+
* Returns `null` when the trace info lacks the minimum required data (file path).
|
|
8
|
+
*/
|
|
9
|
+
export function mapTraceToSourceLocation(trace: ElementTraceInfo): SourceLocation | null {
|
|
10
|
+
const [source, line, column] = trace.pos
|
|
11
|
+
if (!source) return null
|
|
12
|
+
|
|
13
|
+
const componentName = extractComponentName(trace)
|
|
14
|
+
const hierarchy = buildComponentHierarchy(trace)
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
framework: "vue",
|
|
18
|
+
componentName,
|
|
19
|
+
componentHierarchy: hierarchy || undefined,
|
|
20
|
+
file: source,
|
|
21
|
+
line,
|
|
22
|
+
column,
|
|
23
|
+
resolver: "vue-tracer",
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Extracts the component display name from a trace entry.
|
|
29
|
+
*
|
|
30
|
+
* Walks the vnode tree to find the nearest component boundary.
|
|
31
|
+
* Falls back to the file basename when no component name is available.
|
|
32
|
+
*/
|
|
33
|
+
function extractComponentName(trace: ElementTraceInfo): string {
|
|
34
|
+
const vnode = trace.vnode
|
|
35
|
+
if (vnode) {
|
|
36
|
+
const type = vnode.type as Record<string, unknown> | undefined
|
|
37
|
+
if (type) {
|
|
38
|
+
if (typeof type === "object" && typeof type.name === "string" && type.name) {
|
|
39
|
+
return type.name
|
|
40
|
+
}
|
|
41
|
+
if (typeof type === "object" && typeof type.__name === "string" && type.__name) {
|
|
42
|
+
return type.__name
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Fallback: derive name from file path
|
|
48
|
+
const [source] = trace.pos
|
|
49
|
+
if (source) {
|
|
50
|
+
const segments = source.split("/")
|
|
51
|
+
const filename = segments[segments.length - 1] ?? source
|
|
52
|
+
return filename.replace(/\.\w+$/, "")
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return "Unknown"
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Builds a component hierarchy string by walking parent traces.
|
|
60
|
+
*
|
|
61
|
+
* Produces a string like `<App> <Layout> <Button>`.
|
|
62
|
+
* Caps at 10 levels to avoid infinite loops in edge cases.
|
|
63
|
+
*/
|
|
64
|
+
function buildComponentHierarchy(trace: ElementTraceInfo): string | null {
|
|
65
|
+
const names: string[] = []
|
|
66
|
+
let current: ElementTraceInfo | undefined = trace
|
|
67
|
+
const maxDepth = 10
|
|
68
|
+
|
|
69
|
+
for (let depth = 0; current && depth < maxDepth; depth++) {
|
|
70
|
+
const name = extractComponentNameFromTrace(current)
|
|
71
|
+
if (name && (names.length === 0 || names[names.length - 1] !== name)) {
|
|
72
|
+
names.push(name)
|
|
73
|
+
}
|
|
74
|
+
current = current.getParent()
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (names.length <= 1) return null
|
|
78
|
+
return names.reverse().map((n) => `<${n}>`).join(" ")
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function extractComponentNameFromTrace(trace: ElementTraceInfo): string | null {
|
|
82
|
+
const vnode = trace.vnode
|
|
83
|
+
if (!vnode) return null
|
|
84
|
+
|
|
85
|
+
const type = vnode.type as Record<string, unknown> | undefined
|
|
86
|
+
if (!type || typeof type !== "object") return null
|
|
87
|
+
|
|
88
|
+
if (typeof type.name === "string" && type.name) return type.name
|
|
89
|
+
if (typeof type.__name === "string" && type.__name) return type.__name
|
|
90
|
+
|
|
91
|
+
return null
|
|
92
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { SourceLocation } from "@liuovo/agentation-vue-core"
|
|
2
|
+
import type { ComponentInternalInstance, VNode } from "vue"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Fallback resolver using Vue 3 internal properties on DOM elements.
|
|
6
|
+
*
|
|
7
|
+
* Used when vue-tracer data is not available (e.g. tracer plugin not loaded,
|
|
8
|
+
* or element was rendered before tracer initialized).
|
|
9
|
+
*
|
|
10
|
+
* Walks up from the element looking for `__vueParentComponent` or `__vnode`
|
|
11
|
+
* to extract component file information.
|
|
12
|
+
*/
|
|
13
|
+
export function resolveFromVueInternals(el: HTMLElement): SourceLocation | null {
|
|
14
|
+
const instance = findComponentInstance(el)
|
|
15
|
+
if (!instance) return null
|
|
16
|
+
|
|
17
|
+
// Walk up the component tree to find the first instance with file metadata
|
|
18
|
+
const resolved = findInstanceWithFile(instance)
|
|
19
|
+
if (!resolved) return null
|
|
20
|
+
|
|
21
|
+
const { instance: fileInstance, file } = resolved
|
|
22
|
+
const type = fileInstance.type as Record<string, unknown>
|
|
23
|
+
const componentName = extractName(type) ?? deriveNameFromFile(file)
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
framework: "vue",
|
|
27
|
+
componentName,
|
|
28
|
+
componentHierarchy: buildHierarchyFromInstance(fileInstance) || undefined,
|
|
29
|
+
file,
|
|
30
|
+
line: undefined,
|
|
31
|
+
column: undefined,
|
|
32
|
+
resolver: "vue-internal",
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface VueElement extends HTMLElement {
|
|
37
|
+
__vueParentComponent?: ComponentInternalInstance
|
|
38
|
+
__vnode?: VNode
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function findComponentInstance(el: HTMLElement): ComponentInternalInstance | null {
|
|
42
|
+
let current: HTMLElement | null = el
|
|
43
|
+
|
|
44
|
+
while (current) {
|
|
45
|
+
const vueEl = current as VueElement
|
|
46
|
+
|
|
47
|
+
if (vueEl.__vueParentComponent) {
|
|
48
|
+
return vueEl.__vueParentComponent
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (vueEl.__vnode) {
|
|
52
|
+
const vnode = vueEl.__vnode
|
|
53
|
+
const type = vnode.type as Record<string, unknown> | undefined
|
|
54
|
+
if (type && typeof type === "object" && extractFile(type)) {
|
|
55
|
+
return { type, parent: null } as unknown as ComponentInternalInstance
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
current = current.parentElement
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function findInstanceWithFile(
|
|
66
|
+
instance: ComponentInternalInstance,
|
|
67
|
+
): { instance: ComponentInternalInstance; file: string } | null {
|
|
68
|
+
let current: ComponentInternalInstance | null = instance
|
|
69
|
+
const maxDepth = 20
|
|
70
|
+
|
|
71
|
+
for (let depth = 0; current && depth < maxDepth; depth++) {
|
|
72
|
+
const type = current.type as Record<string, unknown>
|
|
73
|
+
const file = extractFile(type)
|
|
74
|
+
if (file) return { instance: current, file }
|
|
75
|
+
current = current.parent
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return null
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function extractFile(type: Record<string, unknown>): string | null {
|
|
82
|
+
if (typeof type.__file === "string" && type.__file) return type.__file
|
|
83
|
+
if (typeof type.__filePath === "string" && type.__filePath) return type.__filePath
|
|
84
|
+
return null
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function extractName(type: Record<string, unknown>): string | null {
|
|
88
|
+
if (typeof type.name === "string" && type.name) return type.name
|
|
89
|
+
if (typeof type.__name === "string" && type.__name) return type.__name
|
|
90
|
+
return null
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function deriveNameFromFile(file: string): string {
|
|
94
|
+
const segments = file.split("/")
|
|
95
|
+
const filename = segments[segments.length - 1] ?? file
|
|
96
|
+
return filename.replace(/\.\w+$/, "")
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function buildHierarchyFromInstance(instance: ComponentInternalInstance): string | null {
|
|
100
|
+
const names: string[] = []
|
|
101
|
+
let current: ComponentInternalInstance | null = instance
|
|
102
|
+
const maxDepth = 10
|
|
103
|
+
|
|
104
|
+
for (let depth = 0; current && depth < maxDepth; depth++) {
|
|
105
|
+
const type = current.type as Record<string, unknown>
|
|
106
|
+
const name = extractName(type)
|
|
107
|
+
if (name) names.push(name)
|
|
108
|
+
current = current.parent
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (names.length <= 1) return null
|
|
112
|
+
return names.reverse().map((n) => `<${n}>`).join(" ")
|
|
113
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createSession as apiCreateSession,
|
|
3
|
+
syncAnnotation,
|
|
4
|
+
updateAnnotation as apiUpdateAnnotation,
|
|
5
|
+
deleteAnnotation as apiDeleteAnnotation,
|
|
6
|
+
} from "@liuovo/agentation-vue-core"
|
|
7
|
+
import {
|
|
8
|
+
getUnsyncedAnnotations,
|
|
9
|
+
loadSessionId,
|
|
10
|
+
markAnnotationsSynced,
|
|
11
|
+
saveSessionId,
|
|
12
|
+
} from "@liuovo/agentation-vue-core"
|
|
13
|
+
import type { AnnotationV2 } from "@liuovo/agentation-vue-core"
|
|
14
|
+
import type { RuntimeSyncBridge } from "@liuovo/agentation-vue-ui"
|
|
15
|
+
import type { AgentationStorageBridge, AgentationVueSyncOptions } from "../types.js"
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Creates a sync bridge that connects the local annotation store
|
|
19
|
+
* to the remote MCP server via the V2 HTTP API.
|
|
20
|
+
*
|
|
21
|
+
* - On init, flushes any unsynced annotations to the server.
|
|
22
|
+
* - On enqueueUpsert, debounces a flush to batch rapid saves.
|
|
23
|
+
* - On enqueueDelete, immediately fires a best-effort DELETE.
|
|
24
|
+
*/
|
|
25
|
+
export function createRuntimeSyncBridge(
|
|
26
|
+
sync: AgentationVueSyncOptions,
|
|
27
|
+
storage: AgentationStorageBridge,
|
|
28
|
+
): RuntimeSyncBridge {
|
|
29
|
+
const pathname = () => window.location.pathname
|
|
30
|
+
const debounceMs = sync.debounceMs ?? 400
|
|
31
|
+
|
|
32
|
+
let sessionId: string | undefined = loadSessionId(pathname(), storage.options) ?? undefined
|
|
33
|
+
let currentPathname = pathname()
|
|
34
|
+
let flushTimer: ReturnType<typeof setTimeout> | undefined
|
|
35
|
+
let flushInFlight: Promise<void> | null = null
|
|
36
|
+
let dirtyWhileFlushing = false
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Re-resolve sessionId when the SPA pathname changes.
|
|
40
|
+
* Called before flush operations to handle client-side routing.
|
|
41
|
+
*/
|
|
42
|
+
function watchPathname(): void {
|
|
43
|
+
const current = pathname()
|
|
44
|
+
if (current !== currentPathname) {
|
|
45
|
+
currentPathname = current
|
|
46
|
+
sessionId = loadSessionId(current, storage.options) ?? undefined
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Ensure a server-side session exists for the current page.
|
|
52
|
+
* Creates one lazily and persists the ID in localStorage.
|
|
53
|
+
*/
|
|
54
|
+
async function ensureSession(): Promise<string> {
|
|
55
|
+
watchPathname()
|
|
56
|
+
if (sessionId) return sessionId
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
const session = await apiCreateSession(sync.endpoint, window.location.href)
|
|
60
|
+
sessionId = session.id
|
|
61
|
+
saveSessionId(pathname(), session.id, storage.options)
|
|
62
|
+
return session.id
|
|
63
|
+
} catch (err) {
|
|
64
|
+
console.warn("[agentation] Failed to create session:", err)
|
|
65
|
+
throw err
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Flush all unsynced annotations to the server.
|
|
71
|
+
*
|
|
72
|
+
* Serialized: if a flush is already running and new writes arrive,
|
|
73
|
+
* we mark the state as dirty and re-flush after the current one completes.
|
|
74
|
+
*/
|
|
75
|
+
async function flush(): Promise<void> {
|
|
76
|
+
if (flushInFlight) {
|
|
77
|
+
dirtyWhileFlushing = true
|
|
78
|
+
return flushInFlight
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
flushInFlight = (async () => {
|
|
82
|
+
const sid = await ensureSession()
|
|
83
|
+
const pending = getUnsyncedAnnotations(pathname(), sid, storage.options)
|
|
84
|
+
|
|
85
|
+
if (pending.length === 0) return
|
|
86
|
+
|
|
87
|
+
const synced: string[] = []
|
|
88
|
+
for (const annotation of pending) {
|
|
89
|
+
try {
|
|
90
|
+
await syncAnnotation(sync.endpoint, sid, annotation)
|
|
91
|
+
synced.push(annotation.id)
|
|
92
|
+
} catch (err) {
|
|
93
|
+
console.warn("[agentation] Failed to sync annotation:", annotation.id, err)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (synced.length > 0) {
|
|
98
|
+
markAnnotationsSynced(pathname(), synced, sid, storage.options)
|
|
99
|
+
}
|
|
100
|
+
})()
|
|
101
|
+
.catch((err) => {
|
|
102
|
+
console.warn("[agentation] Sync flush failed:", err)
|
|
103
|
+
})
|
|
104
|
+
.finally(() => {
|
|
105
|
+
flushInFlight = null
|
|
106
|
+
|
|
107
|
+
// If new writes arrived while we were flushing, schedule a follow-up
|
|
108
|
+
if (dirtyWhileFlushing) {
|
|
109
|
+
dirtyWhileFlushing = false
|
|
110
|
+
scheduleFlush()
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
return flushInFlight
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Schedule a debounced flush.
|
|
119
|
+
* Resets the timer on each call to batch rapid saves.
|
|
120
|
+
*/
|
|
121
|
+
function scheduleFlush(): void {
|
|
122
|
+
if (flushTimer != null) {
|
|
123
|
+
clearTimeout(flushTimer)
|
|
124
|
+
}
|
|
125
|
+
flushTimer = setTimeout(() => {
|
|
126
|
+
flushTimer = undefined
|
|
127
|
+
void flush()
|
|
128
|
+
}, debounceMs)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Best-effort PATCH for an updated annotation.
|
|
133
|
+
* Reuses the same session; falls back to upsert on error.
|
|
134
|
+
*/
|
|
135
|
+
async function updateRemote(annotation: AnnotationV2): Promise<void> {
|
|
136
|
+
watchPathname()
|
|
137
|
+
if (!sessionId) {
|
|
138
|
+
// No session yet — treat as upsert
|
|
139
|
+
scheduleFlush()
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
await apiUpdateAnnotation(sync.endpoint, annotation.id, annotation)
|
|
145
|
+
} catch (err) {
|
|
146
|
+
console.warn("[agentation] Update sync failed, will re-upsert:", annotation.id, err)
|
|
147
|
+
scheduleFlush()
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Best-effort DELETE for a removed annotation.
|
|
153
|
+
* If the annotation was never synced (404), silently ignore.
|
|
154
|
+
* Does NOT create a session — if no session exists, the annotation
|
|
155
|
+
* was never synced anyway.
|
|
156
|
+
*/
|
|
157
|
+
async function deleteRemote(annotation: AnnotationV2): Promise<void> {
|
|
158
|
+
watchPathname()
|
|
159
|
+
// Don't create a session just to delete — if no session exists,
|
|
160
|
+
// the annotation was never synced.
|
|
161
|
+
if (!sessionId) return
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
await apiDeleteAnnotation(sync.endpoint, annotation.id)
|
|
165
|
+
} catch (err) {
|
|
166
|
+
if (err instanceof Error && err.message.includes("404")) return
|
|
167
|
+
console.warn("[agentation] Delete sync failed:", annotation.id, err)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
init() {
|
|
173
|
+
if (sync.autoSync === false) return Promise.resolve()
|
|
174
|
+
return flush()
|
|
175
|
+
},
|
|
176
|
+
|
|
177
|
+
enqueueUpsert(_annotation: AnnotationV2) {
|
|
178
|
+
if (sync.autoSync === false) return
|
|
179
|
+
scheduleFlush()
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
enqueueUpdate(annotation: AnnotationV2) {
|
|
183
|
+
if (sync.autoSync === false) return
|
|
184
|
+
void updateRemote(annotation)
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
enqueueDelete(annotation: AnnotationV2) {
|
|
188
|
+
if (sync.autoSync === false) return
|
|
189
|
+
void deleteRemote(annotation)
|
|
190
|
+
},
|
|
191
|
+
}
|
|
192
|
+
}
|