Skip to content

Commit

Permalink
feat(webui): support image
Browse files Browse the repository at this point in the history
  • Loading branch information
shigma committed Apr 1, 2021
1 parent ee7bf3d commit 7c1724a
Show file tree
Hide file tree
Showing 7 changed files with 174 additions and 16 deletions.
3 changes: 2 additions & 1 deletion packages/plugin-webui/client/components/input.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
@input="onInput"
@focus="onFocus"
@blur="onBlur"
@paste="$emit('paste', $event)"
@keydown.enter.stop="$emit('enter', $event)"
/>
<i v-if="suffix" :class="'fas fa-' + suffix" class="suffix" @click="$emit('clickSuffix')"/>
Expand Down Expand Up @@ -53,7 +54,7 @@ const inputStyle = computed(() => ({
paddingRight: +!!(props.suffix) + 1 + 'em',
}))
const emit = defineEmit(['update:modelValue', 'focus', 'blur', 'enter', 'clickPrefix', 'clickSuffix'])
const emit = defineEmit(['update:modelValue', 'paste', 'focus', 'blur', 'enter', 'clickPrefix', 'clickSuffix'])
function onInput (event) {
if (props.validate) {
Expand Down
29 changes: 29 additions & 0 deletions packages/plugin-webui/client/components/message.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<template>
<div class="k-message">
<template v-for="({ type, data }, index) in segment.parse(text)" :key="index">
<span v-if="type === 'text'">{{ data.content }}</span>
<img v-else-if="type === 'image'" :src="data.url || data.file"/>
</template>
</div>
</template>

<script lang="ts" setup>
import { defineProps } from 'vue'
import { segment } from '~/client'
defineProps<{
text: string
}>()
</script>

<style lang="scss" scoped>
.k-message {
img {
max-height: 400px;
}
}
</style>
98 changes: 98 additions & 0 deletions packages/plugin-webui/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,101 @@ export async function sha256(password: string) {
}
return output
}

export interface segment {
type: string
data: segment.Data
}

export function segment(type: string, data: segment.Data = {}) {
if (type === 'text') return String(data.content)
let output = '[CQ:' + type
for (const key in data) {
if (data[key]) output += `,${key}=${segment.escape(data[key], true)}`
}
return output + ']'
}

type primitive = string | number | boolean

export namespace segment {
export type Chain = segment.Parsed[]
export type Data = Record<string, primitive>
export type Transformer = string | ((data: Record<string, string>, index: number, chain: Chain) => string)

export interface Parsed extends segment {
data: Record<string, string>
capture?: RegExpExecArray
}

export function escape(source: any, inline = false) {
const result = String(source)
.replace(/&/g, '&amp;')
.replace(/\[/g, '&#91;')
.replace(/\]/g, '&#93;')
return inline
? result.replace(/,/g, '&#44;').replace(/(\ud83c[\udf00-\udfff])|(\ud83d[\udc00-\ude4f\ude80-\udeff])|[\u2600-\u2B55]/g, ' ')
: result
}

export function unescape(source: string) {
return String(source)
.replace(/&#91;/g, '[')
.replace(/&#93;/g, ']')
.replace(/&#44;/g, ',')
.replace(/&amp;/g, '&')
}

export function join(codes: segment[]) {
return codes.map(code => segment(code.type, code.data)).join('')
}

export function from(source: string, typeRegExp = '\\w+'): segment.Parsed {
const capture = new RegExp(`\\[CQ:(${typeRegExp})((,\\w+=[^,\\]]*)*)\\]`).exec(source)
if (!capture) return null
const [, type, attrs] = capture
const data: Record<string, string> = {}
attrs && attrs.slice(1).split(',').forEach((str) => {
const index = str.indexOf('=')
data[str.slice(0, index)] = unescape(str.slice(index + 1))
})
return { type, data, capture }
}

export function parse(source: string) {
const chain: Chain = []
let result: segment.Parsed
while ((result = from(source))) {
const { capture } = result
if (capture.index) {
chain.push({ type: 'text', data: { content: source.slice(0, capture.index) } })
}
chain.push(result)
source = source.slice(capture.index + capture[0].length)
}
if (source) chain.push({ type: 'text', data: { content: source } })
return chain
}

export function transform(source: string, rules: Record<string, Transformer>, dropOthers = false) {
return parse(source).map(({ type, data, capture }, index, chain) => {
const transformer = rules[type]
return typeof transformer === 'string' ? transformer
: typeof transformer === 'function' ? transformer(data, index, chain)
: dropOthers ? '' : type === 'text' ? data.content : capture[0]
}).join('')
}

export type Factory<T> = (value: T, data?: segment.Data) => string

function createFactory(type: string, key: string): Factory<primitive> {
return (value, data = {}) => segment(type, { ...data, [key]: value })
}

export const at = createFactory('at', 'id')
export const sharp = createFactory('sharp', 'id')
export const quote = createFactory('quote', 'id')
export const image = createFactory('image', 'url')
export const video = createFactory('video', 'url')
export const audio = createFactory('audio', 'url')
}
3 changes: 2 additions & 1 deletion packages/plugin-webui/client/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import Card from './components/card.vue'
import Collapse from './components/collapse.vue'
import Button from './components/button.vue'
import Input from './components/input.vue'
import Message from './components/message.vue'
import Numeric from './components/numeric.vue'
import App from './views/layout/index.vue'
import { start, user, receive, router } from '~/client'
Expand Down Expand Up @@ -62,9 +63,9 @@ router.addRoute({

app.component('k-card', Card)
app.component('k-button', Button)

app.component('k-collapse', Collapse)
app.component('k-input', Input)
app.component('k-message', Message)
app.component('k-numeric', Numeric)
app.component('k-chart', defineAsyncComponent(() => import('./components/echarts')))

Expand Down
2 changes: 1 addition & 1 deletion packages/plugin-webui/client/views/layout/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { useRoute } from 'vue-router'
const route = useRoute()
const frameless = computed(() => route.meta.frameless)
const loaded = computed(() => (route.meta.require || []).every((key) => client[key].value))
const invalid = computed(() => route.meta.authority > client.user.value.authority)
const invalid = computed(() => route.meta.authority > client.user.value?.authority)
</script>

Expand Down
39 changes: 30 additions & 9 deletions packages/plugin-webui/client/views/sandbox.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,17 @@
<k-card class="sandbox">
<div class="history" ref="panel">
<p v-for="({ from, content }, index) in messages" :key="index" :class="from">
{{ content }}
<k-message :text="content"/>
</p>
</div>
<k-input v-model="text" @enter="onEnter"></k-input>
<k-input v-model="text" @enter="onEnter" @paste="onPaste"></k-input>
</k-card>
</template>

<script lang="ts" setup>
import { ref, watch, nextTick, onMounted } from 'vue'
import { send, receive, user, storage } from '~/client'
import { send, receive, user, storage, segment } from '~/client'
interface Message {
from: 'user' | 'bot'
Expand All @@ -28,7 +28,7 @@ watch(user, () => messages.value = [])
function addMessage(from: 'user' | 'bot', content: string) {
messages.value.push({ from, content })
const { scrollTop, clientHeight, scrollHeight } = panel.value
if (Math.abs(scrollTop + clientHeight - scrollHeight) < 1) {
if (from === 'user' || Math.abs(scrollTop + clientHeight - scrollHeight) < 1) {
nextTick(scrollToBottom)
}
}
Expand All @@ -39,19 +39,40 @@ function scrollToBottom() {
panel.value.scrollTop = panel.value.scrollHeight - panel.value.clientHeight
}
function sendSandbox(content: string) {
const { token, id } = user.value
send('sandbox', { token, id, content })
}
function onEnter() {
if (!text.value) return
addMessage('user', text.value)
const { token, id } = user.value
send('sandbox', { token, id, content: text.value })
sendSandbox(text.value)
text.value = ''
}
receive('sandbox', (data) => {
async function onPaste(event) {
const item = event.clipboardData.items[0]
if (item.kind === 'file') {
event.preventDefault()
const file = item.getAsFile()
const reader = new FileReader()
reader.addEventListener('load', function () {
const { token, id } = user.value
sendSandbox(segment.image('base64://' + reader.result.slice(22)))
}, false)
reader.readAsDataURL(file)
}
}
receive('sandbox:bot', (data) => {
addMessage('bot', data)
})
receive('clear', (data) => {
receive('sandbox:user', (data) => {
addMessage('user', data)
})
receive('sandbox:clear', (data) => {
messages.value = []
})
Expand Down
16 changes: 12 additions & 4 deletions packages/plugin-webui/src/adapter.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Adapter, App, Bot, Context, Logger, omit, pick, Random, Session, Time, User } from 'koishi-core'
import { Adapter, App, Bot, Context, Logger, omit, pick, Random, remove, Session, Time, User } from 'koishi-core'
import { Profile } from './data'
import WebSocket from 'ws'

Expand Down Expand Up @@ -60,6 +60,8 @@ class SocketChannel {
async $sandbox({ id, token, content }) {
const user = await this.validate(id, token, ['name'])
if (!user) return
content = await this.app.transformAssets(content)
this.send('sandbox:user', content)
const session = new Session(this.app, {
platform: 'web',
userId: id,
Expand Down Expand Up @@ -88,7 +90,7 @@ export class SandboxBot extends Bot<'web'> {
}

async sendMessage(id: string, content: string) {
this.adapter.channels[id]?.send('sandbox', content)
this.adapter.channels[id]?.send('sandbox:bot', content)
return Random.uuid()
}
}
Expand All @@ -113,8 +115,11 @@ export class WebAdapter extends Adapter<'web'> {
server: ctx.app._httpServer,
})

ctx.self('sandbox').command('clear', '清空消息列表')
.action(({ session }) => this.channels[session.channelId].send('clear'))
ctx.self('sandbox')
.command('clear', '清空消息列表')
.action(({ session }) => {
this.channels[session.channelId].send('sandbox:clear')
})

ctx.all().middleware(async (session, next) => {
if (session.subtype !== 'private') return next()
Expand Down Expand Up @@ -161,5 +166,8 @@ export class WebAdapter extends Adapter<'web'> {

stop() {
this.server.close()
for (const bot of this.bots) {
remove(this.app.bots, bot as Bot)
}
}
}

0 comments on commit 7c1724a

Please sign in to comment.