refactor(webui): import followup state from core package

- Remove followupState.ts from webui (moved to core)
- Import FollowupSuggestion, FollowupState types from core
- Add @qwen-code/qwen-code-core as peerDependency
- Add core to vite external list
- Update test to include id field in HistoryItem

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
yiliang114 2026-03-28 02:10:30 +08:00
parent 7c558819a7
commit d0f38a5f3b
5 changed files with 37 additions and 280 deletions

View file

@ -30,15 +30,23 @@ function createTool(
};
}
let nextId = 1;
function createToolGroup(
tools: IndividualToolCallDisplay[],
): Extract<HistoryItem, { type: 'tool_group' }> {
return {
type: 'tool_group',
tools,
id: nextId++,
};
}
/** Create a minimal HistoryItem with auto-incremented id */
function h(item: { type: string; text: string }): HistoryItem {
return { ...item, id: nextId++ } as HistoryItem;
}
describe('extractModifiedFileFromTool', () => {
it('treats WriteFile overwrite results conservatively as edited files', () => {
const modifiedFile = extractModifiedFileFromTool(
@ -75,8 +83,8 @@ describe('extractModifiedFileFromTool', () => {
describe('extractFollowupSuggestionContext', () => {
it('uses only tool calls and assistant content from the most recent turn', () => {
const context = extractFollowupSuggestionContext([
{ type: 'user', text: 'old turn' },
{ type: 'gemini', text: 'I created an old file' },
h({ type: 'user', text: 'old turn' }),
h({ type: 'gemini', text: 'I created an old file' }),
createToolGroup([
createTool({
name: 'WriteFile',
@ -85,8 +93,8 @@ describe('extractFollowupSuggestionContext', () => {
'Successfully created and wrote to new file: /tmp/src/old.ts.',
}),
]),
{ type: 'user', text: 'current turn' },
{ type: 'gemini', text: 'I fixed the current bug' },
h({ type: 'user', text: 'current turn' }),
h({ type: 'gemini', text: 'I fixed the current bug' }),
createToolGroup([
createTool({
name: 'Edit',
@ -94,7 +102,7 @@ describe('extractFollowupSuggestionContext', () => {
status: UIToolCallStatus.Success,
}),
]),
] satisfies HistoryItem[]);
]);
expect(context).not.toBeNull();
expect(context?.lastMessage).toBe('I fixed the current bug');
@ -108,25 +116,25 @@ describe('extractFollowupSuggestionContext', () => {
it('returns null when the current turn has no tool calls', () => {
const context = extractFollowupSuggestionContext([
{ type: 'user', text: 'old turn' },
{ type: 'gemini', text: 'I edited a file earlier' },
h({ type: 'user', text: 'old turn' }),
h({ type: 'gemini', text: 'I edited a file earlier' }),
createToolGroup([
createTool({
name: 'Edit',
description: 'src/old.ts: before => after',
}),
]),
{ type: 'user', text: 'current turn' },
{ type: 'gemini', text: 'No tool calls this time' },
] satisfies HistoryItem[]);
h({ type: 'user', text: 'current turn' }),
h({ type: 'gemini', text: 'No tool calls this time' }),
]);
expect(context).toBeNull();
});
it('maps tool statuses for followup generation', () => {
const history: HistoryItem[] = [
{ type: 'user', text: 'current turn' },
{ type: 'gemini', text: 'The shell command failed' },
h({ type: 'user', text: 'current turn' }),
h({ type: 'gemini', text: 'The shell command failed' }),
createToolGroup([
createTool({
name: 'Shell',

View file

@ -40,6 +40,7 @@
"build-storybook": "storybook build"
},
"peerDependencies": {
"@qwen-code/qwen-code-core": ">=0.13.0",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},

View file

@ -1,244 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Shared Follow-up Suggestions State Logic for WebUI
*
* Browser-safe state management for follow-up suggestions.
*/
/**
* A single follow-up suggestion.
*/
export interface FollowupSuggestion {
/** The suggested command text */
text: string;
/** Optional description shown below the suggestion */
description?: string;
/** Priority for ranking (higher = more relevant) */
priority: number;
}
/**
* State for follow-up suggestions.
*/
export interface FollowupState {
/** Current suggestion text */
suggestion: string | null;
/** All available suggestions */
suggestions: FollowupSuggestion[];
/** Whether to show suggestion */
isVisible: boolean;
/** Index of current suggestion (for cycling) */
currentIndex: number;
}
/** Initial empty state. */
export const INITIAL_FOLLOWUP_STATE: FollowupState = {
suggestion: null,
suggestions: [],
isVisible: false,
currentIndex: 0,
};
/** Delay before showing suggestion after response completes */
const SUGGESTION_DELAY_MS = 300;
/** Debounce lock duration to prevent rapid-fire accepts */
const ACCEPT_DEBOUNCE_MS = 100;
export interface FollowupControllerOptions {
/** Whether the feature is enabled (checked when setting suggestions) */
enabled?: boolean;
/** Called whenever the internal state changes */
onStateChange: (state: FollowupState) => void;
/** Returns the latest accept callback */
getOnAccept?: () => ((text: string) => void) | undefined;
}
export interface FollowupControllerActions {
/** Set suggestions (with delayed show). Empty array clears immediately. */
setSuggestions: (suggestions: FollowupSuggestion[]) => void;
/** Accept the current suggestion and invoke onAccept callback */
accept: () => void;
/** Dismiss/clear suggestions */
dismiss: () => void;
/** Cycle to next suggestion */
next: () => void;
/** Cycle to previous suggestion */
previous: () => void;
/** Hard-clear all state and timers */
clear: () => void;
/** Clean up timers — call on unmount */
cleanup: () => void;
}
function clearState(): FollowupState {
return INITIAL_FOLLOWUP_STATE;
}
function setSuggestionsState(suggestions: FollowupSuggestion[]): FollowupState {
if (suggestions.length === 0) {
return clearState();
}
return {
suggestion: suggestions[0].text,
suggestions,
isVisible: true,
currentIndex: 0,
};
}
function getAcceptText(state: FollowupState): string | null {
if (
state.suggestions.length === 0 ||
state.currentIndex >= state.suggestions.length
) {
return null;
}
return state.suggestions[state.currentIndex].text;
}
function getNextState(state: FollowupState): FollowupState | null {
if (state.suggestions.length === 0) {
return null;
}
const nextIndex = (state.currentIndex + 1) % state.suggestions.length;
return {
...state,
currentIndex: nextIndex,
suggestion: state.suggestions[nextIndex].text,
};
}
function getPreviousState(state: FollowupState): FollowupState | null {
if (state.suggestions.length === 0) {
return null;
}
const previousIndex =
state.currentIndex === 0
? state.suggestions.length - 1
: state.currentIndex - 1;
return {
...state,
currentIndex: previousIndex,
suggestion: state.suggestions[previousIndex].text,
};
}
export function createFollowupController(
options: FollowupControllerOptions,
): FollowupControllerActions {
const { enabled = true, onStateChange, getOnAccept } = options;
let currentState: FollowupState = INITIAL_FOLLOWUP_STATE;
let timeoutId: ReturnType<typeof setTimeout> | null = null;
let accepting = false;
let acceptTimeoutId: ReturnType<typeof setTimeout> | null = null;
function applyState(next: FollowupState): void {
currentState = next;
onStateChange(next);
}
function clearTimers(): void {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
if (acceptTimeoutId) {
clearTimeout(acceptTimeoutId);
acceptTimeoutId = null;
}
}
const setSuggestions = (suggestions: FollowupSuggestion[]): void => {
if (!enabled) {
return;
}
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
if (suggestions.length === 0) {
applyState(clearState());
return;
}
timeoutId = setTimeout(() => {
applyState(setSuggestionsState(suggestions));
}, SUGGESTION_DELAY_MS);
};
const accept = (): void => {
if (accepting) {
return;
}
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
accepting = true;
const text = getAcceptText(currentState);
if (text === null) {
accepting = false;
return;
}
applyState(clearState());
queueMicrotask(() => {
getOnAccept?.()?.(text);
if (acceptTimeoutId) {
clearTimeout(acceptTimeoutId);
}
acceptTimeoutId = setTimeout(() => {
accepting = false;
}, ACCEPT_DEBOUNCE_MS);
});
};
const dismiss = (): void => {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
applyState(clearState());
};
const next = (): void => {
const nextState = getNextState(currentState);
if (nextState) {
applyState(nextState);
}
};
const previous = (): void => {
const previousState = getPreviousState(currentState);
if (previousState) {
applyState(previousState);
}
};
const clear = (): void => {
clearTimers();
accepting = false;
applyState(clearState());
};
const cleanup = (): void => {
clearTimers();
};
return { setSuggestions, accept, dismiss, next, previous, clear, cleanup };
}

View file

@ -15,11 +15,17 @@ import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
import {
INITIAL_FOLLOWUP_STATE,
createFollowupController,
type FollowupSuggestion,
type FollowupState,
} from './followupState.js';
} from '@qwen-code/qwen-code-core';
import type {
FollowupSuggestion,
FollowupState,
} from '@qwen-code/qwen-code-core';
export type { FollowupSuggestion, FollowupState } from './followupState.js';
// Re-export types from core for convenience
export type {
FollowupSuggestion,
FollowupState,
} from '@qwen-code/qwen-code-core';
/**
* Options for the hook
@ -59,25 +65,6 @@ export interface UseFollowupSuggestionsReturn {
* Delegates all timer/debounce/state logic to the shared
* `createFollowupController` from core. Adds a `getPlaceholder`
* helper specific to the WebUI input form.
*
* @example
* ```tsx
* const { state, getPlaceholder, setSuggestions, accept, dismiss, next, previous } = useFollowupSuggestions({
* onAccept: (suggestion) => setInputText(suggestion),
* });
*
* // After streaming completes:
* setSuggestions([{ text: 'commit this', priority: 100 }]);
*
* // Pass to InputForm:
* <InputForm
* followupState={state}
* onNextFollowup={next}
* onPreviousFollowup={previous}
* onAcceptFollowup={accept}
* onDismissFollowup={dismiss}
* />
* ```
*/
export function useFollowupSuggestions(
options: UseFollowupSuggestionsOptions = {},

View file

@ -42,7 +42,12 @@ export default defineConfig({
},
},
rollupOptions: {
external: ['react', 'react-dom', 'react/jsx-runtime'],
external: [
'react',
'react-dom',
'react/jsx-runtime',
'@qwen-code/qwen-code-core',
],
output: {
globals: {
react: 'React',