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:
@@ -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",
|
||||
|
||||
@@ -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],
|
||||
]);
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 预算",
|
||||
|
||||
Reference in New Issue
Block a user