mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-10 01:29:17 +00:00
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:
parent
7c558819a7
commit
d0f38a5f3b
5 changed files with 37 additions and 280 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue