feat(core,web): add Fireworks Kimi K3; composer "+" menu, height and version-line fixes (#91)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yaowei Zheng
2026-07-28 00:34:06 +08:00
committed by GitHub
parent 7bca481e35
commit e4dcda54c8
6 changed files with 96 additions and 70 deletions
+10
View File
@@ -563,6 +563,16 @@ export const MODEL_CATALOG: ModelCatalogEntry[] = [
clientType: "openai",
baseUrl: FIREWORKS_BASE_URL,
},
{
modelId: "accounts/fireworks/models/kimi-k3",
displayName: "Kimi K3",
provider: "fireworks",
contextWindow: 1000000,
pricing: usd(0.3, 3, 15),
supportsVision: true,
clientType: "openai",
baseUrl: FIREWORKS_BASE_URL,
},
{
modelId: "accounts/fireworks/models/kimi-k2p7-code",
displayName: "Kimi K2.7 Code",
+1
View File
@@ -189,6 +189,7 @@ describe("model-catalog", () => {
["accounts/fireworks/models/deepseek-v4-flash", false],
["accounts/fireworks/models/deepseek-v4-pro", false],
["accounts/fireworks/models/glm-5p2", false],
["accounts/fireworks/models/kimi-k3", true],
["accounts/fireworks/models/kimi-k2p7-code", true],
["accounts/fireworks/models/minimax-m3", true],
]);
+76 -67
View File
@@ -48,7 +48,7 @@
* Renders only the card body itself: outer positioning such as bottom-docking or vertical
* centering is decided by the page.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { ChangeEvent, ClipboardEvent, KeyboardEvent, ReactNode } from "react";
import type {
AgentSummary,
@@ -724,6 +724,13 @@ function SkillSelect({
);
}
/**
* Picture glyph (24×24 line path) for the "+" menu's image-upload entry: the framing rectangle
* and the mountain line as two subpaths of one `d`, since GlyphIcon renders a single `<path>`.
*/
const IMAGE_ICON =
"M6 5h12a3 3 0 0 1 3 3v8a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3V8a3 3 0 0 1 3-3zM3 15l5-5 4 4 3-3 6 6";
/** One entry of the composer's "+" extension menu. */
interface PlusMenuItem {
key: string;
@@ -1277,13 +1284,12 @@ export function ChatInput({
/** The slash token currently under the caret (kept in a ref so command run() closures always remove the live token). */
const slashMatchRef = useRef<ReturnType<typeof matchSlash>>(null);
const commands = useMemo<SlashCommand[]>(() => {
/** Removes just the slash token after a command runs (the rest of the text stays; setText is async: wait for the DOM value to update before measuring height). */
/** Removes just the slash token after a command runs (the rest of the text stays; the height re-measures itself off the new value, see the autoGrow layout effect). */
const clearInput = () => {
const match = slashMatchRef.current;
const next = match ? removeSlashToken(textRef.current, match) : "";
setText(next);
onTextChange?.(next);
requestAnimationFrame(autoGrow);
};
return [
{
@@ -1413,7 +1419,6 @@ export function ChatInput({
setText("");
setImages([]);
setSelectedSkills([]);
requestAnimationFrame(autoGrow);
}
} finally {
setBusy(false);
@@ -1454,13 +1459,25 @@ export function ChatInput({
el.style.height = `${Math.min(el.scrollHeight, 176)}px`;
};
// Correct the height and cursor right on mount: a restored multi-line draft (initialText)
// would otherwise not expand until the first edit; move the cursor to the end of the draft
// (by default the browser places the cursor at the start when focusing a textarea that already
// has content), so typing continues the text naturally, and sync the caret state to match (the
// @ mention menu filters by cursor position).
/**
* The height follows the **rendered** value, measured in a layout effect — the single place
* that sizes the box, covering mount (a restored multi-line draft), typing, and the clear
* after a send alike.
*
* It must be a layout effect keyed on `text` rather than a call next to each `setText`: the
* textarea is controlled, so after `setText("")` the DOM still holds the old text until React
* commits. A `requestAnimationFrame` scheduled alongside the state update races that commit
* and, when it wins, measures the old multi-line content and re-pins the tall height — with
* nothing left to measure again, the composer stayed expanded after every send. A layout
* effect runs after the commit by construction, and before paint, so the box never flashes.
*/
useLayoutEffect(autoGrow, [text]);
// Cursor placement on mount: move it to the end of a restored draft (by default the browser
// places the cursor at the start when focusing a textarea that already has content), so typing
// continues the text naturally, and sync the caret state to match (the @ mention menu filters
// by cursor position).
useEffect(() => {
autoGrow();
const el = textareaRef.current;
if (el && el.value.length > 0) {
const end = el.value.length;
@@ -1525,7 +1542,6 @@ export function ChatInput({
onTextChange?.(value);
setCaret(start);
setMentionIndex(0);
autoGrow();
};
/**
@@ -1558,7 +1574,6 @@ export function ChatInput({
setText("");
setSelectedSkills([]);
toggleGoal(false);
requestAnimationFrame(autoGrow);
}
} finally {
setBusy(false);
@@ -1589,7 +1604,6 @@ export function ChatInput({
setImages([]);
setTarget(null);
setSelectedSkills([]);
requestAnimationFrame(autoGrow);
}
} finally {
setBusy(false);
@@ -1622,7 +1636,6 @@ export function ChatInput({
steerBaseline.current = steeringDeliveredCount ?? 0;
setSteerPending(true);
setText("");
requestAnimationFrame(autoGrow);
}
} finally {
setBusy(false);
@@ -1736,6 +1749,12 @@ export function ChatInput({
if (e.target.files) addFiles(e.target.files);
e.target.value = "";
};
/**
* The image picker moved into the "+" menu, so the file input can no longer be a `<label>`
* wrapper: the menu unmounts its items on select. It lives outside the menu instead and the
* entry clicks it — still inside the click's user-activation window, so the dialog opens.
*/
const imageInputRef = useRef<HTMLInputElement>(null);
return (
<div className="relative" ref={anchorRef}>
@@ -2120,7 +2139,6 @@ export function ChatInput({
const m = matchMention(value, caretNow);
return m && m.start === d ? d : null;
});
autoGrow();
}}
// Cursor movement (arrow keys/click) syncs to caret: the @ menu filters by the prefix at the cursor.
onSelect={(e) => setCaret(e.currentTarget.selectionStart ?? 0)}
@@ -2153,40 +2171,49 @@ export function ChatInput({
always reachable. */}
<div className="mt-1 flex items-center justify-between gap-2 text-xs">
<div className="no-scrollbar flex min-w-0 flex-1 items-center gap-2 overflow-x-auto">
<label
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-gray-400 transition-colors duration-150 ${
goalOn
? "cursor-not-allowed opacity-40"
: "cursor-pointer hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300"
}`}
title={
goalOn ? S.chat.goalModeDesc : vision ? S.chat.imageAlt : S.chat.imagesAsPathHint
}
>
<input
type="file"
accept="image/*"
multiple
disabled={goalOn}
className="hidden"
onChange={onPickFiles}
/>
<svg
width="17"
height="17"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
className="block"
>
<rect x="3" y="5" width="18" height="14" rx="3" />
<path d="M3 15l5-5 4 4 3-3 6 6" />
</svg>
</label>
{/* The image picker's actual input: kept mounted here (outside the menu, which
unmounts its items on select) and driven by the menu entry below. */}
<input
ref={imageInputRef}
type="file"
accept="image/*"
multiple
disabled={goalOn}
className="hidden"
onChange={onPickFiles}
/>
{/* "+" extension menu, leading the row: input add-ons (image upload, goal mode)
plus the input settings footer (mid-run send mode — usable while running, which
is exactly when it matters, so the button itself never disables). Image upload
lives in here rather than as its own toolbar button: one 8x8 slot instead of
two, which is the difference between the phone row scrolling and not. */}
<PlusMenu
items={[
{
key: "image",
icon: IMAGE_ICON,
label: S.chat.uploadImage,
// Without vision the images still send — as scratchpad file paths — so the
// entry stays usable and the hint explains what will happen instead.
desc: vision ? S.chat.uploadImageDesc : S.chat.imagesAsPathHint,
active: images.length > 0,
// Goal mode is text-only (the objective is re-injected each round).
disabled: goalOn,
onSelect: () => imageInputRef.current?.click(),
},
{
key: "goal",
icon: GOAL_ICON,
label: S.chat.goalMode,
desc: S.chat.goalModeDesc,
active: goalOn,
disabled: running || compacting || busy,
onSelect: () => toggleGoal(!goalOn),
},
]}
footer={<SteerModeRow steerMode={steerMode} onChangeSteerMode={setSteerMode} />}
direction={models && onChangeModel ? "down" : "up"}
/>
<ApprovalModeSelect
value={approvalMode}
onChange={onChangeApprovalMode}
@@ -2201,24 +2228,6 @@ export function ChatInput({
disabled={running || compacting || busy}
direction={models && onChangeModel ? "down" : "up"}
/>
{/* "+" extension menu (input add-ons; goal mode today, more entries later) plus
the input settings footer (mid-run send mode — usable while running, which is
exactly when it matters, so the button itself never disables). */}
<PlusMenu
items={[
{
key: "goal",
icon: GOAL_ICON,
label: S.chat.goalMode,
desc: S.chat.goalModeDesc,
active: goalOn,
disabled: running || compacting || busy,
onSelect: () => toggleGoal(!goalOn),
},
]}
footer={<SteerModeRow steerMode={steerMode} onChangeSteerMode={setSteerMode} />}
direction={models && onChangeModel ? "down" : "up"}
/>
{/* Help text: shown only when the card is wide enough (@lg); it never competes for
space on phones, where the group scrolls instead. */}
<span
@@ -682,8 +682,10 @@ const versionBadgeClass =
"ml-1.5 inline-block rounded-full bg-[var(--accent-bg)] px-1.5 align-super text-[10px] font-medium leading-4 text-[var(--accent-fg)] transition-opacity duration-150 hover:opacity-80";
/**
* Quiet version line under the brand subtitle: `PenguinHarness vX.Y.Z · 最近更新日期
* 7 月 26 日` / `… · Last updated Jul 26`. The date is the running version's release
* Quiet version line under the brand subtitle: `vX.Y.Z · 最近更新日期 7 月 26 日` /
* `… · Last updated Jul 26`. The product name is not repeated here — the brand wordmark
* sits directly above, and the sidebar's version footer is bare `vX.Y.Z` too. The date is
* the running version's release
* date, stamped into core's BUILD_DATE at build time — displayed as-is, no network;
* dev builds and releases that predate the stamping (v0.1.2 and earlier) carry null
* and show the version alone. When the update check knows a newer release, a small
@@ -700,7 +702,7 @@ function VersionLine() {
const date = version.buildDate;
return (
<p className="mt-1.5 text-xs text-gray-400 dark:text-gray-500">
{`PenguinHarness v${version.version}${
{`v${version.version}${
date !== null ? ` · ${S.update.lastUpdated(formatMonthDay(date, locale))}` : ""
}`}
{update !== null &&
+2
View File
@@ -778,6 +778,8 @@ When done, open index.html in a browser and self-test once.`,
`Using skill${names.length === 1 ? "" : "s"}: ${names.join(", ")}`,
/** Composer "+" extension menu (currently only goal mode; more entries later) and the goal chip. */
plusMenu: "More input options",
uploadImage: "Upload image",
uploadImageDesc: "Attach images to this message",
goalMode: "Goal mode",
goalModeDesc: "Loop until the goal completes",
goalBudgetLabel: "Token budget",
+2
View File
@@ -758,6 +758,8 @@ Penguin 视觉风格(见 web-design 技能),深色/浅色主题(<html da
skillsBanner: (names: string[]): string => `使用技能:${names.join("、")}`,
/** Composer "+" extension menu (currently only goal mode; more entries later) and the goal chip. */
plusMenu: "更多输入方式",
uploadImage: "上传图片",
uploadImageDesc: "为本条消息附加图片",
goalMode: "目标模式",
goalModeDesc: "循环运行直至目标完成",
goalBudgetLabel: "Token 预算",