Changelog, dev startup, README, AgentHub 0.4.0, model catalog, and landing site (#7)
Branch-length batch covering tooling, the model layer, the Web App and the public surfaces. Highlights: - Changelog: a per-release `changelog/<version>/` tree, grouped by the surface each change touches, with a root CHANGELOG.md holding one line per release. - Dev startup: `scripts/dev-prebuild.mjs` serializes the skills+core prebuild behind a lock and keeps `pnpm install` current; `pnpm dev` runs server+web together. - AgentHub 0.3.3 -> 0.4.0: OmniMessage complete payloads carry one opaque `fidelity` object in place of item-level `signature`/`phase`, threaded verbatim through Trace, replay and resume; malformed classification adapted to the new error types. - Model layer: a model is always referenced by an explicit `(provider, model_id)` pair. The provider is never inferred, guessed or defaulted -- both the catalog inference and the unique-match config resolution are gone, and CLI, SDK, server routes and run_subagent all require the complete pair. Catalog gains the Qwen Token Plan, Qwen Pay-As-You-Go and Fireworks AI gateways, plus an expanded OpenRouter group. - Web App: catalog preset sync and per-group speed test on the Models page, positional slash commands, a markdown renderer, skill-library update reminders, and a vertically centred draft page whose upward menus size themselves to the room available. - Public surfaces: restructured READMEs, the penguin.ooo landing site and blog, refreshed benchmark results for both suites, and the demo videos playing on the landing page. Includes the fixes from a full review of the branch: 23 confirmed findings, among them a provider-inference bug that could send one vendor's API key to another vendor's endpoint, and an Escape handler that destroyed the composer's contents unrecoverably. Verified on the branch head: pnpm test (1127 passing, 7 packages), pnpm typecheck and pnpm format:check clean, Playwright e2e 14/14.
This commit is contained in:
@@ -31,6 +31,17 @@ export function humanizeDuration(ms: number): string {
|
||||
return `${m}m${Math.round(s % 60)}s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Duration format for a still-running timer: whole seconds only (`0s`, `7s`, `1m3s`),
|
||||
* counting up (floor, never showing a second early). Decimals are reserved for settled
|
||||
* durations (humanizeDuration above) — a live timer showing tenths reads as jitter.
|
||||
*/
|
||||
export function humanizeDurationLive(ms: number): string {
|
||||
const s = Math.max(0, Math.floor(ms / 1000));
|
||||
if (s < 60) return `${s}s`;
|
||||
return `${Math.floor(s / 60)}m${s % 60}s`;
|
||||
}
|
||||
|
||||
/** Add an explicit sign to a delta string: non-negative gets `+`, negative already has `-` (negative when context shrinks after compaction). */
|
||||
export function signedDelta(formatted: string): string {
|
||||
return formatted.startsWith("-") ? formatted : `+${formatted}`;
|
||||
|
||||
@@ -176,15 +176,25 @@ export function createStreamController(deps: StreamControllerDeps): StreamContro
|
||||
// disconnected shouldn't leave a lingering button; the server will
|
||||
// resend still-pending approval_request events on the same connection afterward, naturally rebuilding it.
|
||||
clearPending();
|
||||
// Swap in a new model, injecting the shared localDecisions: approvals decided on this end are still labeled "manual" after the rebuild.
|
||||
model = createStreamModel(localDecisions);
|
||||
await load(epoch);
|
||||
// Atomic swap — deliberately NOT `onLoading(true)`. The fresh model (shared localDecisions
|
||||
// injected, so approvals decided on this end stay labeled "manual") is handed to load(), which
|
||||
// makes it the visible model only once history is back in hand. Until then the OLD transcript
|
||||
// stays mounted and on screen: a mid-stream resync never blanks the conversation nor unmounts
|
||||
// the composer. (Flipping loading=true here drove the consumer to swap both the message list and
|
||||
// the input for a skeleton, losing scroll position, expanded tool cards, and composer
|
||||
// focus/draft.) Deltas arriving during the refetch are buffered and replayed on swap; the brief
|
||||
// no-new-text pause is invisible next to a full teardown.
|
||||
await load(epoch, createStreamModel(localDecisions));
|
||||
};
|
||||
|
||||
const load = async (currentEpoch: number): Promise<void> => {
|
||||
const load = async (currentEpoch: number, freshModel?: StreamModel): Promise<void> => {
|
||||
try {
|
||||
const messages = await deps.loadMessages();
|
||||
if (disposed || currentEpoch !== epoch) return;
|
||||
// Rebuild path: make the freshly-built model visible only now, atomically — the old model
|
||||
// stayed on screen throughout the refetch above (see rebuild). Initial load / retry pass no
|
||||
// freshModel and keep operating on the current model.
|
||||
if (freshModel) model = freshModel;
|
||||
const target = model;
|
||||
pushMessages(target, messages, now());
|
||||
const dedup = buildDedupIndex(messages, 100);
|
||||
@@ -235,11 +245,16 @@ export function createStreamController(deps: StreamControllerDeps): StreamContro
|
||||
},
|
||||
retry: async () => {
|
||||
if (disposed || !failed) return;
|
||||
// On failure the model was never written to and the buffer kept accumulating: keep both, just refetch history and replay again.
|
||||
// Retry always rebuilds into a FRESH model. After an initial-load failure the model was
|
||||
// never written, but after a failed *resync* rebuild the OLD populated model is deliberately
|
||||
// left in place (so the transcript didn't blank during the refetch) — pushing the refetched
|
||||
// history straight onto it would duplicate the entire conversation (pushMessages appends with
|
||||
// no id-dedup). load() swaps the fresh model in only once the refetch succeeds, then replays
|
||||
// the still-accumulating buffer into it; localDecisions carry over via the shared set.
|
||||
deps.onError(null);
|
||||
deps.onLoading(true);
|
||||
epoch += 1;
|
||||
await load(epoch);
|
||||
await load(epoch, createStreamModel(localDecisions));
|
||||
},
|
||||
handleOmni: (msg) => {
|
||||
if (disposed) return;
|
||||
|
||||
@@ -80,12 +80,13 @@ export const en: Strings = {
|
||||
logout: "Sign out",
|
||||
admin: "Admin",
|
||||
defaultAdminNote:
|
||||
"First run: sign in as the built-in admin (admin / admin123), then change the password soon",
|
||||
"First run: sign in as the built-in admin (admin / penguin-2026), then change the password soon",
|
||||
},
|
||||
|
||||
account: {
|
||||
changePassword: "Change password",
|
||||
oldPassword: "Current password",
|
||||
oldPasswordHint: "The built-in admin's default initial password is penguin-2026",
|
||||
newPassword: "New password",
|
||||
confirmPassword: "Confirm new password",
|
||||
passwordMismatch: "New passwords do not match",
|
||||
@@ -286,6 +287,22 @@ export const en: Strings = {
|
||||
groupEmptyHint: "No models in this group yet; use “Add model” to create one",
|
||||
searchPlaceholder: "Search models: id / name / provider",
|
||||
noSearchResults: "No matching models",
|
||||
syncCatalog: "Sync presets",
|
||||
syncCatalogHint:
|
||||
"Update preset models from the built-in catalog: add missing entries and reset differing ones to the catalog's fields; locally added models and API keys are left untouched",
|
||||
syncDone: (added: number, updated: number) =>
|
||||
`Presets synced: ${added} added, ${updated} updated`,
|
||||
syncUpToDate: "Presets are already up to date",
|
||||
homepage: "Model page",
|
||||
speedTest: "Speed test",
|
||||
speedTestTitle: "Speed test",
|
||||
speedTestConfirm: (n: number): string =>
|
||||
`This sends one real request to each of the ${n} models in this group, one at a time, to measure time-to-first-token (TTFT) and output rate (TPS). It consumes a small amount of API quota. Continue?`,
|
||||
speedTestStart: "Start",
|
||||
speedPending: "Testing…",
|
||||
speedFailed: "Test failed",
|
||||
ttftTitle: "Time to first token (TTFT)",
|
||||
tpsTitle: "Output rate (TPS)",
|
||||
modelCount: (n: number): string => `${n} model${n === 1 ? "" : "s"}`,
|
||||
modelId: "Model ID",
|
||||
modelIdHint: "The upstream API model id, e.g. gpt-5.5",
|
||||
@@ -373,7 +390,7 @@ export const en: Strings = {
|
||||
},
|
||||
|
||||
vault: {
|
||||
desc: "Environment variables owned by this Agent (stored in agent_state/.vault.toml), injected into the environment of its shell commands (exec_command); key names are shared with the model, values never enter the model context. Subagents use their own vaults and do not inherit this one.",
|
||||
desc: "Environment variables owned by this Agent (stored in agent_state/.vault.toml), injected into the environment of its shell commands (exec_command); key names are shared with the model, values never enter the model context. Subagents use their own vaults and do not inherit this one. Saved changes take effect from the next task (a task already running is unaffected).",
|
||||
key: "Name",
|
||||
value: "Value",
|
||||
valueMasked: "Value (masked)",
|
||||
@@ -449,6 +466,10 @@ export const en: Strings = {
|
||||
usedByAgents: (n: number): string =>
|
||||
n === 0 ? "not used yet" : n === 1 ? "used by 1 agent" : `used by ${n} agents`,
|
||||
installedToast: (skill: string, agent: string): string => `Installed ${skill} to ${agent}`,
|
||||
updateOutdated: (n: number): string => `Update available: update ${n} Agent install(s)`,
|
||||
updateAction: "Update",
|
||||
updatedToast: (skill: string, n: number): string =>
|
||||
`Updated ${skill} to the latest version (${n} Agent(s))`,
|
||||
uninstalledToast: (skill: string, agent: string): string =>
|
||||
`Uninstalled ${skill} from ${agent}`,
|
||||
},
|
||||
@@ -464,6 +485,63 @@ export const en: Strings = {
|
||||
workspaceClear: "Use auto temp directory instead",
|
||||
workspaceDirInvalid: "Directory does not exist or is inaccessible; reverted",
|
||||
draftSubtitle: "The self-evolving agent that excels at AI development tasks",
|
||||
exampleTasks: {
|
||||
game: {
|
||||
label: "Example: 2D penguin sled game",
|
||||
desc: "A cute Antarctic penguin sleds over rocks, easy start with a gentle difficulty ramp — a 2D pure-frontend mini game",
|
||||
prompt:
|
||||
"Build a cute Antarctic penguin sledding 2D game: press Space to jump over the rocks " +
|
||||
"coming up on the ice; start easy and forgiving, with sled speed and obstacle density " +
|
||||
"ramping up smoothly and gradually over time (no sudden spikes), live scoring, and " +
|
||||
"hitting a rock ending the run with one-click restart. " +
|
||||
"A 2D side-scroller with a cute cartoon look, pure frontend (a single HTML file is " +
|
||||
"fine), styled per the web-design skill. " +
|
||||
"When done, test it in a browser once, confirm the first few seconds are easy to " +
|
||||
"clear, and tell me how to open it and how to play.",
|
||||
},
|
||||
lol: {
|
||||
label: "Example: League of Legends music player",
|
||||
desc: "Worlds anthems on the SoundCloud Widget API — a single file that opens from file://",
|
||||
prompt: `Build a League of Legends Worlds anthem player with the SoundCloud Widget API (see https://developers.soundcloud.com/docs/api/html5-widget): a single index.html that works when opened from file://.
|
||||
|
||||
## Technical constraints
|
||||
- Use the SC.Widget JS API (widget.load / widget.toggle / widget.setVolume / widget.seekTo), loading https://w.soundcloud.com/player/api.js
|
||||
- The iframe must stay visible (180px tall), with visual=true color=f0b90b single_active=true
|
||||
- Include ONLY these 8 tracks confirmed playable (oEmbed-verified); do not add tracks that are not oEmbed-verified:
|
||||
- Warriors (S4) — soundcloud.com/leagueoflegends/warriors
|
||||
- Worlds Collide (S5) — soundcloud.com/leagueoflegends/worlds-collide
|
||||
- Legends Never Die (S7) — soundcloud.com/leagueoflegends/legends-never-die
|
||||
- Phoenix (S9) — soundcloud.com/leagueoflegends/phoenix
|
||||
- Burn It All Down (S11) — soundcloud.com/leagueoflegends/burn-it-all-down
|
||||
- GODS (S13) — soundcloud.com/leagueoflegends/gods
|
||||
- Heavy Is The Crown (S14) — soundcloud.com/linkinpark/heavy-is-the-crown
|
||||
- Sacrifice (S15) — soundcloud.com/leagueoflegends/sacrifice
|
||||
|
||||
## Layout
|
||||
- Left 260px sticky sidebar: the track list (S4/S5/… badge + emoji + title + year); clicking highlights with a gold border and switches tracks via SC.Widget.load() with auto_play
|
||||
- Right main area: hero title + a desktop clock (80px monospace gold HH:MM:SS, refreshed every second, blinking colons) + a mood tag
|
||||
- Player card: the SoundCloud iframe + a custom control bar (⏮ ▶/⏸ ⏭ + track info + a volume slider; clicking the speaker icon toggles mute)
|
||||
- Mood-wave section: 15 gold animated bars, re-randomized on every track switch
|
||||
- Keyboard shortcuts: Space play/pause, ← → previous/next, ↑ ↓ volume
|
||||
|
||||
## Design
|
||||
Penguin visual style (see the web-design skill), dark/light themes via <html data-theme>, dark by default, remembered in localStorage. Responsive: on phones the sidebar becomes a horizontally scrolling top bar.
|
||||
|
||||
When done, open index.html in a browser and self-test once.`,
|
||||
},
|
||||
rag: {
|
||||
label: "Example: build a Claude Code docs expert",
|
||||
desc: "Collect the claude-code-docs repo into a conversational RAG knowledge app with source citations",
|
||||
prompt:
|
||||
"Collect the docs from https://github.com/ericbuess/claude-code-docs and build a RAG knowledge app: " +
|
||||
"clone the repo and prepare the corpus, then build a retrieval index; " +
|
||||
"the app acts as a Claude Code configuration expert, answering Claude Code questions " +
|
||||
"with retrieval-augmented replies and clickable citations that reveal the matched " +
|
||||
"original text chunk and link to the real documents; " +
|
||||
"give it a beautiful web chat UI following the web-design skill, with a few example questions in the empty state. " +
|
||||
"When done, run the app, verify one streamed answer yourself, and tell me how to access it.",
|
||||
},
|
||||
},
|
||||
sessionList: "Sessions",
|
||||
defaultSessionTitle: "New chat",
|
||||
model: "Model",
|
||||
@@ -487,6 +565,7 @@ export const en: Strings = {
|
||||
statusRunning: "Running",
|
||||
statusCompacting: "Compacting",
|
||||
pendingApprovals: (n: number) => `${n} pending approval${n > 1 ? "s" : ""}`,
|
||||
jumpToLatest: "Jump to latest",
|
||||
inputPlaceholder: "Type a message. Enter to send, Shift+Enter for newline, paste images",
|
||||
inputPlaceholderShort: "Type a message…",
|
||||
send: "Send",
|
||||
@@ -547,6 +626,7 @@ export const en: Strings = {
|
||||
mentionHint: "@ to handoff to another agent",
|
||||
mentionRemove: "Remove @ target",
|
||||
skillsSelect: "Skills",
|
||||
skillRemove: "Remove skill",
|
||||
skillsSearchPlaceholder: "Search skills",
|
||||
skillsNoMatch: "No matching skills",
|
||||
skillsEmptyHint: "No skills installed yet — add some from the skill library",
|
||||
@@ -598,6 +678,7 @@ export const en: Strings = {
|
||||
title: "Files",
|
||||
upload: "Upload",
|
||||
download: "Download",
|
||||
openInNewTab: "Open in new tab",
|
||||
refresh: "Refresh",
|
||||
root: "Workspace root",
|
||||
empty: "Empty directory",
|
||||
|
||||
@@ -80,12 +80,13 @@ export const zh = {
|
||||
login: "登录",
|
||||
logout: "登出",
|
||||
admin: "管理员",
|
||||
defaultAdminNote: "首次使用请以内置管理员登录:admin / admin123,登录后请尽快修改密码",
|
||||
defaultAdminNote: "首次使用请以内置管理员登录:admin / penguin-2026,登录后请尽快修改密码",
|
||||
},
|
||||
|
||||
account: {
|
||||
changePassword: "修改密码",
|
||||
oldPassword: "当前密码",
|
||||
oldPasswordHint: "内置管理员的默认初始密码为 penguin-2026",
|
||||
newPassword: "新密码",
|
||||
confirmPassword: "确认新密码",
|
||||
passwordMismatch: "两次输入的新密码不一致",
|
||||
@@ -262,6 +263,21 @@ export const zh = {
|
||||
groupEmptyHint: "该分组暂无模型,点「新增模型」添加",
|
||||
searchPlaceholder: "搜索模型:id / 名称 / 厂商",
|
||||
noSearchResults: "没有匹配的模型",
|
||||
syncCatalog: "同步预置",
|
||||
syncCatalogHint:
|
||||
"用内置目录更新预置模型:新增缺失条目、以目录字段为准刷新差异;本地新增模型与 API key 保持不变",
|
||||
syncDone: (added: number, updated: number) => `预置模型已同步:新增 ${added}、更新 ${updated}`,
|
||||
syncUpToDate: "预置模型已是最新",
|
||||
homepage: "模型主页",
|
||||
speedTest: "测速",
|
||||
speedTestTitle: "分组测速",
|
||||
speedTestConfirm: (n: number): string =>
|
||||
`将对该分组的 ${n} 个模型逐个发起一次真实请求,测量首 token 延迟(TTFT)与输出速率(TPS),会消耗少量 API 额度。是否继续?`,
|
||||
speedTestStart: "开始测速",
|
||||
speedPending: "测速中…",
|
||||
speedFailed: "测速失败",
|
||||
ttftTitle: "首 token 延迟(TTFT)",
|
||||
tpsTitle: "输出速率(TPS)",
|
||||
modelCount: (n: number): string => `${n} 个模型`,
|
||||
modelId: "模型 ID",
|
||||
modelIdHint: "上游 API 使用的模型 id,如 gpt-5.5",
|
||||
@@ -351,7 +367,7 @@ export const zh = {
|
||||
},
|
||||
|
||||
vault: {
|
||||
desc: "本 Agent 专属的环境变量(存于 agent_state/.vault.toml):键值对注入其 shell 命令(exec_command)的子进程环境;键名会告知模型,值不进入模型上下文。子 Agent 使用各自的保险柜,不继承。",
|
||||
desc: "本 Agent 专属的环境变量(存于 agent_state/.vault.toml):键值对注入其 shell 命令(exec_command)的子进程环境;键名会告知模型,值不进入模型上下文。子 Agent 使用各自的保险柜,不继承。保存后自下一个任务起生效(进行中的任务不受影响)。",
|
||||
key: "键名",
|
||||
value: "值",
|
||||
valueMasked: "值(掩码)",
|
||||
@@ -429,6 +445,10 @@ export const zh = {
|
||||
usedByAgents: (n: number): string => (n === 0 ? "未被使用" : `${n} 个 Agent 在用`),
|
||||
/** Top toast shown on successful install / uninstall. */
|
||||
installedToast: (skill: string, agent: string): string => `已将 ${skill} 安装到 ${agent}`,
|
||||
updateOutdated: (n: number): string => `有新版本:更新 ${n} 个 Agent 的安装`,
|
||||
updateAction: "更新",
|
||||
updatedToast: (skill: string, n: number): string =>
|
||||
`已将 ${skill} 更新到最新版(${n} 个 Agent)`,
|
||||
uninstalledToast: (skill: string, agent: string): string => `已从 ${agent} 卸载 ${skill}`,
|
||||
},
|
||||
|
||||
@@ -443,6 +463,66 @@ export const zh = {
|
||||
workspaceClear: "改用自动临时目录",
|
||||
workspaceDirInvalid: "目录不存在或无法访问,已回退",
|
||||
draftSubtitle: "最擅长 AI 开发任务的自进化 Agent",
|
||||
/**
|
||||
* Example task cards on the draft screen: one click auto-submits the canned prompt (game
|
||||
* card first, RAG card below/after it). These are the FULL working prompts — the README and
|
||||
* landing page show a condensed one-sentence version of the RAG example for reading, and
|
||||
* the cards' own desc lines stay short, but what actually gets submitted stays detailed:
|
||||
* build quality depends on it.
|
||||
*/
|
||||
exampleTasks: {
|
||||
game: {
|
||||
label: "示例:2D 企鹅雪橇越野小游戏",
|
||||
desc: "可爱南极企鹅滑雪橇跳石头,难度由易到难的 2D 纯前端小游戏",
|
||||
prompt:
|
||||
"做一个可爱的南极企鹅滑雪橇越野 2D 小游戏:按空格键起跳,跃过冰面上迎面而来的石头;" +
|
||||
"开局要足够简单、上手无压力,滑行速度与障碍密度随时间平滑、循序渐进地上升,避免突然变难," +
|
||||
"实时计分,撞上石头即结束并可一键重新开始。" +
|
||||
"2D 横版画面、可爱卡通风,纯前端实现(单个 HTML 文件即可),界面遵循 web-design 技能。" +
|
||||
"完成后在浏览器里自测一次,确认开局能轻松玩过几秒,并告诉我怎么打开和怎么玩。",
|
||||
},
|
||||
lol: {
|
||||
label: "示例:英雄联盟音乐播放器",
|
||||
desc: "用 SoundCloud Widget API 播放历届 Worlds 主题曲,单文件即开即用",
|
||||
prompt: `用 SoundCloud Widget API(见 https://developers.soundcloud.com/docs/api/html5-widget)做一个英雄联盟 Worlds 主题曲播放器,单文件 index.html,file:// 打开即用。
|
||||
|
||||
## 技术约束
|
||||
- 使用 SC.Widget JS API(widget.load / widget.toggle / widget.setVolume / widget.seekTo),引入 https://w.soundcloud.com/player/api.js
|
||||
- iframe 必须可见(180px 高),visual=true color=f0b90b single_active=true
|
||||
- 仅包含以下 8 首已确认可播曲目(oEmbed 验证通过),不要添加未经 oEmbed 验证的曲目:
|
||||
- Warriors (S4) — soundcloud.com/leagueoflegends/warriors
|
||||
- Worlds Collide (S5) — soundcloud.com/leagueoflegends/worlds-collide
|
||||
- Legends Never Die (S7) — soundcloud.com/leagueoflegends/legends-never-die
|
||||
- Phoenix (S9) — soundcloud.com/leagueoflegends/phoenix
|
||||
- Burn It All Down (S11) — soundcloud.com/leagueoflegends/burn-it-all-down
|
||||
- GODS (S13) — soundcloud.com/leagueoflegends/gods
|
||||
- Heavy Is The Crown (S14) — soundcloud.com/linkinpark/heavy-is-the-crown
|
||||
- Sacrifice (S15) — soundcloud.com/leagueoflegends/sacrifice
|
||||
|
||||
## 布局
|
||||
- 左侧 260px 粘性侧边栏:曲目列表(S4/S5/… 标签 + emoji + 曲名 + 年份),点击高亮金色边框,SC.Widget.load() 切歌 + auto_play
|
||||
- 右侧主区域:Hero 标题 + 桌面时钟(80px 等宽金色 HH:MM:SS,每秒刷新,冒号闪烁)+ 心情标签
|
||||
- 播放器卡片:SoundCloud iframe + 自定义控制栏(⏮ ▶/⏸ ⏭ + 曲目信息 + 音量滑块,点击喇叭图标静音切换)
|
||||
- 心情波动区:15 根金色动画柱,切歌时重新随机生成
|
||||
- 键盘快捷键:空格播放暂停、← → 切歌、↑ ↓ 调音量
|
||||
|
||||
## 设计
|
||||
Penguin 视觉风格(见 web-design 技能),深色/浅色主题(<html data-theme>),默认深色,localStorage 记忆。响应式:手机端侧边栏变为顶部横向滚动。
|
||||
|
||||
完成后在浏览器打开 index.html 自测一次。`,
|
||||
},
|
||||
rag: {
|
||||
label: "示例:构建 Claude Code 文档专家",
|
||||
desc: "收集 claude-code-docs 仓库,生成可对话、带来源引用的 RAG 知识应用",
|
||||
prompt:
|
||||
"收集 https://github.com/ericbuess/claude-code-docs 的文档,构建一个 RAG 知识应用:" +
|
||||
"克隆仓库并整理语料,建立检索索引;应用化身 Claude Code 配置专家," +
|
||||
"检索增强回答 Claude Code 相关问题并标注可点击的来源引用——" +
|
||||
"引用要能展示命中的原文片段,并链接到真实文档;" +
|
||||
"按 web-design 技能提供美观的 Web 聊天界面,空态展示几个示例问题。" +
|
||||
"完成后运行应用、自测一个问题验证流式回答,并告诉我访问方式。",
|
||||
},
|
||||
},
|
||||
sessionList: "Session",
|
||||
defaultSessionTitle: "新对话",
|
||||
model: "Model",
|
||||
@@ -466,6 +546,7 @@ export const zh = {
|
||||
statusRunning: "运行中",
|
||||
statusCompacting: "压缩中",
|
||||
pendingApprovals: (n: number) => `${n} 个待审批`,
|
||||
jumpToLatest: "回到最新消息",
|
||||
inputPlaceholder: "输入消息,Enter 发送,Shift+Enter 换行,可粘贴图片",
|
||||
inputPlaceholderShort: "输入消息…",
|
||||
send: "发送",
|
||||
@@ -527,6 +608,7 @@ export const zh = {
|
||||
mentionRemove: "移除 @ 目标",
|
||||
/** Skill multi-select dropdown (input toolbar): button text, search box, empty state, and no-match hint. */
|
||||
skillsSelect: "技能",
|
||||
skillRemove: "移除技能",
|
||||
skillsSearchPlaceholder: "搜索技能",
|
||||
skillsNoMatch: "没有匹配的技能",
|
||||
skillsEmptyHint: "暂无已装技能,去技能库添加",
|
||||
@@ -574,6 +656,7 @@ export const zh = {
|
||||
title: "文件",
|
||||
upload: "上传",
|
||||
download: "下载",
|
||||
openInNewTab: "新页面打开",
|
||||
refresh: "刷新",
|
||||
root: "根目录",
|
||||
empty: "空目录",
|
||||
|
||||
Reference in New Issue
Block a user