diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index edcc5d83..8d1d8d61 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -79,7 +79,8 @@ jobs: - name: Smoke test container run: | set -euo pipefail - docker run --rm -d \ + docker rm -f liveagent-gateway-smoke >/dev/null 2>&1 || true + docker run -d \ --name liveagent-gateway-smoke \ -p 18080:8080 \ -e LIVEAGENT_GATEWAY_TOKEN=ci-token \ @@ -91,7 +92,8 @@ jobs: fi sleep 1 done - docker logs liveagent-gateway-smoke + echo "Gateway Docker smoke test failed; container logs:" + docker logs liveagent-gateway-smoke || true exit 1 webui: diff --git a/Dockerfile b/Dockerfile index e9c51103..721280e9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,7 +33,8 @@ RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates \ && rm -rf /var/lib/apt/lists/* -RUN useradd --system --uid 10001 --home-dir /nonexistent --shell /usr/sbin/nologin liveagent +RUN useradd --system --uid 10001 --user-group --home-dir /nonexistent --shell /usr/sbin/nologin liveagent \ + && install -d -o liveagent -g liveagent -m 0700 /var/lib/liveagent COPY --from=gateway-builder /out/liveagent-gateway /usr/local/bin/liveagent-gateway @@ -41,6 +42,7 @@ USER liveagent ENV PORT=8080 ENV LIVEAGENT_GATEWAY_GRPC_ADDR=:50051 +ENV LIVEAGENT_GATEWAY_CHAT_EVENT_STORE=/var/lib/liveagent/gateway-chat.sqlite3 EXPOSE 8080 50051 diff --git a/Makefile b/Makefile index 2f55c97a..846de310 100644 --- a/Makefile +++ b/Makefile @@ -143,7 +143,7 @@ gateway-docker-smoke: gateway-docker-build @set -e; \ name="liveagent-gateway-smoke"; \ docker rm -f "$$name" >/dev/null 2>&1 || true; \ - docker run --rm -d --name "$$name" -p 18080:8080 -e LIVEAGENT_GATEWAY_TOKEN=$(DEV_GATEWAY_TOKEN) $(GATEWAY_DOCKER_IMAGE) >/dev/null; \ + docker run -d --name "$$name" -p 18080:8080 -e LIVEAGENT_GATEWAY_TOKEN=$(DEV_GATEWAY_TOKEN) $(GATEWAY_DOCKER_IMAGE) >/dev/null; \ trap 'docker rm -f "$$name" >/dev/null 2>&1 || true' EXIT; \ for _ in $$(seq 1 30); do \ if curl -fsS http://127.0.0.1:18080/healthz | grep -q '"ok":true'; then \ @@ -152,7 +152,8 @@ gateway-docker-smoke: gateway-docker-build fi; \ sleep 1; \ done; \ - docker logs "$$name"; \ + echo "Gateway Docker smoke test failed; container logs:"; \ + docker logs "$$name" || true; \ exit 1 build-linux: proto webui diff --git a/crates/agent-gateway/web/src/components/project-tools/SshTunnelPanel.tsx b/crates/agent-gateway/web/src/components/project-tools/SshTunnelPanel.tsx index 8815b037..151ebf89 100644 --- a/crates/agent-gateway/web/src/components/project-tools/SshTunnelPanel.tsx +++ b/crates/agent-gateway/web/src/components/project-tools/SshTunnelPanel.tsx @@ -237,19 +237,21 @@ export function SshTunnelPanel(props: SshTunnelPanelProps) { const visibleSessions = scope === "project" ? projectSshSessions : sshSessions; const canCreateInScope = scope === "project"; const createHosts = canCreateInScope ? associatedHosts : []; + const hasCreateHosts = createHosts.length > 0; + const canShowCreateButton = canCreateInScope && hasCreateHosts; const selectedCreateHostId = createHosts.some((host) => host.id === createHostId) ? createHostId : (createHosts[0]?.id ?? ""); const selectedCreateHost = createHosts.find((host) => host.id === selectedCreateHostId) ?? null; const selectedHostMessage = selectedCreateHost ? hostStatusMessage(selectedCreateHost, t) : ""; const canCreate = Boolean( - canCreateInScope && selectedCreateHost && !selectedHostMessage && !creating, + canShowCreateButton && selectedCreateHost && !selectedHostMessage && !creating, ); useEffect(() => { - if (canCreateInScope || view !== "create") return; + if (canShowCreateButton || view !== "create") return; setView("list"); - }, [canCreateInScope, view]); + }, [canShowCreateButton, view]); useEffect(() => { if (!active) return; @@ -524,7 +526,6 @@ export function SshTunnelPanel(props: SshTunnelPanelProps) { scope === "project" ? t("projectTools.sshTunnelProjectEmptyHint") : t("projectTools.sshTunnelAllEmptyHint"); - const hasCreateHosts = createHosts.length > 0; const visibleSessionCount = visibleSessions.length; const connectedSessionCount = visibleSessions.filter(sshSessionConnected).length; const statusText = @@ -816,7 +817,7 @@ export function SshTunnelPanel(props: SshTunnelPanelProps) { {statusText} - {canCreateInScope ? ( + {canShowCreateButton ? ( diff --git a/crates/agent-gateway/web/src/i18n/config.ts b/crates/agent-gateway/web/src/i18n/config.ts index 08998d9e..eb7db6b0 100644 --- a/crates/agent-gateway/web/src/i18n/config.ts +++ b/crates/agent-gateway/web/src/i18n/config.ts @@ -642,6 +642,10 @@ export const translations: Record> = { "settings.navRemote": "Remote", "settings.navSkills": "Skills", "settings.navMemory": "记忆", + "settings.groupGeneral": "通用", + "settings.groupIntelligence": "智能", + "settings.groupAutomation": "自动化", + "settings.groupConnectivity": "连接", "settings.backToChat": "返回对话", "settings.title": "设置", @@ -937,8 +941,6 @@ export const translations: Record> = { "settings.agentsNamePlaceholder": "例如:代码审查助手", "settings.agentsDescription": "描述", "settings.agentsDescriptionPlaceholder": "简要说明这个全局提示词模板的用途", - "settings.agentsTags": "标签", - "settings.agentsTagsPlaceholder": "例如:代码审查, 写作, 全局提示", "settings.agentsPrompt": "Prompt", "settings.agentsPromptPlaceholder": "输入完整的全局提示词内容...", "settings.agentsCount": "个模板", @@ -946,10 +948,7 @@ export const translations: Record> = { "settings.agentsActiveLabel": "激活中", "settings.agentsNoTemplates": "还没有配置任何全局提示词模板", "settings.agentsNoTemplatesHint": "创建全局提示词模板,在对话中快速复用常见的 Agent 指令", - "settings.agentsNoDescription": "暂无描述", - "settings.agentsNoTags": "无标签", "settings.agentsShowPrompt": "查看 Prompt", - "settings.agentsHidePrompt": "收起 Prompt", "settings.agentsReady": "可以保存", "settings.agentsRequired": "名称和 Prompt 为必填项", @@ -1286,6 +1285,23 @@ export const translations: Record> = { "settings.skillsHubInstallStatusFailed": "读取技能安装状态失败", "settings.skillsHubDeleteFailed": "删除技能失败", "settings.skillsHubDetailLoadFailed": "加载技能详情失败", + "settings.skillsInstalledPreviewOpen": "查看技能详情", + "settings.skillsInstalledPreviewTitle": "已安装技能", + "settings.skillsInstalledPreviewSelected": "已选中", + "settings.skillsInstalledPreviewUnselected": "未选中", + "settings.skillsInstalledPreviewBuiltIn": "内置", + "settings.skillsInstalledPreviewName": "技能名称", + "settings.skillsInstalledPreviewDescription": "技能描述", + "settings.skillsInstalledPreviewNoDescription": "暂无描述", + "settings.skillsInstalledPreviewDetails": "信息", + "settings.skillsInstalledPreviewBaseDir": "目录", + "settings.skillsInstalledPreviewSkillFile": "技能文件", + "settings.skillsInstalledPreviewSource": "来源", + "settings.skillsInstalledPreviewPublished": "发布", + "settings.skillsInstalledPreviewFilePreview": "文件预览", + "settings.skillsInstalledPreviewUnavailable": "文件预览暂不可用。", + "settings.skillsInstalledPreviewEmpty": "这个技能文件没有可预览内容。", + "settings.skillsInstalledPreviewTruncated": "仅显示前 {count} 行。", "settings.skillsSelected": "已选中", "settings.skillsEnable": "启用技能", "settings.skillsAlwaysOn": "内置", @@ -2075,6 +2091,10 @@ export const translations: Record> = { "settings.navRemote": "Remote", "settings.navSkills": "Skills", "settings.navMemory": "Memory", + "settings.groupGeneral": "General", + "settings.groupIntelligence": "Intelligence", + "settings.groupAutomation": "Automation", + "settings.groupConnectivity": "Connectivity", "settings.backToChat": "Back to Chat", "settings.title": "Settings", @@ -2384,8 +2404,6 @@ export const translations: Record> = { "settings.agentsDescription": "Description", "settings.agentsDescriptionPlaceholder": "Briefly describe what this global prompt template is for", - "settings.agentsTags": "Tags", - "settings.agentsTagsPlaceholder": "e.g. review, writing, global prompt", "settings.agentsPrompt": "Prompt", "settings.agentsPromptPlaceholder": "Enter the full global prompt content...", "settings.agentsCount": "templates", @@ -2394,10 +2412,7 @@ export const translations: Record> = { "settings.agentsNoTemplates": "No global prompt templates yet", "settings.agentsNoTemplatesHint": "Create reusable prompt templates to quickly apply common Agent instructions in your chats", - "settings.agentsNoDescription": "No description", - "settings.agentsNoTags": "No tags", "settings.agentsShowPrompt": "View Prompt", - "settings.agentsHidePrompt": "Hide Prompt", "settings.agentsReady": "Ready to save", "settings.agentsRequired": "Name and Prompt are required", @@ -2747,6 +2762,23 @@ export const translations: Record> = { "settings.skillsHubInstallStatusFailed": "Failed to read Skill install status", "settings.skillsHubDeleteFailed": "Failed to delete Skill", "settings.skillsHubDetailLoadFailed": "Failed to load Skill details", + "settings.skillsInstalledPreviewOpen": "View Skill details", + "settings.skillsInstalledPreviewTitle": "Installed Skill", + "settings.skillsInstalledPreviewSelected": "Selected", + "settings.skillsInstalledPreviewUnselected": "Not selected", + "settings.skillsInstalledPreviewBuiltIn": "Built-in", + "settings.skillsInstalledPreviewName": "Skill name", + "settings.skillsInstalledPreviewDescription": "Description", + "settings.skillsInstalledPreviewNoDescription": "No description", + "settings.skillsInstalledPreviewDetails": "Details", + "settings.skillsInstalledPreviewBaseDir": "Directory", + "settings.skillsInstalledPreviewSkillFile": "Skill file", + "settings.skillsInstalledPreviewSource": "Source", + "settings.skillsInstalledPreviewPublished": "Published", + "settings.skillsInstalledPreviewFilePreview": "File preview", + "settings.skillsInstalledPreviewUnavailable": "File preview is unavailable.", + "settings.skillsInstalledPreviewEmpty": "This Skill file has no previewable content.", + "settings.skillsInstalledPreviewTruncated": "Showing the first {count} lines only.", "settings.skillsSelected": "Selected", "settings.skillsEnable": "Enable Skills", "settings.skillsAlwaysOn": "Built-in", diff --git a/crates/agent-gateway/web/src/lib/settings/index.ts b/crates/agent-gateway/web/src/lib/settings/index.ts index 704931bb..03bdcb99 100644 --- a/crates/agent-gateway/web/src/lib/settings/index.ts +++ b/crates/agent-gateway/web/src/lib/settings/index.ts @@ -222,7 +222,6 @@ export type AgentPromptTemplate = { id: string; name: string; description: string; - tags: string[]; prompt: string; enabled: boolean; }; @@ -934,15 +933,6 @@ function normalizeStringArray(input: unknown): string[] { .filter(Boolean); } -function normalizeOrderedUniqueStrings(input: unknown): string[] { - const out: string[] = []; - for (const value of normalizeStringArray(input)) { - if (out.includes(value)) continue; - out.push(value); - } - return out; -} - function normalizeOptionalText(input: unknown): string { return typeof input === "string" ? input.trim() : ""; } @@ -1298,7 +1288,6 @@ export function normalizeAgentPromptTemplate(input: unknown): AgentPromptTemplat id: typeof obj.id === "string" && obj.id.trim() ? obj.id.trim() : crypto.randomUUID(), name: typeof obj.name === "string" && obj.name.trim() ? obj.name.trim() : "未命名模板", description: normalizeOptionalText(obj.description), - tags: normalizeOrderedUniqueStrings(obj.tags), prompt: normalizeOptionalText(obj.prompt), enabled: obj.enabled === true, }; diff --git a/crates/agent-gateway/web/src/pages/SettingsPage.tsx b/crates/agent-gateway/web/src/pages/SettingsPage.tsx index 212849bf..ce59082a 100644 --- a/crates/agent-gateway/web/src/pages/SettingsPage.tsx +++ b/crates/agent-gateway/web/src/pages/SettingsPage.tsx @@ -62,23 +62,23 @@ function NavItem({ icon, label, active, onClick }: NavItemProps) { + +
+
+ +
+
+
{t("settings.title")}
+
LiveAgent
+
+
+ + +
diff --git a/crates/agent-gateway/web/src/pages/settings/AgentPromptTemplateModal.tsx b/crates/agent-gateway/web/src/pages/settings/AgentPromptTemplateModal.tsx index 7d9bf7c7..d6d36142 100644 --- a/crates/agent-gateway/web/src/pages/settings/AgentPromptTemplateModal.tsx +++ b/crates/agent-gateway/web/src/pages/settings/AgentPromptTemplateModal.tsx @@ -9,7 +9,6 @@ import { Textarea } from "../../components/ui/textarea"; import { useLocale } from "../../i18n"; import { useModalMotion } from "../../lib/shared/modalMotion"; import type { AgentPromptTemplate } from "../../lib/settings"; -import { parseAgentTagsInput, PromptTag, stringifyAgentTags } from "./shared"; type AgentPromptTemplateModalProps = { initialData?: AgentPromptTemplate; @@ -25,7 +24,6 @@ export function AgentPromptTemplateModal({ const { t } = useLocale(); const [name, setName] = useState(initialData?.name ?? ""); const [description, setDescription] = useState(initialData?.description ?? ""); - const [tagsInput, setTagsInput] = useState(() => stringifyAgentTags(initialData?.tags ?? [])); const [prompt, setPrompt] = useState(initialData?.prompt ?? ""); const { isClosing, modalState, requestClose } = useModalMotion(onClose); @@ -39,20 +37,22 @@ export function AgentPromptTemplateModal({ onSave({ name: trimmedName, description: description.trim(), - tags: parseAgentTagsInput(tagsInput), prompt: trimmedPrompt, }); requestClose(); } - const parsedTags = parseAgentTagsInput(tagsInput); - return createPortal(
-
+
-
-

- {template.description || t("settings.agentsNoDescription")} -

- {template.prompt ? ( - - ) : null} - {isExpanded ? ( -
-
-                          {template.prompt}
-                        
-
- ) : null} -
); })} @@ -261,6 +239,75 @@ export function AgentsSection(props: SettingsSectionProps) { onClose={closeModal} /> ) : null} + + {viewingTemplate ? ( + setViewingTemplate(null)} + /> + ) : null} ); } + +type AgentPromptViewModalProps = { + template: AgentPromptTemplate; + onClose: () => void; +}; + +function AgentPromptViewModal({ template, onClose }: AgentPromptViewModalProps) { + const { t } = useLocale(); + const { modalState, requestClose } = useModalMotion(onClose); + + return createPortal( +
+ +
+ +
+ {template.description ? ( +

+ {template.description} +

+ ) : null} +
+
{template.prompt}
+
+
+ + , + document.body, + ); +} diff --git a/crates/agent-gateway/web/src/pages/settings/shared.tsx b/crates/agent-gateway/web/src/pages/settings/shared.tsx index 98ad9ded..4f34209c 100644 --- a/crates/agent-gateway/web/src/pages/settings/shared.tsx +++ b/crates/agent-gateway/web/src/pages/settings/shared.tsx @@ -1,19 +1,5 @@ export { ConfirmActionPopover, ConfirmDeletePopover } from "../../components/ui/confirm-action-popover"; -export function parseAgentTagsInput(input: string): string[] { - const out: string[] = []; - for (const value of input.split(/[\n,,]+/)) { - const tag = value.trim(); - if (!tag || out.includes(tag)) continue; - out.push(tag); - } - return out; -} - -export function stringifyAgentTags(tags: string[]): string { - return tags.join(", "); -} - export function PromptTag({ label, muted = false }: { label: string; muted?: boolean }) { return ( = [ { value: "downloads", labelKey: "settings.skillsStoreSortMostDownloaded" }, @@ -65,6 +68,180 @@ type StoreSkillInstallState = { progress: number | null; }; +type InstalledSkillPreviewState = { + skillFile: string; + content: string; + truncated: boolean; + loading: boolean; + error: string | null; +}; + +function emptyInstalledSkillPreviewState(): InstalledSkillPreviewState { + return { + skillFile: "", + content: "", + truncated: false, + loading: false, + error: null, + }; +} + +function normalizePreviewMetadataText(value: string) { + return value + .trim() + .replace(/^#{1,6}\s+/, "") + .replace(/[`*_]/g, "") + .replace(/\s+/g, " ") + .toLowerCase(); +} + +function stripLeadingBlankLines(lines: string[]) { + let index = 0; + while (index < lines.length && !lines[index].trim()) { + index += 1; + } + return lines.slice(index); +} + +function stripReadmeDuplicateSummary(content: string, skill: SkillSummary) { + const expectedName = normalizePreviewMetadataText(skill.name); + const expectedDescription = normalizePreviewMetadataText(skill.description); + let lines = stripLeadingBlankLines(content.split(/\r?\n/)); + + if (lines.length > 0 && normalizePreviewMetadataText(lines[0]) === expectedName) { + lines = stripLeadingBlankLines(lines.slice(1)); + } + + if (expectedDescription && lines.length > 0) { + const paragraph: string[] = []; + let index = 0; + while (index < lines.length && lines[index].trim()) { + paragraph.push(lines[index]); + index += 1; + } + if (normalizePreviewMetadataText(paragraph.join(" ")) === expectedDescription) { + lines = stripLeadingBlankLines(lines.slice(index)); + } + } + + return lines.join("\n").trimStart(); +} + +const FRONTMATTER_PREVIEW_METADATA_KEYS = new Set(["name", "description"]); + +function hasPreviewMetadataFrontmatterField(frontmatterBody: string) { + return frontmatterBody.split(/\r?\n/).some((line) => { + if (/^[ \t]/.test(line)) return false; + const match = line.match(/^([A-Za-z0-9_-]+)\s*:/); + return match ? FRONTMATTER_PREVIEW_METADATA_KEYS.has(match[1].toLowerCase()) : false; + }); +} + +function hasPreviewMetadataInlineFrontmatterField(frontmatterBody: string) { + return Array.from(frontmatterBody.matchAll(/(?:^|\s)([A-Za-z0-9_-]+)\s*:/g)).some((match) => + FRONTMATTER_PREVIEW_METADATA_KEYS.has(match[1].toLowerCase()), + ); +} + +function hasDisplayableFrontmatterContent(frontmatterBody: string) { + return frontmatterBody.split(/\r?\n/).some((line) => { + const trimmed = line.trim(); + return trimmed !== "" && !trimmed.startsWith("#"); + }); +} + +function stripFrontmatterPreviewMetadataFields(frontmatterBody: string) { + const lines = frontmatterBody.split(/\r?\n/); + const nextLines: string[] = []; + let skippingMetadataField = false; + + for (const line of lines) { + const isIndented = /^[ \t]/.test(line); + const trimmed = line.trim(); + const keyMatch = isIndented ? null : line.match(/^([A-Za-z0-9_-]+)\s*:/); + + if (keyMatch) { + skippingMetadataField = FRONTMATTER_PREVIEW_METADATA_KEYS.has(keyMatch[1].toLowerCase()); + if (skippingMetadataField) continue; + } else if (skippingMetadataField) { + if (trimmed === "" || isIndented) continue; + skippingMetadataField = false; + } + + nextLines.push(line); + } + + return nextLines.join("\n").trim(); +} + +function stripInlineFrontmatterPreviewMetadataFields(frontmatterBody: string) { + const matches = Array.from(frontmatterBody.matchAll(/(?:^|\s)([A-Za-z0-9_-]+)\s*:/g)); + if (matches.length === 0) return frontmatterBody.trim(); + + const fields = matches.map((match, index) => { + const rawIndex = match.index ?? 0; + const startsWithSpace = /^\s/.test(match[0]); + const start = rawIndex + (startsWithSpace ? 1 : 0); + const end = + index + 1 < matches.length + ? (matches[index + 1].index ?? frontmatterBody.length) + : frontmatterBody.length; + return { + key: match[1].toLowerCase(), + text: frontmatterBody.slice(start, end).trim(), + }; + }); + + return fields + .filter((field) => !FRONTMATTER_PREVIEW_METADATA_KEYS.has(field.key)) + .map((field) => field.text) + .join(" ") + .trim(); +} + +function stripMarkdownSkillMetadata(content: string, skill: SkillSummary) { + let next = content.replace(/^\uFEFF/, ""); + const frontmatter = next.match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/); + if (frontmatter && hasPreviewMetadataFrontmatterField(frontmatter[1])) { + const frontmatterBody = stripFrontmatterPreviewMetadataFields(frontmatter[1]); + const rest = next.slice(frontmatter[0].length); + next = hasDisplayableFrontmatterContent(frontmatterBody) + ? `---\n${frontmatterBody}\n---\n${rest}` + : rest; + } else { + const inlineFrontmatter = next.match(/^---[ \t]+([\s\S]*?)[ \t]+---[ \t]*/); + if (inlineFrontmatter && hasPreviewMetadataInlineFrontmatterField(inlineFrontmatter[1])) { + const frontmatterBody = stripInlineFrontmatterPreviewMetadataFields(inlineFrontmatter[1]); + const rest = next.slice(inlineFrontmatter[0].length); + next = frontmatterBody ? `--- ${frontmatterBody} --- ${rest}` : rest; + } + } + return stripReadmeDuplicateSummary(next, skill); +} + +function stripJsonSkillMetadata(content: string) { + try { + const parsed = JSON.parse(content) as unknown; + if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") return content; + const next = { ...(parsed as Record) }; + delete next.name; + delete next.description; + return Object.keys(next).length > 0 ? JSON.stringify(next, null, 2) : ""; + } catch { + return content; + } +} + +function stripInstalledSkillPreviewMetadata(content: string, skill: SkillSummary) { + if (/\.(md|mdx|markdown)$/i.test(skill.skillFile)) { + return stripMarkdownSkillMetadata(content, skill); + } + if (/\.json$/i.test(skill.skillFile)) { + return stripJsonSkillMetadata(content); + } + return content; +} + function ScanActivityDots() { return (