chore: bootstrap hermes-ui skeleton from 0.6.29 snapshot
Build / build (push) Canceled after 0s
NPM Lockfile Check / npm ci --ignore-scripts (push) Canceled after 0s
Playwright / e2e (push) Canceled after 0s

Import the client, server, skills, tests, and build scripts as the starting
point for the rebuild, then trim the surfaces this project does not ship:
Electron desktop packaging, the marketing website, and the ESP32 firmware
tree. CI keeps the web build, e2e, lockfile, release, and Docker workflows.

Rename the package and CLI entry points to hermes-ui, drop the prepare hook so
npm ci no longer triggers a build, and drop the removed tsconfig and nodemon
references. Rewrite AGENTS.md, ARCHITECTURE.md, DEVELOPMENT.md, and the READMEs
so the documented package map and hard rules match this repository.

reference_code/ is ignored: it is a read-only snapshot, not an input to the build.
This commit is contained in:
2026-10-16 10:34:35 +08:00
commit 1e9ea89d86
912 changed files with 287328 additions and 0 deletions
@@ -0,0 +1,168 @@
<script setup lang="ts">
import { ref, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { NInput, NButton, NSpace, NInputNumber, NCollapse, NCollapseItem } from 'naive-ui'
import { getStoredUsername } from '@/api/client'
import FolderPicker from '@/components/hermes/chat/FolderPicker.vue'
type InputLikeInstance = {
focus: () => void
}
const { t } = useI18n()
const emit = defineEmits<{
submit: [name: string, inviteCode: string, userName: string, description: string, compression: { triggerTokens: number; maxHistoryTokens: number; tailMessageCount: number }, workspace: string]
cancel: []
}>()
const roomName = ref('')
const inviteCode = ref('')
const workspace = ref('')
const userName = ref(localStorage.getItem('gc_user_name') || getStoredUsername() || '')
const description = ref(localStorage.getItem('gc_user_description') || '')
const roomInput = ref<InputLikeInstance | null>(null)
const compression = ref({
triggerTokens: 100000,
maxHistoryTokens: 32000,
tailMessageCount: 10,
})
function generateCode(): string {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
let code = ''
for (let i = 0; i < 6; i++) {
code += chars[Math.floor(Math.random() * chars.length)]
}
return code
}
function handleCreate() {
const name = roomName.value.trim()
const code = inviteCode.value.trim() || generateCode()
const user = userName.value.trim()
if (!name || !user) return
emit('submit', name, code, user, description.value.trim(), { ...compression.value }, workspace.value || '')
}
function focusRoomInput() {
nextTick(() => roomInput.value?.focus())
}
</script>
<template>
<div class="create-form">
<div class="form-group">
<label class="form-label">{{ t('groupChat.yourName') }}</label>
<NInput
v-model:value="userName"
:placeholder="t('groupChat.yourNamePlaceholder')"
@keyup.enter="focusRoomInput"
/>
</div>
<div class="form-group">
<label class="form-label">{{ t('groupChat.yourDescription') }}</label>
<NInput
v-model:value="description"
type="textarea"
:rows="2"
:placeholder="t('groupChat.yourDescriptionPlaceholder')"
/>
</div>
<div class="form-group">
<label class="form-label">{{ t('groupChat.roomName') }}</label>
<NInput
ref="roomInput"
v-model:value="roomName"
:placeholder="t('groupChat.roomNamePlaceholder')"
@keyup.enter="handleCreate"
/>
</div>
<div class="form-group">
<label class="form-label">{{ t('groupChat.inviteCode') }}</label>
<div class="code-row">
<NInput
v-model:value="inviteCode"
:placeholder="t('groupChat.autoGenerate')"
/>
<NButton size="small" @click="inviteCode = generateCode()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="23 4 23 10 17 10" /><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
</svg>
</NButton>
</div>
</div>
<div class="form-group">
<label class="form-label">{{ t('chat.workspace') }}</label>
<FolderPicker v-model="workspace" />
</div>
<NCollapse class="compression-collapse">
<NCollapseItem :title="t('groupChat.compressionSettings')" name="compression">
<div class="compression-fields">
<div class="form-group">
<label class="form-label">{{ t('groupChat.triggerTokens') }}</label>
<NInputNumber v-model:value="compression.triggerTokens" :min="1000" :step="1000" style="width: 100%" />
<p class="form-hint">{{ t('groupChat.triggerTokensDesc') }}</p>
</div>
<div class="form-group">
<label class="form-label">{{ t('groupChat.maxHistoryTokens') }}</label>
<NInputNumber v-model:value="compression.maxHistoryTokens" :min="1000" :step="1000" style="width: 100%" />
<p class="form-hint">{{ t('groupChat.maxHistoryTokensDesc') }}</p>
</div>
<div class="form-group">
<label class="form-label">{{ t('groupChat.tailMessageCount') }}</label>
<NInputNumber v-model:value="compression.tailMessageCount" :min="1" :step="5" style="width: 100%" />
<p class="form-hint">{{ t('groupChat.tailMessageCountDesc') }}</p>
</div>
</div>
</NCollapseItem>
</NCollapse>
<div class="modal-actions">
<NSpace justify="end">
<NButton @click="emit('cancel')">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :disabled="!roomName.trim() || !userName.trim()" @click="handleCreate">{{ t('common.create') }}</NButton>
</NSpace>
</div>
</div>
</template>
<style scoped lang="scss">
@use "@/styles/variables" as *;
.create-form {
.form-group {
margin-bottom: 16px;
}
}
.form-label {
display: block;
font-size: 13px;
font-weight: 500;
color: $text-secondary;
margin-bottom: 6px;
}
.form-hint {
font-size: 11px;
color: $text-muted;
margin: 4px 0 0;
}
.code-row {
display: flex;
gap: 8px;
align-items: center;
}
.compression-collapse {
margin-bottom: 16px;
}
.compression-fields {
padding-top: 8px;
}
</style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,269 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { useGroupChatStore } from '@/stores/hermes/group-chat'
import { useToolTraceVisibility } from '@/composables/useToolTraceVisibility'
import GroupMessageItem from './GroupMessageItem.vue'
import VirtualMessageList from '../chat/VirtualMessageList.vue'
const store = useGroupChatStore()
const { t } = useI18n()
const { toolTraceVisible } = useToolTraceVisibility()
const listRef = ref<InstanceType<typeof VirtualMessageList> | null>(null)
const showScrollBottomButton = ref(false)
const displayMessages = computed(() => store.sortedMessages.filter(msg =>
msg.role !== 'tool' ||
toolTraceVisible.value ||
msg.toolStatus === 'running' ||
msg.toolName === 'workspace_diff' ||
msg.tool_name === 'workspace_diff',
))
const listPadding = computed(() => store.activePendingApproval ? '16px 20px 260px' : '16px 20px')
let pendingInitialBottomRoomId: string | null = store.currentRoomId
type BottomScrollOptions = number | {
frames?: number
keepAliveMs?: number
}
function scrollToBottom(options?: BottomScrollOptions): void {
const list = listRef.value as (InstanceType<typeof VirtualMessageList> & {
scrollToBottom: (options?: BottomScrollOptions) => void
}) | null
list?.scrollToBottom(options)
showScrollBottomButton.value = false
}
function updateScrollBottomButton(): void {
showScrollBottomButton.value = displayMessages.value.length > 0 && !(listRef.value?.isNearBottom(1000) ?? true)
}
function handleListScroll(): void {
updateScrollBottomButton()
}
function handleScrollBottomClick(): void {
scrollToBottom({ frames: 4, keepAliveMs: 600 })
}
async function handleTopReach(): Promise<void> {
if (!store.hasMoreBefore || store.isLoadingOlderMessages || store.hasReachedMessageDisplayLimit) return
const snapshot = listRef.value?.captureScrollPosition() ?? null
const loaded = await store.loadOlderMessages()
if (!loaded) return
await nextTick()
listRef.value?.restoreScrollPosition(snapshot)
updateScrollBottomButton()
}
watch(() => store.currentRoomId, (roomId) => {
pendingInitialBottomRoomId = roomId
})
watch(() => displayMessages.value.map(msg => [
msg.id,
msg.content?.length ?? 0,
msg.reasoning?.length ?? 0,
msg.reasoning_content?.length ?? 0,
msg.toolStatus ?? '',
].join(':')).join('|'), async () => {
const shouldForceInitialBottom = !!store.currentRoomId &&
pendingInitialBottomRoomId === store.currentRoomId &&
displayMessages.value.length > 0
const shouldScroll = shouldForceInitialBottom || (listRef.value?.isNearBottom(200) ?? true)
await nextTick()
if (shouldScroll) {
scrollToBottom(shouldForceInitialBottom ? { frames: 5, keepAliveMs: 700 } : { frames: 1, keepAliveMs: 120 })
if (shouldForceInitialBottom) pendingInitialBottomRoomId = null
}
updateScrollBottomButton()
})
onMounted(async () => {
if (!store.currentRoomId || displayMessages.value.length === 0) return
pendingInitialBottomRoomId = null
await nextTick()
scrollToBottom({ frames: 5, keepAliveMs: 700 })
updateScrollBottomButton()
})
defineExpose({ scrollToBottom })
</script>
<template>
<div class="group-message-list-shell">
<VirtualMessageList
class="group-message-list"
ref="listRef"
:messages="displayMessages"
:virtualized="false"
:estimated-item-height="170"
:row-gap="12"
:padding="listPadding"
@scroll="handleListScroll"
@top-reach="handleTopReach"
>
<template #empty>
<div class="empty-state">
<img :src="'/coding-agents/hermes.png'" alt="Hermes" class="empty-logo" />
<p>{{ t("chat.emptyState") }}</p>
</div>
</template>
<template #before>
<div
v-if="store.hasReachedMessageDisplayLimit"
class="history-limit-notice"
>
{{ t('groupChat.messageDisplayLimit') }}
</div>
<div
v-else-if="store.hasMoreBefore || store.isLoadingOlderMessages"
class="history-loader"
>
<span v-if="store.isLoadingOlderMessages" class="history-loader-spinner"></span>
</div>
</template>
<template #item="{ message: msg }">
<GroupMessageItem
:message="msg"
:agents="store.agents"
:members="store.members"
:current-user-id="store.userId"
/>
</template>
</VirtualMessageList>
<button
v-if="showScrollBottomButton"
type="button"
class="scroll-bottom-button"
:aria-label="t('chat.scrollToBottom')"
:title="t('chat.scrollToBottom')"
@click="handleScrollBottomClick"
>
<svg
class="scroll-bottom-icon"
viewBox="0 0 24 24"
aria-hidden="true"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m7 10 5 5 5-5" />
<path d="M6 19h12" />
</svg>
</button>
</div>
</template>
<style scoped lang="scss">
@use "@/styles/variables" as *;
.group-message-list-shell {
flex: 1;
min-height: 0;
min-width: 0;
position: relative;
display: flex;
}
.group-message-list {
min-width: 0;
max-width: 100%;
}
.scroll-bottom-button {
position: absolute;
right: 18px;
bottom: 18px;
z-index: 7;
width: 38px;
height: 38px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
border: 1px solid rgba(var(--accent-primary-rgb), 0.24);
background: rgba(255, 255, 255, 0.94);
color: var(--accent-primary);
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.16);
padding: 0;
cursor: pointer;
.dark & {
background: rgba(38, 38, 38, 0.94);
}
}
.scroll-bottom-button:hover {
background: rgba(var(--accent-primary-rgb), 0.1);
}
.scroll-bottom-icon {
width: 19px;
height: 19px;
}
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: $text-muted;
.empty-logo {
width: 48px;
height: 48px;
opacity: 0.25;
}
p {
font-size: 14px;
}
}
.history-loader {
height: 28px;
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
}
.history-loader-spinner {
width: 14px;
height: 14px;
border: 2px solid rgba(0, 0, 0, 0.16);
border-top-color: $accent-primary;
border-radius: 50%;
animation: spin 0.7s linear infinite;
.dark & {
border-color: rgba(255, 255, 255, 0.18);
border-top-color: $accent-primary;
}
}
.history-limit-notice {
width: fit-content;
max-width: min(100%, 420px);
margin: 0 auto 8px;
padding: 6px 10px;
border: 1px solid rgba(var(--accent-info-rgb), 0.22);
border-radius: 999px;
background: rgba(var(--accent-info-rgb), 0.08);
color: $text-secondary;
font-size: 12px;
line-height: 1.3;
text-align: center;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>
@@ -0,0 +1,46 @@
export type MentionOption = {
key: string
type: 'all' | 'agent'
name: string
label: string
description: string
}
type MentionAgent = {
name: string
profile?: string
}
function isReservedMentionName(name: string): boolean {
return name.trim().toLowerCase() === 'all'
}
export function buildMentionOptions(agents: MentionAgent[], query: string): MentionOption[] {
const normalizedQuery = query.trim().toLowerCase()
const options: MentionOption[] = []
if (!normalizedQuery || 'all'.includes(normalizedQuery)) {
options.push({
key: 'special:all',
type: 'all',
name: 'all',
label: '@all',
description: 'All agents',
})
}
for (const agent of agents) {
const agentName = agent.name || ''
if (isReservedMentionName(agentName)) continue
if (!agentName.toLowerCase().includes(normalizedQuery)) continue
options.push({
key: `agent:${agentName}`,
type: 'agent',
name: agentName,
label: `@${agentName}`,
description: agent.profile || '',
})
}
return options
}