mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
Merge pull request #74651 from NousResearch/bb/composer-paste-directives
Paste directives into the composer
This commit is contained in:
commit
866c9adae3
7 changed files with 348 additions and 65 deletions
|
|
@ -121,7 +121,7 @@ export function useComposerDraft({
|
|||
const editor = editorRef.current
|
||||
|
||||
if (editor) {
|
||||
renderComposerContents(editor, next)
|
||||
renderComposerContents(editor, next, { trailingCommitted: true })
|
||||
placeCaretEnd(editor)
|
||||
}
|
||||
|
||||
|
|
@ -265,7 +265,7 @@ export function useComposerDraft({
|
|||
const editor = editorRef.current
|
||||
|
||||
if (editor && document.activeElement !== editor && composerPlainText(editor) !== text) {
|
||||
renderComposerContents(editor, text)
|
||||
renderComposerContents(editor, text, { trailingCommitted: true })
|
||||
}
|
||||
|
||||
if (isBrowsingHistory(sessionIdRef.current) || queueEditRef.current) {
|
||||
|
|
|
|||
|
|
@ -230,6 +230,82 @@ describe('insertComposerContentsAtCaret', () => {
|
|||
|
||||
editor.remove()
|
||||
})
|
||||
|
||||
// A directive typed by hand chips; the same directive pasted has to chip too,
|
||||
// or copy/pasting a prompt silently drops every command in it.
|
||||
it('chips a pasted slash command, including one that ends the paste', () => {
|
||||
const editor = document.createElement('div')
|
||||
editor.dataset.slot = RICH_INPUT_SLOT
|
||||
document.body.append(editor)
|
||||
caretIn(editor)
|
||||
|
||||
insertComposerContentsAtCaret(editor, '/some-skill')
|
||||
|
||||
expect(editor.querySelector('[data-slash-kind]')?.getAttribute('data-ref-text')).toBe('/some-skill')
|
||||
// Committed pills carry the trailing space the typed path appends, so a
|
||||
// later full re-render doesn't read the token as half-typed.
|
||||
expect(composerPlainText(editor)).toBe('/some-skill ')
|
||||
|
||||
editor.remove()
|
||||
})
|
||||
|
||||
it('chips a skill named mid-paste alongside a ref', () => {
|
||||
const editor = document.createElement('div')
|
||||
editor.dataset.slot = RICH_INPUT_SLOT
|
||||
document.body.append(editor)
|
||||
caretIn(editor)
|
||||
|
||||
insertComposerContentsAtCaret(editor, 'clean @file:`a.ts` with /some-skill then ship')
|
||||
|
||||
expect(editor.querySelectorAll('[data-slash-kind]').length).toBe(1)
|
||||
expect(editor.querySelectorAll('[data-ref-kind="file"]').length).toBe(1)
|
||||
expect(composerPlainText(editor)).toBe('clean @file:`a.ts` with /some-skill then ship')
|
||||
|
||||
editor.remove()
|
||||
})
|
||||
|
||||
it('leaves a pasted path alone — /usr/local is not a command', () => {
|
||||
const editor = document.createElement('div')
|
||||
editor.dataset.slot = RICH_INPUT_SLOT
|
||||
document.body.append(editor)
|
||||
caretIn(editor)
|
||||
|
||||
insertComposerContentsAtCaret(editor, 'see /usr/local/bin and /goal ship it')
|
||||
|
||||
expect(editor.querySelector('[data-slash-kind]')).toBeNull()
|
||||
expect(composerPlainText(editor)).toBe('see /usr/local/bin and /goal ship it')
|
||||
|
||||
editor.remove()
|
||||
})
|
||||
|
||||
it('does not chip a command pasted against a word — foo/clean is not a command', () => {
|
||||
const editor = document.createElement('div')
|
||||
editor.dataset.slot = RICH_INPUT_SLOT
|
||||
editor.textContent = 'foo'
|
||||
document.body.append(editor)
|
||||
caretIn(editor)
|
||||
|
||||
insertComposerContentsAtCaret(editor, '/some-skill')
|
||||
|
||||
expect(editor.querySelector('[data-slash-kind]')).toBeNull()
|
||||
expect(composerPlainText(editor)).toBe('foo/some-skill')
|
||||
|
||||
editor.remove()
|
||||
})
|
||||
|
||||
it('chips a command pasted right after an existing chip', () => {
|
||||
const editor = document.createElement('div')
|
||||
editor.dataset.slot = RICH_INPUT_SLOT
|
||||
editor.append(refChipElement('file', '`a.ts`'))
|
||||
document.body.append(editor)
|
||||
caretIn(editor)
|
||||
|
||||
insertComposerContentsAtCaret(editor, '/some-skill')
|
||||
|
||||
expect(editor.querySelector('[data-slash-kind]')).not.toBeNull()
|
||||
|
||||
editor.remove()
|
||||
})
|
||||
})
|
||||
|
||||
describe('replaceBeforeCaret', () => {
|
||||
|
|
|
|||
|
|
@ -16,22 +16,13 @@ import {
|
|||
type SlashChipKind,
|
||||
slashIconElement
|
||||
} from '@/components/assistant-ui/directive-text'
|
||||
import {
|
||||
desktopSlashCommandArgumentMode,
|
||||
isDesktopSlashCommand,
|
||||
resolveDesktopCommand
|
||||
} from '@/lib/desktop-slash-commands'
|
||||
|
||||
import { slashCommandMatches, type SlashCommandScanOptions } from './slash-refs'
|
||||
|
||||
export const RICH_INPUT_SLOT = 'composer-rich-input'
|
||||
|
||||
export const REF_RE = /@(file|folder|url|image|tool|line|terminal|session):(`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)/g
|
||||
|
||||
/** A committed leading slash command: `/name` followed by whitespace. The
|
||||
* whitespace requirement is what separates a committed command (chips always
|
||||
* serialize with their auto-inserted trailing space) from one still being
|
||||
* typed, which must stay editable text. */
|
||||
const LEADING_SLASH_COMMAND_RE = /^\/[a-zA-Z][\w-]*(?=\s)/
|
||||
|
||||
const ESC: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
|
||||
|
||||
export function escapeHtml(value: string) {
|
||||
|
|
@ -123,42 +114,59 @@ function appendTextWithBreaks(target: DocumentFragment | HTMLElement, text: stri
|
|||
})
|
||||
}
|
||||
|
||||
export function appendComposerContents(target: DocumentFragment | HTMLElement, text: string) {
|
||||
let cursor = 0
|
||||
|
||||
/** Every span of `text` that renders as a chip, in source order. */
|
||||
function chipSpans(text: string, options: SlashCommandScanOptions) {
|
||||
REF_RE.lastIndex = 0
|
||||
|
||||
for (const match of text.matchAll(REF_RE)) {
|
||||
const index = match.index ?? 0
|
||||
appendTextWithBreaks(target, text.slice(cursor, index))
|
||||
target.append(refChipElement(match[1] || 'file', match[2] || ''))
|
||||
cursor = index + match[0].length
|
||||
const refs = Array.from(text.matchAll(REF_RE)).map(match => {
|
||||
const start = match.index ?? 0
|
||||
|
||||
return { end: start + match[0].length, node: () => refChipElement(match[1] || 'file', match[2] || ''), start }
|
||||
})
|
||||
|
||||
const commands = slashCommandMatches(text, options).map(match => ({
|
||||
end: match.end,
|
||||
node: () => slashChipElement(match.command, match.kind),
|
||||
start: match.start
|
||||
}))
|
||||
|
||||
return [...refs, ...commands].sort((a, b) => a.start - b.start)
|
||||
}
|
||||
|
||||
/** Build the chip/text DOM for `text`. Directives hydrate back to their pills —
|
||||
* `@kind:value` refs and `/command` invocations both — so text that arrives
|
||||
* whole (a paste, a restored draft, an undo step, a rebuilt line) carries the
|
||||
* same chips the typed path would have committed. */
|
||||
export function appendComposerContents(
|
||||
target: DocumentFragment | HTMLElement,
|
||||
text: string,
|
||||
options: SlashCommandScanOptions = {}
|
||||
) {
|
||||
let cursor = 0
|
||||
|
||||
for (const span of chipSpans(text, options)) {
|
||||
// A `@` ref wins an overlap: a command token can't contain an `@`, so the
|
||||
// only way spans collide is a slash inside a quoted ref value
|
||||
// (`` @url:`a /clean` ``), which belongs to that value.
|
||||
if (span.start < cursor) {
|
||||
continue
|
||||
}
|
||||
|
||||
appendTextWithBreaks(target, text.slice(cursor, span.start))
|
||||
target.append(span.node())
|
||||
cursor = span.end
|
||||
}
|
||||
|
||||
appendTextWithBreaks(target, text.slice(cursor))
|
||||
}
|
||||
|
||||
export function renderComposerContents(target: HTMLElement, text: string) {
|
||||
export function renderComposerContents(target: HTMLElement, text: string, options?: SlashCommandScanOptions) {
|
||||
target.replaceChildren()
|
||||
|
||||
// A leading `/command` hydrates back to its pill — parity with REF_RE for
|
||||
// `@` refs, so a full re-render from serialized text (draft restore, undo,
|
||||
// the trigger commit fallback) doesn't demote a committed command chip to
|
||||
// plain text. Only commands with NO argument stage qualify (skills, quick
|
||||
// commands, no-arg built-ins): their committed pill is exactly the bare
|
||||
// `/name`, so the boundary is unambiguous. Arg-taking commands (`/goal ship
|
||||
// it`, `/personality alice`) stay text — their tail may be prose that was
|
||||
// never committed. The trailing whitespace is load-bearing too: a committed
|
||||
// pill always serializes with its auto-inserted space, while a half-typed
|
||||
// `/wor` must stay editable text.
|
||||
const command = LEADING_SLASH_COMMAND_RE.exec(text)?.[0]
|
||||
|
||||
if (command && isDesktopSlashCommand(command) && desktopSlashCommandArgumentMode(command) === null) {
|
||||
target.append(slashChipElement(command, resolveDesktopCommand(command) ? 'command' : 'skill'))
|
||||
text = text.slice(command.length)
|
||||
}
|
||||
|
||||
appendComposerContents(target, text)
|
||||
// Defaults to live editing, where a token ending the text is still being
|
||||
// typed (`/wor`) and must stay editable. Callers repainting inert text (a
|
||||
// restored draft, a sent message opened for edit) pass `trailingCommitted`.
|
||||
appendComposerContents(target, text, options)
|
||||
}
|
||||
|
||||
/** Caret range when the selection lives inside `editor`; else null. */
|
||||
|
|
@ -173,20 +181,79 @@ function composerSelectionRange(editor: HTMLElement) {
|
|||
return { range, selection }
|
||||
}
|
||||
|
||||
/** Insert text at the caret (replacing any selection), with any `@kind:value`
|
||||
* directives in it landing as chips. Pastes use this instead of
|
||||
* `execCommand('insertText')` — Chromium's editing pipeline is ~O(n²) on large
|
||||
* multiline blobs. */
|
||||
/** Serialized text from the editor's start up to (`container`, `offset`).
|
||||
*
|
||||
* Chips are ATOMIC here: each contributes an object-replacement placeholder
|
||||
* rather than leaking its label text, and a <br> contributes a newline. That
|
||||
* makes a chip edge read as a token boundary, which is what both trigger
|
||||
* detection and directive recognition need. */
|
||||
export function serializeTextBefore(editor: HTMLElement, container: Node, offset: number): string {
|
||||
const probe = document.createRange()
|
||||
|
||||
probe.selectNodeContents(editor)
|
||||
probe.setEnd(container, offset)
|
||||
|
||||
const scratch = document.createElement('div')
|
||||
|
||||
scratch.append(probe.cloneContents())
|
||||
|
||||
for (const chip of scratch.querySelectorAll('[data-ref-text]')) {
|
||||
chip.replaceWith('\uFFFC')
|
||||
}
|
||||
|
||||
for (const br of scratch.querySelectorAll('br')) {
|
||||
br.replaceWith('\n')
|
||||
}
|
||||
|
||||
return scratch.textContent ?? ''
|
||||
}
|
||||
|
||||
/** True when the insertion point starts a token — the editor's start, or after
|
||||
* whitespace or a chip. `foo` + a pasted `/clean` is `foo/clean`, not a
|
||||
* command; `foo ` + the same paste is. */
|
||||
function atTokenBoundary(editor: HTMLElement, range: Range | null): boolean {
|
||||
// No caret means the insert lands at the end, so the question is about the
|
||||
// editor's last character either way.
|
||||
const before = range
|
||||
? serializeTextBefore(editor, range.startContainer, range.startOffset)
|
||||
: serializeTextBefore(editor, editor, editor.childNodes.length)
|
||||
|
||||
const last = before.slice(-1)
|
||||
|
||||
return !last || /[\s\uFFFC]/.test(last)
|
||||
}
|
||||
|
||||
/** Insert text at the caret (replacing any selection), with any directives in
|
||||
* it landing as chips. Pastes use this instead of `execCommand('insertText')`
|
||||
* — Chromium's editing pipeline is ~O(n²) on large multiline blobs.
|
||||
*
|
||||
* The text arrives whole rather than typed, so a `/command` ending it is
|
||||
* complete rather than half-written and chips like the rest. */
|
||||
export function insertComposerContentsAtCaret(editor: HTMLElement, text: string) {
|
||||
const hit = composerSelectionRange(editor)
|
||||
const fragment = document.createDocumentFragment()
|
||||
|
||||
appendComposerContents(fragment, text)
|
||||
// Before measuring the boundary — a replaced selection puts the insertion
|
||||
// point where the selection started, not where it ended.
|
||||
if (hit) {
|
||||
hit.range.deleteContents()
|
||||
}
|
||||
|
||||
appendComposerContents(fragment, text, {
|
||||
boundaryBefore: atTokenBoundary(editor, hit?.range ?? null),
|
||||
trailingCommitted: true
|
||||
})
|
||||
|
||||
// A slash pill ending the insert gets the trailing space the typed commit
|
||||
// path appends, or the next full re-render reads it as a half-typed token
|
||||
// and demotes it. `@` refs need no marker — REF_RE re-chips them either way.
|
||||
if ((fragment.lastChild as HTMLElement | null)?.dataset?.slashKind) {
|
||||
fragment.append(document.createTextNode(' '))
|
||||
}
|
||||
|
||||
const tail = fragment.lastChild
|
||||
|
||||
if (hit) {
|
||||
hit.range.deleteContents()
|
||||
hit.range.insertNode(fragment)
|
||||
} else {
|
||||
editor.append(fragment)
|
||||
|
|
|
|||
45
apps/desktop/src/app/chat/composer/slash-refs.test.ts
Normal file
45
apps/desktop/src/app/chat/composer/slash-refs.test.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { slashCommandMatches } from './slash-refs'
|
||||
|
||||
const commands = (text: string, options?: Parameters<typeof slashCommandMatches>[1]) =>
|
||||
slashCommandMatches(text, options).map(match => `${match.kind}:${match.command}`)
|
||||
|
||||
describe('slashCommandMatches', () => {
|
||||
it('recognizes a leading command and a skill named mid-prose', () => {
|
||||
expect(commands('/some-skill clean this with /other-skill please')).toEqual([
|
||||
'skill:/some-skill',
|
||||
'skill:/other-skill'
|
||||
])
|
||||
})
|
||||
|
||||
it('leaves a path alone — /usr/local/bin is not a command', () => {
|
||||
expect(commands('see /usr/local/bin ')).toEqual([])
|
||||
})
|
||||
|
||||
it('holds a trailing token as still-typed unless the text is inert', () => {
|
||||
expect(commands('/some-skill')).toEqual([])
|
||||
expect(commands('/some-skill', { trailingCommitted: true })).toEqual(['skill:/some-skill'])
|
||||
})
|
||||
|
||||
it('leaves an arg-taking command as text — its tail may be prose', () => {
|
||||
expect(commands('/goal ship the redesign')).toEqual([])
|
||||
})
|
||||
|
||||
it('leaves a command with no desktop surface as text', () => {
|
||||
expect(commands('/exit now')).toEqual([])
|
||||
})
|
||||
|
||||
it('offers a built-in only as an invocation, never mid-message', () => {
|
||||
// Mirrors what the popover offers: `/new` acts on the app, so it means
|
||||
// nothing dropped into a sentence, while a skill reads as "handle this
|
||||
// part with X".
|
||||
expect(commands('/new ')).toEqual(['command:/new'])
|
||||
expect(commands('start over with /new ')).toEqual([])
|
||||
expect(commands('start over with /some-skill ')).toEqual(['skill:/some-skill'])
|
||||
})
|
||||
|
||||
it('disqualifies a leading token when the text lands mid-word', () => {
|
||||
expect(commands('/some-skill ', { boundaryBefore: false })).toEqual([])
|
||||
})
|
||||
})
|
||||
105
apps/desktop/src/app/chat/composer/slash-refs.ts
Normal file
105
apps/desktop/src/app/chat/composer/slash-refs.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
/**
|
||||
* Slash-command recognition for text the composer did not watch being typed —
|
||||
* a paste, a restored draft, an undo step, a rebuilt line.
|
||||
*
|
||||
* The typed path chips a command as it's picked or accepted, so the composer
|
||||
* agrees with what the sent message renders (`SLASH_SKILL_RE` in
|
||||
* directive-text). Text that arrives whole never passed through that path, so
|
||||
* it needs the same commands recognized in place — on exactly the terms the
|
||||
* typed path would have used, or hydration invents pills the popover would
|
||||
* never have committed.
|
||||
*/
|
||||
import type { SlashChipKind } from '@/components/assistant-ui/directive-text'
|
||||
import {
|
||||
desktopSlashCommandArgumentMode,
|
||||
isDesktopSlashCommand,
|
||||
resolveDesktopCommand
|
||||
} from '@/lib/desktop-slash-commands'
|
||||
|
||||
// A command token starts a word and doesn't continue into a path: `/usr/local`
|
||||
// is a path, not a `/usr` command. Same shape the sent message uses to decide
|
||||
// what renders as a pill, so the composer and the transcript agree.
|
||||
const SLASH_COMMAND_RE = /(?<=^|\s)\/([a-zA-Z][\w-]*)(?![\w-]*\/)/g
|
||||
|
||||
export interface SlashCommandMatch {
|
||||
/** The command with its leading slash, e.g. `/clean`. */
|
||||
command: string
|
||||
end: number
|
||||
kind: SlashChipKind
|
||||
start: number
|
||||
}
|
||||
|
||||
export interface SlashCommandScanOptions {
|
||||
/**
|
||||
* Whether the text is preceded by a token boundary. False when it's being
|
||||
* inserted mid-word (a paste landing against existing characters), which
|
||||
* disqualifies a token at index 0 — `foo/clean` is not a command. It also
|
||||
* makes that token mid-message rather than an invocation.
|
||||
*/
|
||||
boundaryBefore?: boolean
|
||||
/**
|
||||
* Whether a token ending the text counts as committed. True for inert text
|
||||
* (a paste, dropped content): nothing is being typed, so `/clean` at the end
|
||||
* is the whole command. False while editing live, where a trailing `/wor` is
|
||||
* a half-typed query the popover owns and must leave editable.
|
||||
*/
|
||||
trailingCommitted?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Only commands with NO argument stage chip: their committed pill is exactly
|
||||
* the bare `/name`, so the boundary is unambiguous. Arg-taking commands
|
||||
* (`/goal ship it`) stay text — their tail may be prose. Commands with no
|
||||
* desktop surface at all (`/exit`, `/config`) stay text too.
|
||||
*/
|
||||
function chippableKind(command: string): SlashChipKind | null {
|
||||
if (!isDesktopSlashCommand(command) || desktopSlashCommandArgumentMode(command) !== null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return resolveDesktopCommand(command) ? 'command' : 'skill'
|
||||
}
|
||||
|
||||
/** Every `/command` in `text` that should render as a pill, in source order. */
|
||||
export function slashCommandMatches(text: string, options: SlashCommandScanOptions = {}): SlashCommandMatch[] {
|
||||
const { boundaryBefore = true, trailingCommitted = false } = options
|
||||
|
||||
if (!text.includes('/')) {
|
||||
return []
|
||||
}
|
||||
|
||||
const matches: SlashCommandMatch[] = []
|
||||
|
||||
for (const match of text.matchAll(SLASH_COMMAND_RE)) {
|
||||
const start = match.index ?? 0
|
||||
const command = match[0]
|
||||
const end = start + command.length
|
||||
const after = text[end]
|
||||
|
||||
// A committed pill always carries its auto-inserted trailing space, which
|
||||
// is what separates it from a token still being typed.
|
||||
if (after === undefined ? !trailingCommitted : !/\s/.test(after)) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Only the FIRST token can be an invocation, and only when the text lands
|
||||
// on a token boundary — `foo` + a pasted `/clean` is `foo/clean`.
|
||||
const invocation = start === 0
|
||||
|
||||
if (invocation && !boundaryBefore) {
|
||||
continue
|
||||
}
|
||||
|
||||
const kind = chippableKind(command)
|
||||
|
||||
// Later tokens are references dropped into prose, where the popover offers
|
||||
// SKILLS alone — a built-in like `/new` acts on the app and means nothing
|
||||
// mid-sentence. Hydration has to agree, or pasted text grows pills typing
|
||||
// never would.
|
||||
if (kind && (invocation || kind === 'skill')) {
|
||||
matches.push({ command, end, kind, start })
|
||||
}
|
||||
}
|
||||
|
||||
return matches
|
||||
}
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
import { DATA_IMAGE_URL_RE, dataUrlToBlob } from '@/lib/embedded-images'
|
||||
import { $reactionsEnabled } from '@/store/reactions-enabled'
|
||||
|
||||
import { serializeTextBefore } from './rich-editor'
|
||||
|
||||
export interface TriggerState {
|
||||
/** True for a `/` typed mid-message — an inline skill/command reference in
|
||||
* prose rather than a command invocation. Arg completion doesn't apply. */
|
||||
|
|
@ -141,23 +143,7 @@ export function textBeforeCaret(editor: HTMLDivElement): string | null {
|
|||
return null
|
||||
}
|
||||
|
||||
const before = range.cloneRange()
|
||||
before.selectNodeContents(editor)
|
||||
before.setEnd(range.startContainer, range.startOffset)
|
||||
|
||||
const scratch = document.createElement('div')
|
||||
|
||||
scratch.append(before.cloneContents())
|
||||
|
||||
for (const chip of scratch.querySelectorAll('[data-ref-text]')) {
|
||||
chip.replaceWith('\uFFFC')
|
||||
}
|
||||
|
||||
for (const br of scratch.querySelectorAll('br')) {
|
||||
br.replaceWith('\n')
|
||||
}
|
||||
|
||||
return scratch.textContent ?? ''
|
||||
return serializeTextBefore(editor, range.startContainer, range.startOffset)
|
||||
}
|
||||
|
||||
export function detectTrigger(textBefore: string): TriggerState | null {
|
||||
|
|
|
|||
|
|
@ -168,7 +168,7 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
|
|||
const editor = editorRef.current
|
||||
|
||||
if (editor) {
|
||||
renderComposerContents(editor, next)
|
||||
renderComposerContents(editor, next, { trailingCommitted: true })
|
||||
placeCaretEnd(editor)
|
||||
}
|
||||
|
||||
|
|
@ -187,7 +187,11 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
|
|||
editor &&
|
||||
(editor.childNodes.length === 0 || (document.activeElement !== editor && composerPlainText(editor) !== draft))
|
||||
) {
|
||||
renderComposerContents(editor, draft)
|
||||
// Inert by construction — this repaints on mount or when the editor
|
||||
// isn't the one being typed into. A message opened for edit is finished
|
||||
// text, so a `/command` ending it is committed and chips, matching how
|
||||
// the transcript rendered that same message a moment ago.
|
||||
renderComposerContents(editor, draft, { trailingCommitted: true })
|
||||
|
||||
if (document.activeElement === editor) {
|
||||
placeCaretEnd(editor)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue