Forráskód Böngészése

Added chat, improved prototype

luis 7 éve
szülő
commit
78850e4b8c

+ 8 - 1
Makefile

@@ -30,7 +30,14 @@ push: DIST/.dockerized
 
 frontend: DIST/web
 
-backend: DIST/backend
+backend: DIST/flowserver
+
+dev: clean backend
+	tmux split "DEBUG=1 DIST/flowserver"
+	cd browser/vue-flow; yarn dev
+
+
+
 
 
 .PHONY: all clean frontend backend

+ 32 - 1
browser/vue-flow/src/assets/style.css

@@ -1,5 +1,9 @@
 h4 {
-  margin: 2px 0 8px 0;
+  margin: 8px 0 8px 0;
+  font-weight: bold;
+}
+
+b {
   font-weight: bold;
 }
 
@@ -23,3 +27,30 @@ ul {
   padding-left: 15px;
   list-style: disc;
 }
+
+/* Let's get this party started */
+::-webkit-scrollbar {
+  padding-top: 10px;
+  width: 8px;
+  height: 8px;
+}
+
+/* Track */
+::-webkit-scrollbar-track {
+  border-radius: 0;
+  width: 3px;
+  height: 3px;
+  background: rgba(55, 55, 55, 0.1);
+}
+
+/* Handle */
+::-webkit-scrollbar-thumb {
+  border-radius: 0;
+  width: 8px;
+  height: 8px;
+  background: rgba(155, 155, 155, 0.8);
+}
+
+::-webkit-scrollbar-thumb:window-inactive {
+  background: rgba(55, 55, 55, 0.8);
+}

+ 199 - 0
browser/vue-flow/src/components/chat.vue

@@ -0,0 +1,199 @@
+<template>
+  <div class="flow-chat" :class="{active:active}">
+    <div class="flow-chat__toggle" @click="active=!active">=</div>
+    <div class="flow-chat__area">
+      <div class="flow-chat__container">
+        <input class="handle" type="text" v-model="handle">
+        <div ref="messages" class="flow-chat__messages">
+          <div v-for="m in events" class="message">
+            <div class="handle">
+              <div class="name">{{ m.handle }} <span v-if="m.type!='msg'">{{ m.type }}</span></div>
+              <div class="time">{{ m.time | time }}</div>
+            </div>
+            <div class="text">{{ m.message }}</div>
+          </div>
+        </div>
+      <input ref="input" class="message" @keyup.enter="send" type="text" v-model="input"></div>
+    </div>
+  </div>
+</template>
+
+<script>
+// init
+let storedHandle = localStorage.getItem('handle')
+if (storedHandle === '') {
+  storedHandle = 'someone'
+}
+
+// Load handle from storage
+export default {
+  filters: {
+    time (value) {
+      const d = new Date(value)
+
+      const hours = pad(d.getHours(), 2)
+      const minutes = pad(d.getMinutes(), 2)
+      let msg = `${hours}:${minutes}`
+      return msg
+    }
+  },
+  data () {
+    return {
+      handle: storedHandle,
+      active: false,
+      events: [],
+      input: ''
+    }
+  },
+  watch: {
+    handle (val) {
+      localStorage.setItem('handle', val)
+    },
+    active (val, oldVal) {
+      if (val === true && oldVal === false) {
+        this.$refs.input.focus()
+      }
+    },
+    events () {
+      const height = this.$refs.messages.clientHeight
+      if (this.$refs.messages.scrollTop + height >= this.$refs.messages.scrollHeight) {
+        this.$nextTick(() => {
+          this.$refs.messages.scrollTop = this.$refs.messages.scrollHeight
+        })
+      }
+      if (this.active === false) {
+        this.active = true
+      }
+    }
+  },
+  mounted () {
+    // this.$flowService.on('chatEvent', this.addChatEvent)
+    this.$flowService.on('chatEvent', this.addChatEvent)
+    this.$flowService.on('sessionJoin', this.chatJoin)
+    // we might not be joined in a sess
+    this.$flowService.connected(() => {
+      this.$flowService.chatJoin(this.handle, this.$route.params.sessId)
+    })
+  },
+  methods: {
+    send () {
+      const msg = this.input
+      if (msg.trim() === '') {
+        return
+      }
+      this.input = ''
+      const msgEvent = {type: 'msg', handle: this.handle, message: msg, time: new Date()}
+      this.$flowService.chatEvent(msgEvent)
+    },
+    addChatEvent (v) {
+      this.events.push(v.data)
+    },
+    chatJoin () {
+      this.$flowService.chatJoin(this.handle, this.$route.params.sessId)
+    }
+  }
+
+}
+function pad (n, width, z) {
+  z = z || '0'
+  n = n + ''
+  return n.length >= width ? n : new Array(width - n.length + 1).join(z) + n
+}
+</script>
+<style>
+.flow-chat {
+  height:100%;
+  box-sizing:border-box;
+  position:relative;
+  width:0px;
+  transition: all .3s;
+  background: rgba(208,208,208,0.7);
+}
+.flow-chat.active {
+  width:330px;
+}
+.flow-chat__toggle {
+  user-select:none;
+  cursor: pointer;
+  background: rgba(108,108,208,0.3);
+  position:absolute;
+  display:flex;
+  justify-content: center;
+  align-items: center;
+  width:30px;
+  height:50px;
+  left:-30px;
+  top:calc(50% - 25px);
+  border-top: solid 1px rgba(208,208,208,0.4);
+  border-left: solid 1px rgba(208,208,208,0.4);
+  border-bottom: solid 1px rgba(208,208,208,0.4);
+}
+
+.flow-chat__area {
+  height:100%;
+  overflow:hidden;
+}
+
+.flow-chat__container {
+  box-shadow: 0px 0px 1px 2px rgba(50,50,50,0.1);
+  height:100%;
+  display:flex;
+  flex-flow:column;
+  padding:8px;
+}
+.flow-chat__container >*:not(last-child) {
+  margin-bottom:10px;
+  padding:8px;
+}
+
+.flow-chat__messages {
+  font-size:12px;
+  overflow-y:scroll;
+  min-width:300px;
+  flex:1;
+}
+.flow-chat__messages .message{
+  padding:2px 2px 12px 2px;
+  border-bottom: solid 1px rgba(150,150,150,0.1);
+
+}
+.flow-chat__messages .handle {
+  display:flex;
+  flex-flow:row;
+
+  justify-content: space-between;
+  align-items: center;
+  padding-top:2px;
+
+}
+.flow-chat__messages .handle .name {
+  margin-left:-5px;
+  font-weight:bold;
+  padding-bottom:4px;
+}
+.flow-chat__messages .handle .time{
+  font-weight:normal;
+  font-size:8px;
+  color: #999;
+}
+
+.flow-chat__messages .text {
+  padding-top:0px;
+  color:#777;
+}
+
+.flow-chat input {
+background: #fff;
+  width:100%;
+  border: none;
+  padding:10px;
+
+}
+.flow-chat input.handle {
+  background: #777;
+  color: #FFF;
+}
+.flow-chat input.message {
+  box-shadow: 0px 1px 2px rgba(150,150,150,0.5);
+}
+</style>

+ 14 - 3
browser/vue-flow/src/components/flow/link.vue

@@ -1,5 +1,7 @@
 <template>
-  <g class="flow-link" @click="$emit('click',$event)">
+  <g
+    class="flow-link"
+    @click="$emit('click',$event)">
     <path
       class="flow-link__area"
       :d="path"
@@ -8,10 +10,11 @@
       class="flow-link__visible"
       :d="path"
     />
+
   </g>
 </template>
 <script>
-const s = 60
+const curve = 100
 export default {
   name: 'FlowLink',
   props: {
@@ -22,6 +25,14 @@ export default {
   },
   computed: {
     path () {
+      const dx = this.x2 - this.x1
+      const dy = this.y2 - this.y1
+      let s = curve
+      let lineDist = 200
+
+      if (Math.sqrt(dx * dx + dy * dy) < lineDist) {
+        s = Math.max(curve - (lineDist - Math.sqrt(dx * dx + dy * dy)), 0)
+      }
       return `M${this.x1},${this.y1} C${this.x1 + s},${this.y1} ${this.x2 - s},${this.y2} ${this.x2},${this.y2}`
     }
   }
@@ -39,8 +50,8 @@ export default {
 .flow-link .flow-link__visible{
   stroke: rgba(0,0,0,0.5);
   stroke-width:4;
-  stroke-linecap:round;
   fill: transparent;
+  marker-end:url(#triangle);
 }
 .flow-link:hover .flow-link__visible {
   stroke: #F00;

+ 64 - 16
browser/vue-flow/src/components/flow/manager.vue

@@ -9,19 +9,32 @@
         {{ k }}
       </button>
     </div>
+
     <svg
+      xmlns="http://www.w3.org/2000/svg"
+      xmlns:xlink="http://www.w3.org/1999/xlink"
       ref="svg"
       class="view"
       :width="width"
       :height="height">
+      <defs>
+        <pattern id="smallGrid" width="8" height="8" pattern-units="userSpaceOnUse">
+          <path d="M 8 0 L 0 0 0 8" fill="none" stroke="gray" stroke-width="0.5"/>
+        </pattern>
+        <pattern id="grid" width="80" height="80" pattern-units="userSpaceOnUse">
+          <rect width="80" height="80" fill="url(#smallGrid)"/>
+          <path d="M 80 0 L 0 0 0 80" fill="none" stroke="gray" stroke-width="1"/>
+        </pattern>
+      </defs>
+
       <flow-pan-zoom
         ref="panzoom"
         v-model="panzoom">
         <flow-link
-          v-for="(l,i) in nodeData.links"
+          v-for="(link,i) in nodeData.links"
           :key="i"
-          v-bind="linkProps(l)"
-          @click="linkRemove(i)"
+          v-bind="linkProps(link)"
+          @click="linkRemove(link)"
         />
         <!-- mouse link-->
         <flow-link
@@ -57,10 +70,11 @@ export default {
     'height': {type: String, default: '600px'}
   },
   data () {
-    const cloned = JSON.parse(JSON.stringify(this.value)) // initial?
+    // const cloned = JSON.parse(JSON.stringify(this.value)) // initial?
     return {
       panzoom: { x: 0, y: 0, zoom: 1 },
-      nodeData: cloned,
+      nodeData: { nodes: [], links: [] },
+      dragging: null,
       pointerLink: {active: false, props: {}, src: {}}
     }
   },
@@ -115,7 +129,21 @@ export default {
   watch: {
     value: {
       handler (val) {
-        this.nodeData = JSON.parse(JSON.stringify(this.value)) // deepClone
+        const clone = JSON.parse(JSON.stringify(this.value)) // deepClone
+        const nodes = []
+        for (let n of clone.nodes) {
+          if (this.dragging && this.dragging.id === n.id) {
+            continue
+          }
+          nodes.push(n)
+        }
+        if (this.dragging) {
+          nodes.push(this.dragging)
+        }
+        this.nodeData = {
+          nodes: nodes,
+          links: clone.links
+        }
         this.$nextTick(() => {
           this.$forceUpdate()
         })
@@ -130,6 +158,7 @@ export default {
   },
   methods: {
     // XXX: Shrink this function
+    // and create some LinkAdd method
     socketPointerDown (nodeId, e, socket) {
       const nodeRef = this.$refs.nodes.find(n => n.id === nodeId)
       const node = this.nodeData.nodes.find(n => n.id === nodeId)
@@ -222,9 +251,7 @@ export default {
           // console.error('LINK: already has input')
           // return
         }
-
-        this.nodeData.links.push(link)
-        this.$emit('input', this.nodeData)
+        this.linkAdd(link)
       }
       document.addEventListener('mousemove', drag)
       document.addEventListener('mouseup', drop)
@@ -237,6 +264,8 @@ export default {
         this.nodeData.links = this.nodeData.links.filter(l => l.from !== tnode.id && l.to !== tnode.id)
         this.nodeData.nodes.splice(i, 1)
         this.$emit('input', this.nodeData)
+        this.$emit('nodeRemove', tnode)
+        this.$emit('shouldPersist')
         return
       }
       if (e.button !== 0) return // first button
@@ -244,6 +273,7 @@ export default {
       this.nodeData.nodes.splice(i, 1)
       this.nodeData.nodes.push(tnode) // put in last
 
+      this.dragging = tnode
       // transform CTM
       const delta = this.transformedPoint(e.clientX, e.clientY)
       delta.x -= tnode.x
@@ -255,30 +285,50 @@ export default {
         tnode.y = point.y - delta.y
         // Bad possibly
         this.$emit('input', this.nodeData)
+        this.$emit('nodeUpdate', tnode)
       }
       const drop = (e) => {
         document.removeEventListener('mousemove', drag)
         document.removeEventListener('mouseup', drop)
+        this.dragging = null
+
         this.$emit('input', this.nodeData)
+        this.$emit('nodeUpdate', tnode)
+        this.$emit('shouldPersist')
       }
       document.addEventListener('mousemove', drag)
       document.addEventListener('mouseup', drop)
     },
     nodeAdd (k) {
-      this.nodeData.nodes.push({
+      const newNode = {
         id: guid(),
         x: 100,
         y: 100,
         label: k,
         src: k
-      })
+      }
+      this.nodeData.nodes.push(newNode)
       this.$emit('input', this.nodeData)
+      this.$emit('nodeUpdate', newNode)
+      this.$emit('shouldPersist')
     },
-    linkRemove (i) {
+    linkAdd (link) {
+      this.nodeData.links.push(link)
+      this.$emit('input', this.nodeData)
+      this.$emit('linkUpdate', link)
+      this.$emit('shouldPersist')
+    },
+    linkRemove (link) {
+      const i = this.nodeData.links.findIndex(l => l === link)
+      if (i === -1) return
+
       this.nodeData.links.splice(i, 1)
       this.$emit('input', this.nodeData)
+      this.$emit('linkRemove', link)
+      this.$emit('shouldPersist')
     },
 
+    // HELPERS depending on svg ref
     createSVGPoint (x, y) {
       const p = this.$refs.svg.createSVGPoint()
       p.x = x; p.y = y
@@ -287,14 +337,12 @@ export default {
     transformedPoint (x, y, abs) {
       const svgRect = this.$refs.svg.getBoundingClientRect()
       if (!abs) {
-        x -= svgRect.x
-        y -= svgRect.y
+        x -= svgRect.left
+        y -= svgRect.top
       }
       return this.$refs.panzoom.transformedPoint(this.createSVGPoint(x, y))
     }
-    // helper
   }
-
 }
 function guid () {
   function s4 () {

+ 13 - 4
browser/vue-flow/src/components/flow/panzoom.vue

@@ -1,12 +1,17 @@
 <template>
   <g>
+    <rect
+      class="flow-pan-zoom__grid"
+      width="100%"
+      height="100%"
+      fill="url(#grid)"
+    />
     <rect
       ref="transformer"
       class="flow-pan-zoom__transformer"
       width="100%"
       height="100%"
       @mousedown.stop.prevent="dragStart"/>
-
     <g
       ref="transform"
       v-bind="transformProps">
@@ -52,8 +57,9 @@ export default {
   methods: {
     // panStart
     dragStart (e) {
-      if (e.button !== 0) return // first button
+      if (e.button !== 1) return // first button
       if (e.target !== this.$refs.transformer) return
+      document.activeElement && document.activeElement.blur()
 
       const drag = (e) => {
         this.moving = true
@@ -72,8 +78,8 @@ export default {
       let deltaY = (e.deltaY > 0) ? 1 : -1
       deltaY *= (e.shiftKey) ? 0.01 : 0.07
       const svgRect = this.$refs.transformer.getBoundingClientRect()
-      const oX = e.clientX - svgRect.x
-      const oY = e.clientY - svgRect.y
+      const oX = e.clientX - svgRect.left
+      const oY = e.clientY - svgRect.top
       const z = this.zoom - (deltaY * this.zoom)
 
       var curX = this.x
@@ -105,5 +111,8 @@ export default {
 .flow-pan-zoom__transformer {
   fill:transparent;
 }
+.flow-pan-zoom__grid {
+  pointer-events:none;
+}
 
 </style>

+ 111 - 49
browser/vue-flow/src/components/flowmain.vue

@@ -3,71 +3,121 @@
     <div class="app-header">
       Flow
     </div>
-    <div class="app-flow-container">
-      <div class="app-info">
-        <h4>TODO:</h4>
-        <ul>
-          <li>Load the functions from server registry</li>
-          <li>Create session based collaboration</li>
-          <li>Build the graph on the server</li>
-          <li>Create training mechanism</li>
-          <li>matrix pooling</li>
-          <li>Group nodes into a single box, exposing inputs and outputs</li>
-        </ul>
+    <div class="app-horizontal">
+      <div class="app-flow-container">
+        <div class="app-watermark">PROTOTYPE</div>
+        <div class="app-info">
+          <h4>HELP</h4>
+          <ul>
+            <li><b>Pan</b>: Drag with Middle Mouse button</li>
+            <li><b>Zoom</b>: Mouse wheel up and down to zoom in and out</li>
+            <li><b>New Node</b>: Create a node by clicking on fn: buttons</li>
+            <li><b>Remove Node</b>: Middle click in a node to remove a node</li>
+            <li><b>Drag Node</b>: Mouse click and drag</li>
+            <li><b>Link two nodes</b>: Drag from any slot to any compatible in another node (in green)</li>
+            <li><b>Remove Link</b>: Simple click on the link when it turns red</li>
+          </ul>
 
-      </div>
+          <h4>TODO:</h4>
+          <ul>
+            <li>Data visualizer: Special nodes with display capabilities</li>
+            <li>Visualiser: Group nodes into a single box, exposing inputs and outputs</li>
+            <li>Visualiser: Implement touch</li>
+            <li>Registry: Synchronize registry with server(GET)</li>
+            <li>Collaboration: Better concurrent editing/message passing</li>
+            <li>Collaboration: Improve document synchronization</li>
+            <li>FlowServer: Build the graph on the server and run</li>
+            <li>FlowPkg: Create training mechanism</li>
+            <li>FlowPkg: matrix pooling function example</li>
+          </ul>
+        </div>
+
+        <!--:value="nodeData"
+          @input="documentUpdate"-->
+        <flow-manager
+          :registry="registry"
+          v-model="nodeData"
+
+          @linkUpdate="sendFlowEvent('linkUpdate',$event)"
+          @linkRemove="sendFlowEvent('linkRemove',$event)"
 
-      <div class="app-watermark">PROTOTYPE</div>
-      <flow-manager
-        :registry="registry"
-        :value="nodeData"
-        @input="documentUpdate"
-        width="100%"
-        height="100%"/>
+          @nodeUpdate="sendFlowEvent('nodeUpdate',$event)"
+          @nodeRemove="sendFlowEvent('nodeRemove',$event)"
+          @shouldPersist="sendDocumentUpdate"
+
+          width="100%"
+          height="100%"/>
+      </div>
+      <div class="app-chat">
+        <app-chat/>
+      </div>
     </div>
   </div>
 </template>
 <script>
+import AppChat from '@/components/chat'
 import FlowManager from '@/components/flow/manager'
 import 'reset-css/reset.css'
 import '@/assets/style.css'
-import nodeData from './nodedata'
+// import nodeData from './nodedata'
 
 export default {
-  components: {FlowManager},
+  components: {FlowManager, AppChat},
   data () {
     return {
-      registry: nodeData.registry,
+      registry: {
+        'MatMul': { inputs: [ '[]float32', '[]float32' ], output: '[]float32', style: { 'color': '#789', 'textColor': '#fff' } },
+        'Weights': { inputs: [], output: '[]float32' },
+        'Input': { inputs: [], output: '[]float32', style: { 'color': '#686', 'textColor': '#fff' } },
+        'Activator': { inputs: [ '[]float32' ], output: '[]float32', 'type': 'circle', style: { 'color': '#a44', 'textColor': 'white' } },
+        'test': { inputs: [ '[]float32', 'string' ], output: 'string' },
+        'reverse': { inputs: [ 'string' ], output: 'string' }
+      },
       nodeData: {nodes: [], links: []}
     }
   },
-
   mounted () {
-    // Make this in a service
-    this.$ws.recv((msg) => {
-      switch (msg.op) {
-        case 'sessionSwitch':
-          this.$router.push('/' + msg.data) // Swap to ID
-          break
-        case 'document':
-          // Transform here
-          if (!msg.data) break
-          this.nodeData = {
-            nodes: msg.data.nodes || this.nodeData.nodes,
-            links: msg.data.links || this.nodeData.links
-          }
-          break
+    // Handle incoming things
+    this.$flowService.on('sessionJoin', (v) => {
+      if (v.id !== this.$route.params.sessId) {
+        this.$router.push('/' + v.id) // Swap to ID
       }
     })
-    if (this.$route.params.sessId === undefined) {
-      this.$ws.send({op: 'sessionNew'})
-      return
-    }
-    this.$ws.send({op: 'sessionLoad', data: this.$route.params.sessId})
+    this.$flowService.on('document', (v) => {
+      if (!v.data) return
+      this.nodeData = {
+        nodes: v.data.nodes || [],
+        links: v.data.links || []
+      }
+    })
+
+    this.$flowService.on('nodeUpdate', (v) => {
+      const node = v.data
+      const nd = this.nodeData
+      const idx = nd.nodes.findIndex(n => n.id === node.id)
+      if (idx === -1) { // new node
+        nd.nodes.push(node)
+        return
+      }
+      this.$set(nd.nodes, idx, node) // new Node
+    })
+    // Connected
+    this.$flowService.connected(() => {
+      // Make this in a service
+      if (this.$route.params.sessId === undefined) {
+        this.$flowService.sessionNew()
+        return
+      }
+      this.$flowService.sessionLoad(undefined, this.$route.params.sessId)
+    })
   },
   methods: {
-    documentUpdate (nodeData) {
-      this.$ws.send({op: 'documentUpdate', data: nodeData})
+    sendFlowEvent (type, param) {
+      this.$flowService[type](param)
+    },
+    // Update individual nodes/links
+    sendDocumentUpdate (nodeData) {
+      this.$flowService.documentUpdate(this.nodeData, this.$route.params.sessId)
     }
   }
 }
@@ -99,14 +149,13 @@ export default {
   font-weight:bold;
 }
 .flow-main  .app-info {
-  box-shadow:0px 1px 1px rgba(150,150,0,0.2);
-  color: #999;
-  background: #FfFfdd;
+  color: #aaa;
+  font-size:10px;
   margin:20px;
   padding:20px;
   position:absolute;
   left:0;
-  bottom:10%;
+  bottom:3%;
 }
 
 .flow-main .app-watermark {
@@ -117,7 +166,20 @@ export default {
   font-size:100px;
   color: #f4f4f4;
   text-shadow: 1px 1px 1px rgba(255,255,255,0.5), -1px -1px 1px rgba(0,0,0,0.05);
-
+}
+.app-horizontal {
+  height:100%;
+  max-height:100%;
+  display:flex;
+  position:relative;
+  flex-flow:row;
+  overflow:hidden;
+}
+.app-chat {
+  position:absolute;
+  top:0px;
+  right:0px;
+  height:100%;
 }
 
 </style>

+ 6 - 2
browser/vue-flow/src/main.js

@@ -1,10 +1,14 @@
 import Vue from 'vue'
 import App from './App.vue'
-import WsConn from './services/wsconn'
+import FlowService from './services/flowservice'
 
 import router from './router'
 
-Vue.use(WsConn, {location: 'ws://' + window.location.host + '/conn'})
+let targetws = 'ws://' + window.location.host + '/conn'
+if (window.location.protocol === 'https:') {
+  targetws = 'wss://' + window.location.host + '/conn'
+}
+Vue.use(FlowService, {location: targetws})
 
 window.app = new Vue({
   el: '#app',

+ 104 - 0
browser/vue-flow/src/services/flowservice.js

@@ -0,0 +1,104 @@
+// FlowWSService
+
+const debug = 0
+let log = () => {}
+if (debug) {
+  log = console.log.bind(console.log, '%cSVC:', 'color:#0a0')
+}
+
+export default {
+
+  install (Vue, options) {
+    let ws
+    let connected = false
+    let failed
+
+    const eventBus = new Vue()
+
+    let service = {
+      /* documentUpdate (nodeData, id) { // Specific
+        log('documentUpdate -- ', nodeData)
+        if (connected === false) {
+          log('Saving failed, rescheduling')
+          failed = {
+            DocumentUpdate: JSON.parse(JSON.stringify(nodeData)),
+            sessId: id
+          }
+          return
+        }
+        this.send({op: 'documentUpdate', id: id, data: nodeData})
+      }, */
+      send (msg) { // Raw message
+        ws.send(JSON.stringify(msg))
+      },
+      // Calls callback if already connected or waits for connection
+      connected (cb) {
+        if (connected === false) {
+          eventBus.$on('open', cb)
+          return
+        }
+        cb()
+      },
+      // event related
+      on: eventBus.$on.bind(eventBus),
+      once: eventBus.$once.bind(eventBus)
+    };
+
+    /// ///////////////
+    // Service entry points map
+    //
+    [
+      'sessionNew', 'sessionLoad', // SESSION
+      'documentUpdate',
+      'chatEvent', 'chatJoin', // CHAT
+      'linkAdd', 'linkUpdate', 'linkRemove', // LINK
+      'nodeUpdate', 'nodeAdd', 'nodeRemove' // NODE
+    ].forEach(ftyp => {
+      service[ftyp] = (param, id) => {
+        log('sending:', ftyp, ' -- ', param)
+        if (connected) {
+          service.send({op: ftyp, id: id, data: param})
+          return
+        }
+        service.once('open', () => {
+          service.send({op: ftyp, id: id, data: param})
+        })
+      }
+    })
+
+    // WS Connector
+    const connect = (loc) => {
+      ws = new window.WebSocket(loc)
+      ws.onopen = () => {
+        log('connected')
+        connected = true
+        eventBus.$emit('open')
+        if (failed) {
+          log('Persisting failed document (retry)')
+          service.documentUpdate(failed.DocumentUpdate, failed.sessId)
+        }
+      }
+      ws.onmessage = (e) => { // receiving message
+        log('received:', e.data)
+        const msg = JSON.parse(e.data)
+        eventBus.$emit(msg.op, msg) // Pass message through
+      }
+      ws.onerror = (e) => { connected = false }
+      ws.onclose = (e) => {
+        log('Lost connection', e)
+        if (connected === true) {
+          eventBus.$emit('close')
+        } // emit close
+        connected = false
+        setTimeout(() => connect(loc), 3000) // Reconnect
+      }
+    }
+
+    connect(options.location)
+    /// ////////////////////////////////////////
+    // Service methods here
+    /// /////
+    Vue.prototype.$flowService = service
+  }
+
+}

+ 0 - 49
browser/vue-flow/src/services/wsconn.js

@@ -1,49 +0,0 @@
-export default {
-
-  install (Vue, options) {
-    let ws
-    let connected = false
-
-    const eventBus = new Vue({
-      methods: {
-        send (msg) {
-          if (connected === false) {
-            console.log('No connection, scheduling message')
-            this.$on('open', () => {
-              this.$emit('send', msg)
-            })
-            return
-          }
-          this.$emit('send', msg)
-        },
-        recv (cb) {
-          this.$on('message', obj => cb(obj))
-        }
-      }
-    })
-
-    const connect = (loc) => {
-      ws = new window.WebSocket(loc)
-      ws.onopen = () => { connected = true; eventBus.$emit('open') }
-      ws.onerror = (e) => { connected = false }
-      ws.onclose = (e) => {
-        //        console.log('Disconnected', e)
-        if (connected === true) { } // emit close
-        connected = false
-        setTimeout(() => connect(loc), 3000) // Reconnect
-      }
-      ws.onmessage = (e) => { // receiving message
-        // console.log('Message received', e)
-        eventBus.$emit('message', JSON.parse(e.data))
-      }
-    }
-    connect(options.location)
-
-    eventBus.$on('send', (msg) => {
-      ws.send(JSON.stringify(msg))
-    })
-
-    Vue.prototype.$ws = eventBus
-  }
-
-}

+ 111 - 0
go/src/flowserver/chatroom.go

@@ -0,0 +1,111 @@
+package flowserver
+
+import (
+	"flowserver/flowmsg"
+	"sync"
+	"time"
+
+	"github.com/gorilla/websocket"
+)
+
+//ChatClient structure
+type ChatClient struct {
+	Handle string
+	ws     *websocket.Conn
+}
+
+//ChatRoom will have an external ID
+type ChatRoom struct {
+	sync.Mutex
+	clients []ChatClient
+	Events  []interface{} // Persistent chat, temporary datatype
+}
+
+// NewChatRoom create a chatRoom
+func NewChatRoom() *ChatRoom {
+	return &ChatRoom{sync.Mutex{}, []ChatClient{}, []interface{}{}}
+}
+
+// ClientAdd add a client to session
+func (r *ChatRoom) ClientAdd(c *websocket.Conn, handle string) error {
+	r.Lock()
+	defer r.Unlock()
+
+	// Already exists
+	for _, cl := range r.clients {
+		if cl.ws == c {
+			return nil
+		}
+	}
+
+	r.clients = append(r.clients, ChatClient{handle, c})
+	// Add event joined
+	//XXX: temp
+	{
+		dataMap := map[string]interface{}{
+			"handle": handle,
+			"type":   "join",
+			"time":   time.Now().UTC().Format(time.RFC3339),
+		}
+		r.Events = append(r.Events, dataMap)
+		r.broadcast(c, flowmsg.SendMessage{OP: "chatEvent", Data: dataMap})
+	}
+
+	for _, ev := range r.Events {
+		if err := c.WriteJSON(flowmsg.SendMessage{OP: "chatEvent", Data: ev}); err != nil {
+			return err
+		}
+	}
+	return nil
+
+}
+
+// ClientRemove remove client from Session
+func (r *ChatRoom) ClientRemove(c *websocket.Conn) {
+	r.Lock()
+	defer r.Unlock()
+
+	var cli ChatClient
+	for i, cl := range r.clients {
+		if cl.ws == c {
+			cli = cl
+			r.clients = append(r.clients[:i], r.clients[i+1:]...)
+			break
+		}
+	}
+
+	{
+		dataMap := map[string]interface{}{
+			"handle": cli.Handle,
+			"type":   "left",
+			"time":   time.Now().UTC().Format(time.RFC3339),
+		}
+		r.Events = append(r.Events, dataMap)
+		r.broadcast(c, flowmsg.SendMessage{OP: "chatEvent", Data: dataMap})
+	}
+}
+
+// ChatEvent Message broad a cast a message to clients in session
+func (r *ChatRoom) Event(cl *websocket.Conn, v interface{}) error {
+	r.Lock()
+	defer r.Unlock()
+
+	r.Events = append(r.Events, v)
+
+	// Every one including self
+	return r.broadcast(nil, flowmsg.SendMessage{OP: "chatEvent", Data: v})
+}
+
+func (r *ChatRoom) broadcast(c *websocket.Conn, v interface{}) error {
+	for _, sc := range r.clients {
+		if sc.ws == c { // ours
+			continue
+		}
+		err := sc.ws.WriteJSON(v)
+		if err != nil {
+			return err
+		}
+	}
+	return nil
+
+}

+ 5 - 2
go/src/flowserver/msg/flowmessage.go

@@ -1,14 +1,17 @@
-package msg
+package flowmsg
 
 import "encoding/json"
 
-// FlowMessage Main message structure
+// RecvMessage Main message structure
 type RecvMessage struct {
 	OP   string          `json:"op"`
+	ID   string          `json:"id"` // destination sessId
 	Data json.RawMessage `json:"data"`
 }
 
+// SendMessage sendMessage structure
 type SendMessage struct {
 	OP   string      `json:"op"`
+	ID   string      `json:"id"`
 	Data interface{} `json:"data"`
 }

+ 3 - 5
go/src/flowserver/flowserver.go

@@ -7,6 +7,7 @@ import (
 	"net/http"
 	"net/http/httputil"
 	"net/url"
+	"os"
 
 	"github.com/gohxs/prettylog"
 	"github.com/gohxs/webu"
@@ -17,10 +18,6 @@ import (
 //go:generate genversion -package flowserver -out version.go
 //
 
-var (
-	debug = false
-)
-
 // FlowServer structure
 type FlowServer struct {
 }
@@ -35,7 +32,8 @@ func (f *FlowServer) ListenAndServe() error {
 	mux := http.NewServeMux()
 	mux.Handle("/conn", c.Build(fsm.ServeHTTP))
 
-	if debug {
+	if os.Getenv("DEBUG") == "1" {
+		log.Println("DEBUG MODE: reverse proxy localhost:8081")
 		proxyURL, err := url.Parse("http://localhost:8081")
 		if err != nil {
 			return err

+ 41 - 17
go/src/flowserver/session.go

@@ -2,27 +2,27 @@ package flowserver
 
 import (
 	"encoding/json"
-	"flowserver/msg"
+	"flowserver/flowmsg"
+	"log"
 	"sync"
 
 	"github.com/gorilla/websocket"
 )
 
-// This might be better named as a room/project than a session
-
 // FlowSession Create a session and link clients
 type FlowSession struct {
 	sync.Mutex
 	ID string // Random handle for sessionID
 	// List of clients on this session
 	clients []*websocket.Conn
+	Chat    ChatRoom
 
 	RawData []byte // Just share data
 }
 
 //NewSession creates and initializes a NewSession
 func NewSession(ID string) *FlowSession {
-	s := &FlowSession{sync.Mutex{}, ID, []*websocket.Conn{}, []byte{}}
+	s := &FlowSession{sync.Mutex{}, ID, []*websocket.Conn{}, ChatRoom{}, []byte{}}
 	return s
 }
 
@@ -35,7 +35,8 @@ func (f *FlowSession) ClientAdd(c *websocket.Conn) error {
 
 	dataMap := map[string]interface{}{}
 	json.Unmarshal(f.RawData, &dataMap)
-	return c.WriteJSON(msg.SendMessage{OP: "document", Data: dataMap})
+
+	return c.WriteJSON(flowmsg.SendMessage{OP: "document", Data: dataMap})
 
 }
 
@@ -43,43 +44,66 @@ func (f *FlowSession) ClientAdd(c *websocket.Conn) error {
 func (f *FlowSession) ClientRemove(c *websocket.Conn) {
 	f.Lock()
 	defer f.Unlock()
-
 	for i, cl := range f.clients {
 		if cl == c {
 			f.clients = append(f.clients[:i], f.clients[i+1:]...)
 			break
 		}
 	}
+	f.Chat.ClientRemove(c)
+}
+
+// Join the chat room on this session
+func (f *FlowSession) ChatJoin(c *websocket.Conn, handle string) {
+	f.Chat.ClientAdd(c, handle)
 }
 
 // DocumentUpdate client c Updates the session document
 func (f *FlowSession) DocumentUpdate(c *websocket.Conn, data []byte) error {
 	f.Lock()
 	defer f.Unlock()
+	log.Println("Document updating")
 
 	f.RawData = data // Update
+	dataMap := map[string]interface{}{}
+
+	// contextual lock
+	err := json.Unmarshal(f.RawData, &dataMap)
+	if err != nil {
+		return err
+	}
+
+	return f.broadcast(c, flowmsg.SendMessage{OP: "document", Data: dataMap})
+}
+
+// Document send document to client c
+func (f *FlowSession) Document(c *websocket.Conn) error {
+	f.Lock()
+	defer f.Unlock()
+
 	dataMap := map[string]interface{}{}
 	json.Unmarshal(f.RawData, &dataMap)
+	return c.WriteJSON(flowmsg.SendMessage{OP: "document", Data: dataMap})
+}
 
-	// Send to all except ours
+// Broadcast broadcast a message in session besides C
+func (f *FlowSession) Broadcast(c *websocket.Conn, v interface{}) error {
+	f.Lock()
+	defer f.Unlock()
+	return f.broadcast(c, v)
+}
+
+//
+func (f *FlowSession) broadcast(c *websocket.Conn, v interface{}) error {
 	for _, sc := range f.clients {
 		if sc == c { // ours
 			continue
 		}
-		err := sc.WriteJSON(msg.SendMessage{OP: "document", Data: dataMap})
+		err := sc.WriteJSON(v)
 		if err != nil {
 			return err
 		}
 	}
 	return nil
-}
 
-// Document send document to client c
-func (f *FlowSession) Document(c *websocket.Conn) error {
-	f.Lock()
-	defer f.Unlock()
-
-	dataMap := map[string]interface{}{}
-	json.Unmarshal(f.RawData, &dataMap)
-	return c.WriteJSON(msg.SendMessage{OP: "document", Data: dataMap})
 }

+ 44 - 10
go/src/flowserver/sessionmgr.go

@@ -3,7 +3,7 @@ package flowserver
 import (
 	"encoding/json"
 	"errors"
-	"flowserver/msg"
+	"flowserver/flowmsg"
 	"log"
 	"net/http"
 	"sync"
@@ -15,6 +15,7 @@ import (
 type FlowSessionManager struct {
 	// List of flow sessions?
 	sessions map[string]*FlowSession
+	chats    map[string]*ChatRoom
 
 	sync.Mutex
 }
@@ -94,14 +95,14 @@ func (fsm *FlowSessionManager) ServeHTTP(w http.ResponseWriter, r *http.Request)
 			break
 		}
 
-		fmsg := msg.RecvMessage{}
-		err = json.Unmarshal(data, &fmsg)
+		m := flowmsg.RecvMessage{}
+		err = json.Unmarshal(data, &m)
 		if err != nil {
 			log.Println("Err parsing message:", err)
 			c.WriteJSON("bye")
 			break
 		}
-		switch fmsg.OP {
+		switch m.OP {
 		/////////////////////////////
 		// NEWSESSION request
 		//////////////////
@@ -110,14 +111,15 @@ func (fsm *FlowSessionManager) ServeHTTP(w http.ResponseWriter, r *http.Request)
 				log.Println("We want a new session so")
 				sess = fsm.CreateSession()
 				sess.ClientAdd(c)
-				return c.WriteJSON(msg.SendMessage{OP: "sessionSwitch", Data: sess.ID})
+				return c.WriteJSON(flowmsg.SendMessage{OP: "sessionJoin", ID: sess.ID})
 			}()
 			//////////////////////////////////
 			// LOADSESSION request
 			////////////////////////////////////
 		case "sessionLoad":
 			err = func() error {
-				sessID := string(fmsg.Data)
+				sessID := string(m.ID)
+
 				if sess != nil {
 					sess.ClientRemove(c)
 				}
@@ -126,17 +128,49 @@ func (fsm *FlowSessionManager) ServeHTTP(w http.ResponseWriter, r *http.Request)
 					log.Println("Err:", err)
 					return err
 				}
-				return sess.ClientAdd(c)
+				err = sess.ClientAdd(c)
+				if err != nil {
+					return err
+				}
+				return c.WriteJSON(flowmsg.SendMessage{OP: "sessionJoin", ID: m.ID})
 			}()
+
 		///////////////////////
 		// DOCUMENTUPDATE Receive a document
 		//////
 		case "documentUpdate":
+			err = func() error {
+				sess, err = fsm.LoadSession(m.ID)
+				if err != nil {
+					return err
+				}
+				return sess.DocumentUpdate(c, m.Data)
+			}()
+		case "nodeUpdate":
+			err = func() error {
+				if sess == nil {
+					return errors.New("nodeUpdate: invalid session")
+				}
+				return sess.Broadcast(c, flowmsg.SendMessage{OP: m.OP, Data: m.Data})
+			}()
+		case "chatJoin":
+			err = func() error {
+				if sess == nil {
+					return nil // just do nothing
+				}
+				var handle string
+				json.Unmarshal(m.Data, &handle)
+				log.Println("Joining with:", handle)
+				sess.ChatJoin(c, handle)
+				return nil
+			}()
+		case "chatEvent":
 			err = func() error {
 				if sess == nil {
-					return errors.New("There is no session")
+					return errors.New("invalid session")
 				}
-				return sess.DocumentUpdate(c, fmsg.Data)
+				sess.Chat.Event(c, m.Data)
+				return nil
 			}()
 		}
 
@@ -145,5 +179,5 @@ func (fsm *FlowSessionManager) ServeHTTP(w http.ResponseWriter, r *http.Request)
 			break
 		}
 	}
-	log.Println("ws Is disconnecting")
+	log.Println("ws Is disconnecting", r.RemoteAddr)
 }